├── .copywrite.hcl ├── .github ├── CODEOWNERS ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── nightly.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── catalog-info.yaml ├── go.mod ├── go.sum ├── internal └── version │ └── version.go ├── scripts └── release │ ├── changelog_links.sh │ ├── release.sh │ └── signore-wrapper.sh └── tfexec ├── apply.go ├── apply_test.go ├── cmd.go ├── cmd_default.go ├── cmd_default_test.go ├── cmd_linux.go ├── cmd_linux_test.go ├── cmd_test.go ├── destroy.go ├── destroy_test.go ├── doc.go ├── errors.go ├── fmt.go ├── fmt_test.go ├── force_unlock.go ├── force_unlock_test.go ├── get.go ├── get_test.go ├── graph.go ├── graph_test.go ├── import.go ├── import_test.go ├── init.go ├── init_test.go ├── internal ├── e2etest │ ├── apply_test.go │ ├── cmp.go │ ├── destroy_test.go │ ├── doc.go │ ├── errors_test.go │ ├── fmt_test.go │ ├── force_unlock_test.go │ ├── graph_test.go │ ├── import_test.go │ ├── init_test.go │ ├── main_test.go │ ├── metadata_functions_test.go │ ├── output_test.go │ ├── plan_test.go │ ├── providers_lock_test.go │ ├── providers_schema_test.go │ ├── refresh_test.go │ ├── show_test.go │ ├── state_mv_test.go │ ├── state_pull_test.go │ ├── state_push_test.go │ ├── state_rm_test.go │ ├── taint_test.go │ ├── test_test.go │ ├── testdata │ │ ├── basic │ │ │ └── main.tf │ │ ├── basic_with_state │ │ │ ├── main.tf │ │ │ └── terraform.tfstate │ │ ├── bigint │ │ │ └── main.tf │ │ ├── cloud_backend │ │ │ └── main.tf │ │ ├── deep_module │ │ │ ├── foo │ │ │ │ ├── bar │ │ │ │ │ └── main.tf │ │ │ │ └── main.tf │ │ │ ├── main.tf │ │ │ ├── plan.json │ │ │ └── schemas.json │ │ ├── empty │ │ │ └── .empty │ │ ├── empty_with_tf_file │ │ │ └── main.tf │ │ ├── etcd_backend │ │ │ └── main.tf │ │ ├── import │ │ │ └── main.tf │ │ ├── inmem_backend │ │ │ └── main.tf │ │ ├── inmem_backend_locked │ │ │ └── main.tf │ │ ├── invalid │ │ │ └── main.tf │ │ ├── local_backend_non_default_state │ │ │ └── main.tf │ │ ├── non_default_planfile_012 │ │ │ ├── human_readable_output.txt │ │ │ ├── main.tf │ │ │ └── planfilefoo │ │ ├── non_default_planfile_013 │ │ │ ├── .gitignore │ │ │ ├── human_readable_output.txt │ │ │ ├── main.tf │ │ │ └── planfilefoo │ │ ├── non_default_planfile_014 │ │ │ ├── .gitignore │ │ │ ├── human_readable_output.txt │ │ │ ├── main.tf │ │ │ └── planfilefoo │ │ ├── non_default_statefile_012 │ │ │ ├── .gitignore │ │ │ ├── main.tf │ │ │ └── statefilefoo │ │ ├── non_default_statefile_013 │ │ │ ├── .gitignore │ │ │ ├── main.tf │ │ │ └── statefilefoo │ │ ├── non_default_statefile_014 │ │ │ ├── .gitignore │ │ │ ├── main.tf │ │ │ └── statefilefoo │ │ ├── pre_011_syntax │ │ │ ├── file1.golden.txt │ │ │ ├── file1.tf │ │ │ ├── file2.golden.txt │ │ │ └── file2.tf │ │ ├── registry_module │ │ │ ├── main.tf │ │ │ ├── plan.json │ │ │ └── schemas.json │ │ ├── remote_backend │ │ │ └── main.tf │ │ ├── sleep │ │ │ └── main.tf │ │ ├── test_command_failing │ │ │ ├── main.tf │ │ │ └── tests │ │ │ │ └── passthrough.tftest.hcl │ │ ├── test_command_passing │ │ │ ├── main.tf │ │ │ └── tests │ │ │ │ └── passthrough.tftest.hcl │ │ ├── tf99 │ │ │ └── main.tf │ │ ├── unformatted │ │ │ ├── file1.golden.txt │ │ │ ├── file1.tf │ │ │ ├── file2.golden.txt │ │ │ └── file2.tf │ │ ├── var │ │ │ └── main.tf │ │ └── workspaces │ │ │ ├── .terraform │ │ │ └── environment │ │ │ └── terraform.tfstate.d │ │ │ └── foo │ │ │ └── .keep │ ├── untaint_test.go │ ├── upgrade012_test.go │ ├── util_test.go │ ├── validate_test.go │ ├── version_test.go │ └── workspace_test.go └── testutil │ ├── logger.go │ ├── tfcache.go │ └── tfcache_find.go ├── metadata_functions.go ├── metadata_functions_test.go ├── options.go ├── output.go ├── output_test.go ├── plan.go ├── plan_test.go ├── providers_lock.go ├── providers_lock_test.go ├── providers_schema.go ├── providers_schema_test.go ├── refresh.go ├── refresh_test.go ├── show.go ├── show_test.go ├── sleepmock_test.go ├── state_mv.go ├── state_mv_test.go ├── state_pull.go ├── state_pull_test.go ├── state_push.go ├── state_push_test.go ├── state_rm.go ├── state_rm_test.go ├── taint.go ├── taint_test.go ├── terraform.go ├── terraform_test.go ├── test.go ├── test_test.go ├── untaint.go ├── untaint_test.go ├── upgrade012.go ├── upgrade012_test.go ├── upgrade013.go ├── upgrade013_test.go ├── validate.go ├── version.go ├── version_test.go ├── workspace_delete.go ├── workspace_delete_test.go ├── workspace_list.go ├── workspace_list_test.go ├── workspace_new.go ├── workspace_new_test.go ├── workspace_select.go ├── workspace_show.go └── workspace_show_test.go /.copywrite.hcl: -------------------------------------------------------------------------------- 1 | schema_version = 1 2 | 3 | project { 4 | license = "MPL-2.0" 5 | copyright_year = 2020 6 | 7 | header_ignore = [ 8 | "tfexec/internal/e2etest/testdata/**", 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This codebase has shared ownership and responsibility. 2 | 3 | * @hashicorp/terraform-core @hashicorp/terraform-core-plugins @hashicorp/tf-editor-experience-engineers 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | labels: ["dependencies"] 8 | - package-ecosystem: github-actions 9 | directory: / 10 | schedule: 11 | interval: weekly 12 | labels: 13 | - dependencies 14 | groups: 15 | github-actions-breaking: 16 | update-types: 17 | - major 18 | github-actions-backward-compatible: 19 | update-types: 20 | - minor 21 | - patch 22 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Related Issue 2 | 3 | Fixes # 4 | 5 | ## Description 6 | 7 | In plain English, describe your approach to addressing the issue linked above. For example, if you made a particular design decision, let us know why you chose this path instead of another solution. 8 | 9 | 10 | ## Rollback Plan 11 | 12 | - [ ] If a change needs to be reverted, we will roll out an update to the code within 7 days. 13 | 14 | ## Changes to Security Controls 15 | 16 | Are there any changes to security controls (access controls, encryption, logging) in this pull request? If so, explain. 17 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: Nightly E2E tests 2 | 3 | on: 4 | schedule: 5 | - cron: '0 4 * * *' 6 | workflow_dispatch: 7 | 8 | env: 9 | GOPROXY: https://proxy.golang.org/ 10 | 11 | jobs: 12 | # resolve-versions allows us to show resolved Go versions in job titles 13 | # for added clarity and quick orientation in a long list of past jobs 14 | resolve-versions: 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 5 17 | steps: 18 | - 19 | name: Resolve old stable version 20 | id: oldstable 21 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 22 | with: 23 | go-version: oldstable 24 | - 25 | name: Resolve stable version 26 | id: stable 27 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 28 | with: 29 | go-version: stable 30 | outputs: 31 | oldstable: ${{ steps.oldstable.outputs.go-version }} 32 | stable: ${{ steps.stable.outputs.go-version }} 33 | 34 | e2e-tests: 35 | name: e2e-tests (${{ matrix.os }}, go ${{ matrix.go_version }}) 36 | needs: resolve-versions 37 | runs-on: ${{ matrix.os }} 38 | timeout-minutes: 60 39 | strategy: 40 | fail-fast: false 41 | matrix: 42 | os: 43 | - ubuntu-latest 44 | - windows-latest 45 | - macos-latest 46 | go_version: 47 | - ${{ needs.resolve-versions.outputs.oldstable }} 48 | - ${{ needs.resolve-versions.outputs.stable }} 49 | steps: 50 | - 51 | name: Checkout 52 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 53 | - 54 | name: Set up Go 55 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 56 | with: 57 | go-version: ${{ matrix.go_version }} 58 | - 59 | name: Run E2E tests 60 | env: 61 | TFEXEC_E2ETEST_VERSIONS: refs/heads/main 62 | run: go test -race -timeout=30m -v ./tfexec/internal/e2etest 63 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write # for modifying version in-place & pushing tags 5 | packages: read # for downloading signore docker image 6 | 7 | on: 8 | workflow_dispatch: 9 | 10 | env: 11 | GOPROXY: https://proxy.golang.org/ 12 | 13 | jobs: 14 | release: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - 18 | name: Checkout 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | with: 21 | ssh-key: "${{ secrets.GIT_SSH_PRIVATE_KEY }}" 22 | - 23 | id: setup-signore-package 24 | uses: hashicorp/setup-signore-package@v1 25 | - 26 | name: Run release script 27 | env: 28 | SIGNORE_CLIENT_ID: ${{ secrets.SIGNORE_CLIENT_ID }} 29 | SIGNORE_CLIENT_SECRET: ${{ secrets.SIGNORE_CLIENT_SECRET }} 30 | SIGNORE_SIGNER: ${{ secrets.SIGNORE_SIGNER }} 31 | run: | 32 | mkdir -p ~/.ssh 33 | echo "${{ secrets.GIT_SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed22519 34 | ./scripts/release/release.sh 35 | rm -f ~/.ssh/id_ed22519 36 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | env: 12 | GOPROXY: https://proxy.golang.org/ 13 | 14 | jobs: 15 | copywrite: 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 3 18 | steps: 19 | - name: Checkout Repo 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | - name: Install copywrite 22 | uses: hashicorp/setup-copywrite@v1.1.3 23 | - name: Validate Header Compliance 24 | run: copywrite headers --plan 25 | 26 | # resolve-versions allows us to show resolved Go versions in job titles 27 | # for added clarity and quick orientation in a long list of past jobs 28 | resolve-versions: 29 | runs-on: ubuntu-latest 30 | timeout-minutes: 5 31 | steps: 32 | - 33 | name: Resolve old stable version 34 | id: oldstable 35 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 36 | with: 37 | go-version: oldstable 38 | - 39 | name: Resolve stable version 40 | id: stable 41 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 42 | with: 43 | go-version: stable 44 | outputs: 45 | oldstable: ${{ steps.oldstable.outputs.go-version }} 46 | stable: ${{ steps.stable.outputs.go-version }} 47 | 48 | static-checks: 49 | name: static-checks (go ${{ matrix.go_version }}) 50 | needs: resolve-versions 51 | runs-on: ubuntu-latest 52 | timeout-minutes: 10 53 | strategy: 54 | fail-fast: false 55 | matrix: 56 | go_version: 57 | - ${{ needs.resolve-versions.outputs.oldstable }} 58 | - ${{ needs.resolve-versions.outputs.stable }} 59 | steps: 60 | - 61 | name: Checkout 62 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 63 | - 64 | name: Set up Go 65 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 66 | with: 67 | go-version: ${{ matrix.go_version }} 68 | - 69 | name: Go fmt 70 | run: 71 | # exit 1 if any files need go fmt 72 | test -z $(gofmt -s -l .) 73 | - 74 | name: Go vet 75 | run: go vet ./... 76 | 77 | unit-tests: 78 | name: unit-tests (${{ matrix.os }}, go ${{ matrix.go_version }}) 79 | needs: 80 | - resolve-versions 81 | - static-checks 82 | runs-on: ${{ matrix.os }} 83 | timeout-minutes: 20 84 | strategy: 85 | fail-fast: false 86 | matrix: 87 | os: 88 | - ubuntu-latest 89 | - windows-latest 90 | - macos-latest 91 | go_version: 92 | - ${{ needs.resolve-versions.outputs.oldstable }} 93 | - ${{ needs.resolve-versions.outputs.stable }} 94 | steps: 95 | - 96 | name: Checkout 97 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 98 | - 99 | name: Set up Go 100 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 101 | with: 102 | go-version: ${{ matrix.go_version }} 103 | - 104 | name: Go mod download 105 | run: go mod download -x 106 | - 107 | name: Go mod verify 108 | run: go mod verify 109 | - 110 | name: Run build 111 | run: go build ./... 112 | - 113 | name: Run unit tests 114 | run: go test -cover -race $(go list ./... | grep -v /tfexec/internal/e2etest) 115 | 116 | e2e-tests: 117 | name: e2e-tests (${{ matrix.os }}, go ${{ matrix.go_version }}, terraform ${{ matrix.terraform_version }}) 118 | needs: 119 | - resolve-versions 120 | - static-checks 121 | - unit-tests 122 | runs-on: ${{ matrix.os }} 123 | timeout-minutes: 60 124 | strategy: 125 | fail-fast: false 126 | matrix: 127 | os: 128 | - ubuntu-latest 129 | - windows-latest 130 | - macos-latest 131 | go_version: 132 | - ${{ needs.resolve-versions.outputs.oldstable }} 133 | - ${{ needs.resolve-versions.outputs.stable }} 134 | terraform_version: 135 | - "0.12.31" 136 | - "0.13.7" 137 | - "0.14.11" 138 | - "0.15.5" 139 | - "1.0.11" 140 | - "1.1.9" 141 | - "1.2.9" 142 | - "1.3.10" 143 | - "1.4.7" 144 | - "1.5.7" 145 | - "1.6.6" 146 | - "1.7.5" 147 | - "1.8.5" 148 | - "1.9.8" 149 | - "1.10.4" 150 | - "refs/heads/main" 151 | steps: 152 | - 153 | name: Checkout 154 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 155 | - 156 | name: Set up Go 157 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 158 | with: 159 | go-version: ${{ matrix.go_version }} 160 | - 161 | name: Run E2E tests 162 | env: 163 | TFEXEC_E2ETEST_VERSIONS: ${{ matrix.terraform_version }} 164 | run: go test -race -timeout=30m -v ./tfexec/internal/e2etest 165 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | go.work* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/hashicorp/terraform-exec)](https://pkg.go.dev/github.com/hashicorp/terraform-exec) 2 | 3 | # terraform-exec 4 | 5 | A Go module for constructing and running [Terraform](https://terraform.io) CLI commands. Structured return values use the data types defined in [terraform-json](https://github.com/hashicorp/terraform-json). 6 | 7 | The [Terraform Plugin Framework](https://github.com/hashicorp/terraform-plugin-framework) is the canonical Go interface (SDK) for Terraform plugins using the gRPC protocol. This library is intended for use in Go programs that make use of Terraform's other interface, the CLI. Importing this library is preferable to importing `github.com/hashicorp/terraform/command`, because the latter is not intended for use outside Terraform Core. 8 | 9 | While terraform-exec is already widely used, please note that this module is **not yet at v1.0.0**, and that therefore breaking changes may occur in minor releases. 10 | 11 | We strictly follow [semantic versioning](https://semver.org). 12 | 13 | ## Go compatibility 14 | 15 | This library is built in Go, and uses the [support policy](https://golang.org/doc/devel/release.html#policy) of Go as its support policy. The two latest major releases of Go are supported by terraform-exec. 16 | 17 | Currently, that means Go **1.23** or later must be used. 18 | 19 | ## Terraform compatibility 20 | 21 | We generally follow [Terraform's own compatibility promises](https://developer.hashicorp.com/terraform/language/v1-compatibility-promises). i.e. **we recommend Terraform v1.x to be used alongside this library**. 22 | 23 | Given the nature of this library being used in automation, we maintain compatibility **on best effort basis** with latest minor versions from `0.12` and later. This does not imply coverage of all features or CLI surface, just that it shouldn't break in unexpected ways. 24 | 25 | ## Usage 26 | 27 | The `Terraform` struct must be initialised with `NewTerraform(workingDir, execPath)`. 28 | 29 | Top-level Terraform commands each have their own function, which will return either `error` or `(T, error)`, where `T` is a `terraform-json` type. 30 | 31 | 32 | ### Example 33 | 34 | 35 | ```go 36 | package main 37 | 38 | import ( 39 | "context" 40 | "fmt" 41 | "log" 42 | 43 | "github.com/hashicorp/go-version" 44 | "github.com/hashicorp/hc-install/product" 45 | "github.com/hashicorp/hc-install/releases" 46 | "github.com/hashicorp/terraform-exec/tfexec" 47 | ) 48 | 49 | func main() { 50 | installer := &releases.ExactVersion{ 51 | Product: product.Terraform, 52 | Version: version.Must(version.NewVersion("1.0.6")), 53 | } 54 | 55 | execPath, err := installer.Install(context.Background()) 56 | if err != nil { 57 | log.Fatalf("error installing Terraform: %s", err) 58 | } 59 | 60 | workingDir := "/path/to/working/dir" 61 | tf, err := tfexec.NewTerraform(workingDir, execPath) 62 | if err != nil { 63 | log.Fatalf("error running NewTerraform: %s", err) 64 | } 65 | 66 | err = tf.Init(context.Background(), tfexec.Upgrade(true)) 67 | if err != nil { 68 | log.Fatalf("error running Init: %s", err) 69 | } 70 | 71 | state, err := tf.Show(context.Background()) 72 | if err != nil { 73 | log.Fatalf("error running Show: %s", err) 74 | } 75 | 76 | fmt.Println(state.FormatVersion) // "0.1" 77 | } 78 | ``` 79 | 80 | ## Testing Terraform binaries 81 | 82 | The terraform-exec test suite contains end-to-end tests which run realistic workflows against a real Terraform binary using `tfexec.Terraform{}`. 83 | 84 | To run these tests with a local Terraform binary, set the environment variable `TFEXEC_E2ETEST_TERRAFORM_PATH` to its path and run: 85 | ```sh 86 | go test -timeout=20m ./tfexec/internal/e2etest 87 | ``` 88 | 89 | For more information on terraform-exec's test suite, please see Contributing below. 90 | 91 | ## Contributing 92 | 93 | Please see [CONTRIBUTING.md](./CONTRIBUTING.md). 94 | -------------------------------------------------------------------------------- /catalog-info.yaml: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | # 4 | # Intended for internal HashiCorp use only 5 | apiVersion: backstage.io/v1alpha1 6 | kind: Component 7 | metadata: 8 | name: terraform-exec 9 | description: Terraform CLI commands via Go 10 | annotations: 11 | github.com/project-slug: hashicorp/terraform-exec 12 | jira/project-key: TF 13 | jira/label: terraform-exec 14 | spec: 15 | type: library 16 | owner: team-tf-core 17 | lifecycle: production 18 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hashicorp/terraform-exec 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.0 6 | 7 | require ( 8 | github.com/google/go-cmp v0.7.0 9 | github.com/hashicorp/go-version v1.7.0 10 | github.com/hashicorp/hc-install v0.9.2 11 | github.com/hashicorp/terraform-json v0.25.0 12 | github.com/zclconf/go-cty v1.16.3 13 | github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b 14 | ) 15 | 16 | require ( 17 | dario.cat/mergo v1.0.0 // indirect 18 | github.com/Microsoft/go-winio v0.6.2 // indirect 19 | github.com/ProtonMail/go-crypto v1.1.6 // indirect 20 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 21 | github.com/cloudflare/circl v1.6.0 // indirect 22 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 23 | github.com/emirpasic/gods v1.18.1 // indirect 24 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 25 | github.com/go-git/go-billy/v5 v5.6.2 // indirect 26 | github.com/go-git/go-git/v5 v5.14.0 // indirect 27 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 28 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 29 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 30 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 31 | github.com/kevinburke/ssh_config v1.2.0 // indirect 32 | github.com/pjbgf/sha1cd v0.3.2 // indirect 33 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 34 | github.com/skeema/knownhosts v1.3.1 // indirect 35 | github.com/xanzy/ssh-agent v0.3.3 // indirect 36 | golang.org/x/crypto v0.36.0 // indirect 37 | golang.org/x/mod v0.24.0 // indirect 38 | golang.org/x/net v0.38.0 // indirect 39 | golang.org/x/sys v0.31.0 // indirect 40 | golang.org/x/text v0.23.0 // indirect 41 | gopkg.in/warnings.v0 v0.1.2 // indirect 42 | ) 43 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package version 5 | 6 | const version = "0.23.0" 7 | 8 | // ModuleVersion returns the current version of the github.com/hashicorp/terraform-exec Go module. 9 | // This is a function to allow for future possible enhancement using debug.BuildInfo. 10 | func ModuleVersion() string { 11 | return version 12 | } 13 | -------------------------------------------------------------------------------- /scripts/release/changelog_links.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright (c) HashiCorp, Inc. 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | 6 | # This script rewrites [GH-nnnn]-style references in the CHANGELOG.md file to 7 | # be Markdown links to the given github issues. 8 | # 9 | # This is run during releases so that the issue references in all of the 10 | # released items are presented as clickable links, but we can just use the 11 | # easy [GH-nnnn] shorthand for quickly adding items to the "Unrelease" section 12 | # while merging things between releases. 13 | 14 | set -e 15 | 16 | if [[ ! -f CHANGELOG.md ]]; then 17 | echo "ERROR: CHANGELOG.md not found in pwd." 18 | echo "Please run this from the root of the repository" 19 | exit 1 20 | fi 21 | 22 | if [[ `uname` == "Darwin" ]]; then 23 | echo "Using BSD sed" 24 | SED="sed -i.bak -E -e" 25 | else 26 | echo "Using GNU sed" 27 | SED="sed -i.bak -r -e" 28 | fi 29 | 30 | $SED 's/GH-([0-9]+)/\[#\1\]\(https:\/\/github.com\/hashicorp\/terraform-exec\/issues\/\1\)/g' -e 's/\[\[#(.+)([0-9])\)]$/(\[#\1\2))/g' CHANGELOG.md 31 | 32 | rm CHANGELOG.md.bak 33 | -------------------------------------------------------------------------------- /scripts/release/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright (c) HashiCorp, Inc. 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | 6 | set -e 7 | set -x 8 | 9 | # release.sh will: 10 | # 1. Modify changelog 11 | # 2. Run changelog links script 12 | # 3. Modify version in internal/version/version.go 13 | # 4. Commit and push changes 14 | # 5. Create a Git tag 15 | 16 | function pleaseUseGNUsed { 17 | echo "Please install GNU sed to your PATH as 'sed'." 18 | exit 1 19 | } 20 | 21 | function init { 22 | sed --version > /dev/null || pleaseUseGNUsed 23 | 24 | DATE=$(date '+%B %d, %Y') 25 | 26 | if [ "$CI" = true ] ; then 27 | git config --global user.email "proj-terraform-exec@hashicorp.com" 28 | git config --global user.name "terraform-exec [bot]" 29 | git config --global gpg.program scripts/release/signore-wrapper.sh 30 | fi 31 | 32 | TARGET_VERSION="$(getTargetVersion)" 33 | 34 | if [ "$TARGET_VERSION" = "" ] ; then 35 | printf "Target version not found in changelog, exiting\n" 36 | exit 1 37 | fi 38 | } 39 | 40 | semverRegex='\([0-9]\+\.[0-9]\+\.[0-9]\+\)\(-\?\)\([0-9a-zA-Z.]\+\)\?' 41 | 42 | function getTargetVersion { 43 | # parse target version from CHANGELOG 44 | sed -n 's/^# '"$semverRegex"' (Unreleased)$/\1\2\3/p' CHANGELOG.md 45 | } 46 | 47 | function modifyChangelog { 48 | sed -i "s/$TARGET_VERSION (Unreleased)$/$TARGET_VERSION ($DATE)/" CHANGELOG.md 49 | } 50 | 51 | function changelogLinks { 52 | ./scripts/release/changelog_links.sh 53 | } 54 | 55 | function changelogMain { 56 | printf "Modifying Changelog..." 57 | modifyChangelog 58 | printf "ok!\n" 59 | printf "Running Changelog Links..." 60 | changelogLinks 61 | printf "ok!\n" 62 | } 63 | 64 | function modifyVersionFiles { 65 | sed -i "s/const version =.*/const version = \"${TARGET_VERSION}\"/" internal/version/version.go 66 | } 67 | 68 | function commitChanges { 69 | git add CHANGELOG.md 70 | modifyVersionFiles 71 | git add internal/version/version.go 72 | 73 | if [ "$CI" = true ] ; then 74 | git commit --gpg-sign="${SIGNORE_SIGNER}" -m "v${TARGET_VERSION} [skip ci]" 75 | git tag -a -m "v${TARGET_VERSION}" -s -u "${SIGNORE_SIGNER}" "v${TARGET_VERSION}" 76 | else 77 | git commit -m "v${TARGET_VERSION} [skip ci]" 78 | git tag -a -m "v${TARGET_VERSION}" -s "v${TARGET_VERSION}" 79 | fi 80 | 81 | git push origin "${GITHUB_REF_NAME}" 82 | git push origin "v${TARGET_VERSION}" 83 | } 84 | 85 | function commitMain { 86 | printf "Committing Changes..." 87 | commitChanges 88 | printf "ok!\n" 89 | } 90 | 91 | function main { 92 | init 93 | changelogMain 94 | commitMain 95 | } 96 | 97 | main 98 | -------------------------------------------------------------------------------- /scripts/release/signore-wrapper.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright (c) HashiCorp, Inc. 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | printf '\n[GNUPG:] SIG_CREATED ' >&${1#--status-fd=} 6 | signore sign --file /dev/stdin --signer $3 2>/dev/null 7 | -------------------------------------------------------------------------------- /tfexec/apply_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestApplyCmd(t *testing.T) { 14 | td := t.TempDir() 15 | 16 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | // empty env, to avoid environ mismatch in testing 22 | tf.SetEnv(map[string]string{}) 23 | 24 | t.Run("basic", func(t *testing.T) { 25 | applyCmd, err := tf.applyCmd(context.Background(), 26 | Backup("testbackup"), 27 | LockTimeout("200s"), 28 | State("teststate"), 29 | StateOut("teststateout"), 30 | VarFile("foo.tfvars"), 31 | VarFile("bar.tfvars"), 32 | Lock(false), 33 | Parallelism(99), 34 | Refresh(false), 35 | Replace("aws_instance.test"), 36 | Replace("google_pubsub_topic.test"), 37 | Target("target1"), 38 | Target("target2"), 39 | Var("var1=foo"), 40 | Var("var2=bar"), 41 | Destroy(true), 42 | DirOrPlan("testfile"), 43 | ) 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | 48 | assertCmd(t, []string{ 49 | "apply", 50 | "-no-color", 51 | "-auto-approve", 52 | "-input=false", 53 | "-backup=testbackup", 54 | "-lock-timeout=200s", 55 | "-state=teststate", 56 | "-state-out=teststateout", 57 | "-var-file=foo.tfvars", 58 | "-var-file=bar.tfvars", 59 | "-lock=false", 60 | "-parallelism=99", 61 | "-refresh=false", 62 | "-replace=aws_instance.test", 63 | "-replace=google_pubsub_topic.test", 64 | "-destroy", 65 | "-target=target1", 66 | "-target=target2", 67 | "-var", "var1=foo", 68 | "-var", "var2=bar", 69 | "testfile", 70 | }, nil, applyCmd) 71 | }) 72 | 73 | t.Run("refresh-only operation", func(t *testing.T) { 74 | applyCmd, err := tf.applyCmd(context.Background(), 75 | RefreshOnly(true), 76 | ) 77 | if err != nil { 78 | t.Fatal(err) 79 | } 80 | 81 | assertCmd(t, []string{ 82 | "apply", 83 | "-no-color", 84 | "-auto-approve", 85 | "-input=false", 86 | "-lock=true", 87 | "-parallelism=10", 88 | "-refresh=true", 89 | "-refresh-only", 90 | }, nil, applyCmd) 91 | }) 92 | } 93 | 94 | func TestApplyJSONCmd(t *testing.T) { 95 | td := t.TempDir() 96 | 97 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 98 | if err != nil { 99 | t.Fatal(err) 100 | } 101 | 102 | // empty env, to avoid environ mismatch in testing 103 | tf.SetEnv(map[string]string{}) 104 | 105 | t.Run("basic", func(t *testing.T) { 106 | applyCmd, err := tf.applyJSONCmd(context.Background(), 107 | Backup("testbackup"), 108 | LockTimeout("200s"), 109 | State("teststate"), 110 | StateOut("teststateout"), 111 | VarFile("foo.tfvars"), 112 | VarFile("bar.tfvars"), 113 | Lock(false), 114 | Parallelism(99), 115 | Refresh(false), 116 | Replace("aws_instance.test"), 117 | Replace("google_pubsub_topic.test"), 118 | Target("target1"), 119 | Target("target2"), 120 | Var("var1=foo"), 121 | Var("var2=bar"), 122 | DirOrPlan("testfile"), 123 | ) 124 | if err != nil { 125 | t.Fatal(err) 126 | } 127 | 128 | assertCmd(t, []string{ 129 | "apply", 130 | "-no-color", 131 | "-auto-approve", 132 | "-input=false", 133 | "-backup=testbackup", 134 | "-lock-timeout=200s", 135 | "-state=teststate", 136 | "-state-out=teststateout", 137 | "-var-file=foo.tfvars", 138 | "-var-file=bar.tfvars", 139 | "-lock=false", 140 | "-parallelism=99", 141 | "-refresh=false", 142 | "-replace=aws_instance.test", 143 | "-replace=google_pubsub_topic.test", 144 | "-target=target1", 145 | "-target=target2", 146 | "-var", "var1=foo", 147 | "-var", "var2=bar", 148 | "-json", 149 | "testfile", 150 | }, nil, applyCmd) 151 | }) 152 | } 153 | 154 | func TestApplyCmd_AllowDeferral(t *testing.T) { 155 | td := t.TempDir() 156 | 157 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_Alpha_v1_9)) 158 | if err != nil { 159 | t.Fatal(err) 160 | } 161 | 162 | // empty env, to avoid environ mismatch in testing 163 | tf.SetEnv(map[string]string{}) 164 | 165 | t.Run("allow deferrals during apply", func(t *testing.T) { 166 | applyCmd, err := tf.applyCmd(context.Background(), 167 | AllowDeferral(true), 168 | ) 169 | if err != nil { 170 | t.Fatal(err) 171 | } 172 | 173 | assertCmd(t, []string{ 174 | "apply", 175 | "-no-color", 176 | "-auto-approve", 177 | "-input=false", 178 | "-lock=true", 179 | "-parallelism=10", 180 | "-refresh=true", 181 | "-allow-deferral", 182 | }, nil, applyCmd) 183 | }) 184 | } 185 | -------------------------------------------------------------------------------- /tfexec/cmd_default.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //go:build !linux 5 | // +build !linux 6 | 7 | package tfexec 8 | 9 | import ( 10 | "context" 11 | "fmt" 12 | "os/exec" 13 | "strings" 14 | "sync" 15 | ) 16 | 17 | func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error { 18 | var errBuf strings.Builder 19 | 20 | // check for early cancellation 21 | select { 22 | case <-ctx.Done(): 23 | return ctx.Err() 24 | default: 25 | } 26 | 27 | // Read stdout / stderr logs from pipe instead of setting cmd.Stdout and 28 | // cmd.Stderr because it can cause hanging when killing the command 29 | // https://github.com/golang/go/issues/23019 30 | stdoutWriter := mergeWriters(cmd.Stdout, tf.stdout) 31 | stderrWriter := mergeWriters(tf.stderr, &errBuf) 32 | 33 | cmd.Stderr = nil 34 | cmd.Stdout = nil 35 | 36 | stdoutPipe, err := cmd.StdoutPipe() 37 | if err != nil { 38 | return err 39 | } 40 | 41 | stderrPipe, err := cmd.StderrPipe() 42 | if err != nil { 43 | return err 44 | } 45 | 46 | err = cmd.Start() 47 | if ctx.Err() != nil { 48 | return cmdErr{ 49 | err: err, 50 | ctxErr: ctx.Err(), 51 | } 52 | } 53 | if err != nil { 54 | return err 55 | } 56 | 57 | var errStdout, errStderr error 58 | var wg sync.WaitGroup 59 | wg.Add(1) 60 | go func() { 61 | defer wg.Done() 62 | errStdout = writeOutput(ctx, stdoutPipe, stdoutWriter) 63 | }() 64 | 65 | wg.Add(1) 66 | go func() { 67 | defer wg.Done() 68 | errStderr = writeOutput(ctx, stderrPipe, stderrWriter) 69 | }() 70 | 71 | // Reads from pipes must be completed before calling cmd.Wait(). Otherwise 72 | // can cause a race condition 73 | wg.Wait() 74 | 75 | err = cmd.Wait() 76 | if ctx.Err() != nil { 77 | return cmdErr{ 78 | err: err, 79 | ctxErr: ctx.Err(), 80 | } 81 | } 82 | if err != nil { 83 | return fmt.Errorf("%w\n%s", err, errBuf.String()) 84 | } 85 | 86 | // Return error if there was an issue reading the std out/err 87 | if errStdout != nil && ctx.Err() != nil { 88 | return fmt.Errorf("%w\n%s", errStdout, errBuf.String()) 89 | } 90 | if errStderr != nil && ctx.Err() != nil { 91 | return fmt.Errorf("%w\n%s", errStderr, errBuf.String()) 92 | } 93 | 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /tfexec/cmd_default_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //go:build !linux 5 | // +build !linux 6 | 7 | package tfexec 8 | 9 | import ( 10 | "bytes" 11 | "context" 12 | "log" 13 | "strings" 14 | "testing" 15 | "time" 16 | ) 17 | 18 | func Test_runTerraformCmd_default(t *testing.T) { 19 | // Checks runTerraformCmd for race condition when using 20 | // go test -race -run Test_runTerraformCmd_default ./tfexec 21 | var buf bytes.Buffer 22 | 23 | tf := &Terraform{ 24 | logger: log.New(&buf, "", 0), 25 | execPath: "echo", 26 | } 27 | 28 | ctx, cancel := context.WithCancel(context.Background()) 29 | 30 | cmd := tf.buildTerraformCmd(ctx, nil, "hello tf-exec!") 31 | err := tf.runTerraformCmd(ctx, cmd) 32 | if err != nil { 33 | t.Fatal(err) 34 | } 35 | 36 | // Cancel stops the leaked go routine which logs an error 37 | cancel() 38 | time.Sleep(time.Second) 39 | if strings.Contains(buf.String(), "error from kill") { 40 | t.Fatal("canceling context should not lead to logging an error") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tfexec/cmd_linux.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "os/exec" 10 | "strings" 11 | "sync" 12 | "syscall" 13 | ) 14 | 15 | func (tf *Terraform) runTerraformCmd(ctx context.Context, cmd *exec.Cmd) error { 16 | var errBuf strings.Builder 17 | 18 | cmd.SysProcAttr = &syscall.SysProcAttr{ 19 | // kill children if parent is dead 20 | Pdeathsig: syscall.SIGKILL, 21 | // set process group ID 22 | Setpgid: true, 23 | } 24 | 25 | // check for early cancellation 26 | select { 27 | case <-ctx.Done(): 28 | return ctx.Err() 29 | default: 30 | } 31 | 32 | // Read stdout / stderr logs from pipe instead of setting cmd.Stdout and 33 | // cmd.Stderr because it can cause hanging when killing the command 34 | // https://github.com/golang/go/issues/23019 35 | stdoutWriter := mergeWriters(cmd.Stdout, tf.stdout) 36 | stderrWriter := mergeWriters(tf.stderr, &errBuf) 37 | 38 | cmd.Stderr = nil 39 | cmd.Stdout = nil 40 | 41 | stdoutPipe, err := cmd.StdoutPipe() 42 | if err != nil { 43 | return err 44 | } 45 | 46 | stderrPipe, err := cmd.StderrPipe() 47 | if err != nil { 48 | return err 49 | } 50 | 51 | err = cmd.Start() 52 | if ctx.Err() != nil { 53 | return cmdErr{ 54 | err: err, 55 | ctxErr: ctx.Err(), 56 | } 57 | } 58 | if err != nil { 59 | return err 60 | } 61 | 62 | var errStdout, errStderr error 63 | var wg sync.WaitGroup 64 | wg.Add(1) 65 | go func() { 66 | defer wg.Done() 67 | errStdout = writeOutput(ctx, stdoutPipe, stdoutWriter) 68 | }() 69 | 70 | wg.Add(1) 71 | go func() { 72 | defer wg.Done() 73 | errStderr = writeOutput(ctx, stderrPipe, stderrWriter) 74 | }() 75 | 76 | // Reads from pipes must be completed before calling cmd.Wait(). Otherwise 77 | // can cause a race condition 78 | wg.Wait() 79 | 80 | err = cmd.Wait() 81 | if ctx.Err() != nil { 82 | return cmdErr{ 83 | err: err, 84 | ctxErr: ctx.Err(), 85 | } 86 | } 87 | if err != nil { 88 | return fmt.Errorf("%w\n%s", err, errBuf.String()) 89 | } 90 | 91 | // Return error if there was an issue reading the std out/err 92 | if errStdout != nil && ctx.Err() != nil { 93 | return fmt.Errorf("%w\n%s", errStdout, errBuf.String()) 94 | } 95 | if errStderr != nil && ctx.Err() != nil { 96 | return fmt.Errorf("%w\n%s", errStderr, errBuf.String()) 97 | } 98 | 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /tfexec/cmd_linux_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "bytes" 8 | "context" 9 | "log" 10 | "strings" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | func Test_runTerraformCmd_linux(t *testing.T) { 16 | // Checks runTerraformCmd for race condition when using 17 | // go test -race -run Test_runTerraformCmd_linux ./tfexec -tags=linux 18 | var buf bytes.Buffer 19 | 20 | tf := &Terraform{ 21 | logger: log.New(&buf, "", 0), 22 | execPath: "echo", 23 | } 24 | 25 | ctx, cancel := context.WithCancel(context.Background()) 26 | 27 | cmd := tf.buildTerraformCmd(ctx, nil, "hello tf-exec!") 28 | err := tf.runTerraformCmd(ctx, cmd) 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | 33 | // Cancel stops the leaked go routine which logs an error 34 | cancel() 35 | time.Sleep(time.Second) 36 | if strings.Contains(buf.String(), "error from kill") { 37 | t.Fatal("canceling context should not lead to logging an error") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tfexec/cmd_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "fmt" 8 | "os/exec" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/hashicorp/terraform-exec/internal/version" 13 | ) 14 | 15 | func TestMergeUserAgent(t *testing.T) { 16 | for i, c := range []struct { 17 | expected string 18 | uas []string 19 | }{ 20 | {"foo/1 bar/2", []string{"foo/1", "bar/2"}}, 21 | {"foo/1 bar/2", []string{"foo/1 bar/2"}}, 22 | {"foo/1 bar/2", []string{"", "foo/1", "bar/2"}}, 23 | {"foo/1 bar/2", []string{"", "foo/1 bar/2"}}, 24 | {"foo/1 bar/2", []string{" ", "foo/1 bar/2"}}, 25 | {"foo/1 bar/2", []string{"foo/1", "", "bar/2"}}, 26 | {"foo/1 bar/2", []string{"foo/1", " ", "bar/2"}}, 27 | 28 | // comments 29 | {"foo/1 (bar/1 bar/2 bar/3) bar/2", []string{"foo/1 (bar/1 bar/2 bar/3)", "bar/2"}}, 30 | } { 31 | t.Run(fmt.Sprintf("%d %s", i, c.expected), func(t *testing.T) { 32 | actual := mergeUserAgent(c.uas...) 33 | if c.expected != actual { 34 | t.Fatalf("expected %q, got %q", c.expected, actual) 35 | } 36 | }) 37 | } 38 | } 39 | 40 | func defaultEnv() []string { 41 | return []string{ 42 | "CHECKPOINT_DISABLE=", 43 | "TF_APPEND_USER_AGENT=HashiCorp-terraform-exec/" + version.ModuleVersion(), 44 | "TF_IN_AUTOMATION=1", 45 | "TF_LOG=", 46 | "TF_LOG_CORE=", 47 | "TF_LOG_PATH=", 48 | "TF_LOG_PROVIDER=", 49 | } 50 | } 51 | 52 | func assertCmd(t *testing.T, expectedArgs []string, expectedEnv map[string]string, actual *exec.Cmd) { 53 | t.Helper() 54 | 55 | // check args (skip path) 56 | actualArgs := actual.Args[1:] 57 | 58 | if len(expectedArgs) != len(actualArgs) { 59 | t.Fatalf("args mismatch\n\nexpected:\n%v\n\ngot:\n%v", strings.Join(expectedArgs, " "), strings.Join(actualArgs, " ")) 60 | } 61 | for i := range expectedArgs { 62 | if expectedArgs[i] != actualArgs[i] { 63 | t.Fatalf("args mismatch, expected %q, got %q\n\nfull expected:\n%v\n\nfull actual:\n%v", expectedArgs[i], actualArgs[i], strings.Join(expectedArgs, " "), strings.Join(actualArgs, " ")) 64 | } 65 | } 66 | 67 | // check environment 68 | expectedEnv = envMap(append(defaultEnv(), envSlice(expectedEnv)...)) 69 | actualEnv := envMap(actual.Env) 70 | 71 | if len(actualEnv) != len(actual.Env) { 72 | t.Fatalf("duplication in actual env, unable to assert: %v", actual.Env) 73 | } 74 | 75 | // ignore tempdir related env vars from comparison 76 | for _, k := range []string{"TMPDIR", "TMP", "TEMP", "USERPROFILE"} { 77 | if _, ok := expectedEnv[k]; ok { 78 | t.Logf("ignoring env var %q", k) 79 | delete(expectedEnv, k) 80 | } 81 | 82 | if _, ok := actualEnv[k]; ok { 83 | t.Logf("ignoring env var %q", k) 84 | delete(actualEnv, k) 85 | } 86 | } 87 | 88 | // compare against raw slice len incase of duplication or something 89 | if len(expectedEnv) != len(actualEnv) { 90 | t.Fatalf("env mismatch\n\nexpected:\n%v\n\ngot:\n%v", envSlice(expectedEnv), actual.Env) 91 | } 92 | 93 | for k, ev := range expectedEnv { 94 | av, ok := actualEnv[k] 95 | if !ok { 96 | t.Fatalf("env mismatch, missing %q\n\nfull expected:\n%v\n\nfull actual:\n%v", k, envSlice(expectedEnv), envSlice(actualEnv)) 97 | } 98 | if ev != av { 99 | t.Fatalf("env mismatch, expected %q, got %q\n\nfull expected:\n%v\n\nfull actual:\n%v", ev, av, envSlice(expectedEnv), envSlice(actualEnv)) 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /tfexec/destroy_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestDestroyCmd(t *testing.T) { 14 | td := t.TempDir() 15 | 16 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | // empty env, to avoid environ mismatch in testing 22 | tf.SetEnv(map[string]string{}) 23 | 24 | t.Run("defaults", func(t *testing.T) { 25 | destroyCmd, err := tf.destroyCmd(context.Background()) 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | 30 | assertCmd(t, []string{ 31 | "destroy", 32 | "-no-color", 33 | "-auto-approve", 34 | "-input=false", 35 | "-lock-timeout=0s", 36 | "-lock=true", 37 | "-parallelism=10", 38 | "-refresh=true", 39 | }, nil, destroyCmd) 40 | }) 41 | 42 | t.Run("override all defaults", func(t *testing.T) { 43 | destroyCmd, err := tf.destroyCmd(context.Background(), Backup("testbackup"), LockTimeout("200s"), State("teststate"), StateOut("teststateout"), VarFile("testvarfile"), Lock(false), Parallelism(99), Refresh(false), Target("target1"), Target("target2"), Var("var1=foo"), Var("var2=bar"), Dir("destroydir")) 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | 48 | assertCmd(t, []string{ 49 | "destroy", 50 | "-no-color", 51 | "-auto-approve", 52 | "-input=false", 53 | "-backup=testbackup", 54 | "-lock-timeout=200s", 55 | "-state=teststate", 56 | "-state-out=teststateout", 57 | "-var-file=testvarfile", 58 | "-lock=false", 59 | "-parallelism=99", 60 | "-refresh=false", 61 | "-target=target1", 62 | "-target=target2", 63 | "-var", "var1=foo", 64 | "-var", "var2=bar", 65 | "destroydir", 66 | }, nil, destroyCmd) 67 | }) 68 | } 69 | 70 | func TestDestroyJSONCmd(t *testing.T) { 71 | td := t.TempDir() 72 | 73 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 74 | if err != nil { 75 | t.Fatal(err) 76 | } 77 | 78 | // empty env, to avoid environ mismatch in testing 79 | tf.SetEnv(map[string]string{}) 80 | 81 | t.Run("defaults", func(t *testing.T) { 82 | destroyCmd, err := tf.destroyJSONCmd(context.Background()) 83 | if err != nil { 84 | t.Fatal(err) 85 | } 86 | 87 | assertCmd(t, []string{ 88 | "destroy", 89 | "-no-color", 90 | "-auto-approve", 91 | "-input=false", 92 | "-lock-timeout=0s", 93 | "-lock=true", 94 | "-parallelism=10", 95 | "-refresh=true", 96 | "-json", 97 | }, nil, destroyCmd) 98 | }) 99 | 100 | t.Run("override all defaults", func(t *testing.T) { 101 | destroyCmd, err := tf.destroyJSONCmd(context.Background(), Backup("testbackup"), LockTimeout("200s"), State("teststate"), StateOut("teststateout"), VarFile("testvarfile"), Lock(false), Parallelism(99), Refresh(false), Target("target1"), Target("target2"), Var("var1=foo"), Var("var2=bar"), Dir("destroydir")) 102 | if err != nil { 103 | t.Fatal(err) 104 | } 105 | 106 | assertCmd(t, []string{ 107 | "destroy", 108 | "-no-color", 109 | "-auto-approve", 110 | "-input=false", 111 | "-backup=testbackup", 112 | "-lock-timeout=200s", 113 | "-state=teststate", 114 | "-state-out=teststateout", 115 | "-var-file=testvarfile", 116 | "-lock=false", 117 | "-parallelism=99", 118 | "-refresh=false", 119 | "-target=target1", 120 | "-target=target2", 121 | "-var", "var1=foo", 122 | "-var", "var2=bar", 123 | "-json", 124 | "destroydir", 125 | }, nil, destroyCmd) 126 | }) 127 | } 128 | -------------------------------------------------------------------------------- /tfexec/doc.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | // Package tfexec exposes functionality for constructing and running Terraform 5 | // CLI commands. Structured return values use the data types defined in the 6 | // github.com/hashicorp/terraform-json package. 7 | package tfexec 8 | -------------------------------------------------------------------------------- /tfexec/errors.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | ) 10 | 11 | // this file contains non-parsed exported errors 12 | 13 | type ErrNoSuitableBinary struct { 14 | err error 15 | } 16 | 17 | func (e *ErrNoSuitableBinary) Error() string { 18 | return fmt.Sprintf("no suitable terraform binary could be found: %s", e.err.Error()) 19 | } 20 | 21 | func (e *ErrNoSuitableBinary) Unwrap() error { 22 | return e.err 23 | } 24 | 25 | // ErrVersionMismatch is returned when the detected Terraform version is not compatible with the 26 | // command or flags being used in this invocation. 27 | type ErrVersionMismatch struct { 28 | MinInclusive string 29 | MaxExclusive string 30 | Actual string 31 | } 32 | 33 | func (e *ErrVersionMismatch) Error() string { 34 | return fmt.Sprintf("unexpected version %s (min: %s, max: %s)", e.Actual, e.MinInclusive, e.MaxExclusive) 35 | } 36 | 37 | // ErrManualEnvVar is returned when an env var that should be set programatically via an option or method 38 | // is set via the manual environment passing functions. 39 | type ErrManualEnvVar struct { 40 | Name string 41 | } 42 | 43 | func (err *ErrManualEnvVar) Error() string { 44 | return fmt.Sprintf("manual setting of env var %q detected", err.Name) 45 | } 46 | 47 | // cmdErr is a custom error type to be returned when a cmd exits with a context 48 | // error such as context.Canceled or context.DeadlineExceeded. 49 | // The type is specifically designed to respond true to errors.Is for these two 50 | // errors. 51 | // See https://github.com/golang/go/issues/21880 for why this is necessary. 52 | type cmdErr struct { 53 | err error 54 | ctxErr error 55 | } 56 | 57 | func (e cmdErr) Is(target error) bool { 58 | switch target { 59 | case context.DeadlineExceeded, context.Canceled: 60 | return e.ctxErr == context.DeadlineExceeded || e.ctxErr == context.Canceled 61 | } 62 | return false 63 | } 64 | 65 | func (e cmdErr) Unwrap() error { 66 | return e.err 67 | } 68 | 69 | func (e cmdErr) Error() string { 70 | return e.err.Error() 71 | } 72 | -------------------------------------------------------------------------------- /tfexec/fmt.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "io" 10 | "os/exec" 11 | "path/filepath" 12 | "strings" 13 | ) 14 | 15 | type formatConfig struct { 16 | recursive bool 17 | dir string 18 | } 19 | 20 | var defaultFormatConfig = formatConfig{ 21 | recursive: false, 22 | } 23 | 24 | type FormatOption interface { 25 | configureFormat(*formatConfig) 26 | } 27 | 28 | func (opt *RecursiveOption) configureFormat(conf *formatConfig) { 29 | conf.recursive = opt.recursive 30 | } 31 | 32 | func (opt *DirOption) configureFormat(conf *formatConfig) { 33 | conf.dir = opt.path 34 | } 35 | 36 | // FormatString formats a passed string, given a path to Terraform. 37 | func FormatString(ctx context.Context, execPath string, content string) (string, error) { 38 | tf, err := NewTerraform(filepath.Dir(execPath), execPath) 39 | if err != nil { 40 | return "", err 41 | } 42 | 43 | return tf.FormatString(ctx, content) 44 | } 45 | 46 | // FormatString formats a passed string. 47 | func (tf *Terraform) FormatString(ctx context.Context, content string) (string, error) { 48 | in := strings.NewReader(content) 49 | var outBuf strings.Builder 50 | err := tf.Format(ctx, in, &outBuf) 51 | if err != nil { 52 | return "", err 53 | } 54 | return outBuf.String(), nil 55 | } 56 | 57 | // Format performs formatting on the unformatted io.Reader (as stdin to the CLI) and returns 58 | // the formatted result on the formatted io.Writer. 59 | func (tf *Terraform) Format(ctx context.Context, unformatted io.Reader, formatted io.Writer) error { 60 | cmd, err := tf.formatCmd(ctx, nil, Dir("-")) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | cmd.Stdin = unformatted 66 | cmd.Stdout = mergeWriters(cmd.Stdout, formatted) 67 | 68 | return tf.runTerraformCmd(ctx, cmd) 69 | } 70 | 71 | // FormatWrite attempts to format and modify all config files in the working or selected (via DirOption) directory. 72 | func (tf *Terraform) FormatWrite(ctx context.Context, opts ...FormatOption) error { 73 | for _, o := range opts { 74 | switch o := o.(type) { 75 | case *DirOption: 76 | if o.path == "-" { 77 | return fmt.Errorf("a path of \"-\" is not supported for this method, please use FormatString") 78 | } 79 | } 80 | } 81 | 82 | cmd, err := tf.formatCmd(ctx, []string{"-write=true", "-list=false", "-diff=false"}, opts...) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | return tf.runTerraformCmd(ctx, cmd) 88 | } 89 | 90 | // FormatCheck returns true if the config files in the working or selected (via DirOption) directory are already formatted. 91 | func (tf *Terraform) FormatCheck(ctx context.Context, opts ...FormatOption) (bool, []string, error) { 92 | for _, o := range opts { 93 | switch o := o.(type) { 94 | case *DirOption: 95 | if o.path == "-" { 96 | return false, nil, fmt.Errorf("a path of \"-\" is not supported for this method, please use FormatString") 97 | } 98 | } 99 | } 100 | 101 | cmd, err := tf.formatCmd(ctx, []string{"-write=false", "-list=true", "-diff=false", "-check=true"}, opts...) 102 | if err != nil { 103 | return false, nil, err 104 | } 105 | 106 | var outBuf strings.Builder 107 | cmd.Stdout = mergeWriters(cmd.Stdout, &outBuf) 108 | 109 | err = tf.runTerraformCmd(ctx, cmd) 110 | if err == nil { 111 | return true, nil, nil 112 | } 113 | if cmd.ProcessState.ExitCode() == 3 { 114 | // unformatted, parse the file list 115 | 116 | files := []string{} 117 | lines := strings.Split(strings.Replace(outBuf.String(), "\r\n", "\n", -1), "\n") 118 | for _, l := range lines { 119 | l = strings.TrimSpace(l) 120 | if l == "" { 121 | continue 122 | } 123 | files = append(files, l) 124 | } 125 | 126 | return false, files, nil 127 | } 128 | return false, nil, err 129 | } 130 | 131 | func (tf *Terraform) formatCmd(ctx context.Context, args []string, opts ...FormatOption) (*exec.Cmd, error) { 132 | err := tf.compatible(ctx, tf0_7_7, nil) 133 | if err != nil { 134 | return nil, fmt.Errorf("fmt was first introduced in Terraform 0.7.7: %w", err) 135 | } 136 | 137 | c := defaultFormatConfig 138 | 139 | for _, o := range opts { 140 | switch o.(type) { 141 | case *RecursiveOption: 142 | err := tf.compatible(ctx, tf0_12_0, nil) 143 | if err != nil { 144 | return nil, fmt.Errorf("-recursive was added to fmt in Terraform 0.12: %w", err) 145 | } 146 | } 147 | 148 | o.configureFormat(&c) 149 | } 150 | 151 | args = append([]string{"fmt", "-no-color"}, args...) 152 | 153 | if c.recursive { 154 | args = append(args, "-recursive") 155 | } 156 | 157 | if c.dir != "" { 158 | args = append(args, c.dir) 159 | } 160 | 161 | return tf.buildTerraformCmd(ctx, nil, args...), nil 162 | } 163 | -------------------------------------------------------------------------------- /tfexec/fmt_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "runtime" 9 | "testing" 10 | 11 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 12 | ) 13 | 14 | func TestFormatCmd(t *testing.T) { 15 | if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { 16 | t.Skip("Terraform for darwin/arm64 is not available until v1") 17 | } 18 | 19 | td := t.TempDir() 20 | 21 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1_1)) 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | 26 | // empty env, to avoid environ mismatch in testing 27 | tf.SetEnv(map[string]string{}) 28 | 29 | t.Run("defaults", func(t *testing.T) { 30 | fmtCmd, err := tf.formatCmd(context.Background(), []string{}) 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | 35 | assertCmd(t, []string{ 36 | "fmt", 37 | "-no-color", 38 | }, nil, fmtCmd) 39 | }) 40 | 41 | t.Run("override all defaults", func(t *testing.T) { 42 | fmtCmd, err := tf.formatCmd(context.Background(), 43 | []string{"string1", "string2"}, 44 | Recursive(true), 45 | Dir("mydir")) 46 | if err != nil { 47 | t.Fatal(err) 48 | } 49 | 50 | assertCmd(t, []string{ 51 | "fmt", 52 | "-no-color", 53 | "string1", 54 | "string2", 55 | "-recursive", 56 | "mydir", 57 | }, nil, fmtCmd) 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /tfexec/force_unlock.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "os/exec" 10 | ) 11 | 12 | type forceUnlockConfig struct { 13 | dir string 14 | } 15 | 16 | var defaultForceUnlockOptions = forceUnlockConfig{} 17 | 18 | type ForceUnlockOption interface { 19 | configureForceUnlock(*forceUnlockConfig) 20 | } 21 | 22 | func (opt *DirOption) configureForceUnlock(conf *forceUnlockConfig) { 23 | conf.dir = opt.path 24 | } 25 | 26 | // ForceUnlock represents the `terraform force-unlock` command 27 | func (tf *Terraform) ForceUnlock(ctx context.Context, lockID string, opts ...ForceUnlockOption) error { 28 | unlockCmd, err := tf.forceUnlockCmd(ctx, lockID, opts...) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | if err := tf.runTerraformCmd(ctx, unlockCmd); err != nil { 34 | return err 35 | } 36 | 37 | return nil 38 | } 39 | 40 | func (tf *Terraform) forceUnlockCmd(ctx context.Context, lockID string, opts ...ForceUnlockOption) (*exec.Cmd, error) { 41 | c := defaultForceUnlockOptions 42 | 43 | for _, o := range opts { 44 | o.configureForceUnlock(&c) 45 | } 46 | args := []string{"force-unlock", "-no-color", "-force"} 47 | 48 | // positional arguments 49 | args = append(args, lockID) 50 | 51 | // optional positional arguments 52 | if c.dir != "" { 53 | err := tf.compatible(ctx, nil, tf0_15_0) 54 | if err != nil { 55 | return nil, fmt.Errorf("[DIR] option was removed in Terraform v0.15.0") 56 | } 57 | args = append(args, c.dir) 58 | } 59 | 60 | return tf.buildTerraformCmd(ctx, nil, args...), nil 61 | } 62 | -------------------------------------------------------------------------------- /tfexec/force_unlock_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "runtime" 9 | "testing" 10 | 11 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 12 | ) 13 | 14 | func TestForceUnlockCmd(t *testing.T) { 15 | td := t.TempDir() 16 | 17 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1_1)) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | 22 | // empty env, to avoid environ mismatch in testing 23 | tf.SetEnv(map[string]string{}) 24 | 25 | t.Run("defaults", func(t *testing.T) { 26 | forceUnlockCmd, err := tf.forceUnlockCmd(context.Background(), "12345") 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | 31 | assertCmd(t, []string{ 32 | "force-unlock", 33 | "-no-color", 34 | "-force", 35 | "12345", 36 | }, nil, forceUnlockCmd) 37 | }) 38 | } 39 | 40 | // The optional final positional [DIR] argument is available 41 | // until v0.15.0. 42 | func TestForceUnlockCmd_pre015(t *testing.T) { 43 | if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { 44 | t.Skip("Terraform for darwin/arm64 is not available until v1") 45 | } 46 | 47 | td := t.TempDir() 48 | 49 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest014)) 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | 54 | // empty env, to avoid environ mismatch in testing 55 | tf.SetEnv(map[string]string{}) 56 | 57 | t.Run("override all defaults", func(t *testing.T) { 58 | forceUnlockCmd, err := tf.forceUnlockCmd(context.Background(), "12345", Dir("mydir")) 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | 63 | assertCmd(t, []string{ 64 | "force-unlock", 65 | "-no-color", 66 | "-force", 67 | "12345", 68 | "mydir", 69 | }, nil, forceUnlockCmd) 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /tfexec/get.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "os/exec" 10 | ) 11 | 12 | type getCmdConfig struct { 13 | dir string 14 | update bool 15 | } 16 | 17 | // GetCmdOption represents options used in the Get method. 18 | type GetCmdOption interface { 19 | configureGet(*getCmdConfig) 20 | } 21 | 22 | func (opt *DirOption) configureGet(conf *getCmdConfig) { 23 | conf.dir = opt.path 24 | } 25 | 26 | func (opt *UpdateOption) configureGet(conf *getCmdConfig) { 27 | conf.update = opt.update 28 | } 29 | 30 | // Get represents the terraform get subcommand. 31 | func (tf *Terraform) Get(ctx context.Context, opts ...GetCmdOption) error { 32 | cmd, err := tf.getCmd(ctx, opts...) 33 | if err != nil { 34 | return err 35 | } 36 | return tf.runTerraformCmd(ctx, cmd) 37 | } 38 | 39 | func (tf *Terraform) getCmd(ctx context.Context, opts ...GetCmdOption) (*exec.Cmd, error) { 40 | c := getCmdConfig{} 41 | 42 | for _, o := range opts { 43 | o.configureGet(&c) 44 | } 45 | 46 | args := []string{"get", "-no-color"} 47 | 48 | args = append(args, "-update="+fmt.Sprint(c.update)) 49 | 50 | if c.dir != "" { 51 | args = append(args, c.dir) 52 | } 53 | 54 | return tf.buildTerraformCmd(ctx, nil, args...), nil 55 | } 56 | -------------------------------------------------------------------------------- /tfexec/get_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestGetCmd(t *testing.T) { 14 | td := t.TempDir() 15 | 16 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | // empty env, to avoid environ mismatch in testing 22 | tf.SetEnv(map[string]string{}) 23 | 24 | t.Run("basic", func(t *testing.T) { 25 | getCmd, err := tf.getCmd(context.Background()) 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | 30 | assertCmd(t, []string{ 31 | "get", 32 | "-no-color", 33 | "-update=false", 34 | }, nil, getCmd) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /tfexec/graph.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "os/exec" 10 | "strings" 11 | ) 12 | 13 | type graphConfig struct { 14 | plan string 15 | drawCycles bool 16 | graphType string 17 | } 18 | 19 | var defaultGraphOptions = graphConfig{} 20 | 21 | type GraphOption interface { 22 | configureGraph(*graphConfig) 23 | } 24 | 25 | func (opt *GraphPlanOption) configureGraph(conf *graphConfig) { 26 | conf.plan = opt.file 27 | } 28 | 29 | func (opt *DrawCyclesOption) configureGraph(conf *graphConfig) { 30 | conf.drawCycles = opt.drawCycles 31 | } 32 | 33 | func (opt *GraphTypeOption) configureGraph(conf *graphConfig) { 34 | conf.graphType = opt.graphType 35 | } 36 | 37 | func (tf *Terraform) Graph(ctx context.Context, opts ...GraphOption) (string, error) { 38 | graphCmd, err := tf.graphCmd(ctx, opts...) 39 | if err != nil { 40 | return "", err 41 | } 42 | var outBuf strings.Builder 43 | graphCmd.Stdout = &outBuf 44 | err = tf.runTerraformCmd(ctx, graphCmd) 45 | if err != nil { 46 | return "", err 47 | } 48 | 49 | return outBuf.String(), nil 50 | 51 | } 52 | 53 | func (tf *Terraform) graphCmd(ctx context.Context, opts ...GraphOption) (*exec.Cmd, error) { 54 | c := defaultGraphOptions 55 | 56 | for _, o := range opts { 57 | o.configureGraph(&c) 58 | } 59 | 60 | args := []string{"graph"} 61 | 62 | if c.plan != "" { 63 | // plan was a positional argument prior to Terraform 0.15.0. Ensure proper use by checking version. 64 | if err := tf.compatible(ctx, tf0_15_0, nil); err == nil { 65 | args = append(args, "-plan="+c.plan) 66 | } else { 67 | args = append(args, c.plan) 68 | } 69 | } 70 | 71 | if c.drawCycles { 72 | err := tf.compatible(ctx, tf0_5_0, nil) 73 | if err != nil { 74 | return nil, fmt.Errorf("-draw-cycles was first introduced in Terraform 0.5.0: %w", err) 75 | } 76 | args = append(args, "-draw-cycles") 77 | } 78 | 79 | if c.graphType != "" { 80 | err := tf.compatible(ctx, tf0_8_0, nil) 81 | if err != nil { 82 | return nil, fmt.Errorf("-graph-type was first introduced in Terraform 0.8.0: %w", err) 83 | } 84 | args = append(args, "-type="+c.graphType) 85 | } 86 | 87 | return tf.buildTerraformCmd(ctx, nil, args...), nil 88 | } 89 | -------------------------------------------------------------------------------- /tfexec/graph_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "runtime" 9 | "testing" 10 | 11 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 12 | ) 13 | 14 | func TestGraphCmd_v013(t *testing.T) { 15 | if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { 16 | t.Skip("Terraform for darwin/arm64 is not available until v1") 17 | } 18 | 19 | td := t.TempDir() 20 | 21 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest013)) 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | 26 | // empty env, to avoid environ mismatch in testing 27 | tf.SetEnv(map[string]string{}) 28 | 29 | t.Run("defaults", func(t *testing.T) { 30 | graphCmd, _ := tf.graphCmd(context.Background()) 31 | 32 | assertCmd(t, []string{ 33 | "graph", 34 | }, nil, graphCmd) 35 | }) 36 | 37 | t.Run("override all defaults", func(t *testing.T) { 38 | graphCmd, _ := tf.graphCmd(context.Background(), 39 | GraphPlan("teststate"), 40 | DrawCycles(true), 41 | GraphType("output")) 42 | 43 | assertCmd(t, []string{ 44 | "graph", 45 | "teststate", 46 | "-draw-cycles", 47 | "-type=output", 48 | }, nil, graphCmd) 49 | }) 50 | } 51 | 52 | func TestGraphCmd_v1(t *testing.T) { 53 | td := t.TempDir() 54 | 55 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 56 | if err != nil { 57 | t.Fatal(err) 58 | } 59 | 60 | // empty env, to avoid environ mismatch in testing 61 | tf.SetEnv(map[string]string{}) 62 | 63 | t.Run("defaults", func(t *testing.T) { 64 | graphCmd, _ := tf.graphCmd(context.Background()) 65 | 66 | assertCmd(t, []string{ 67 | "graph", 68 | }, nil, graphCmd) 69 | }) 70 | 71 | t.Run("override all defaults", func(t *testing.T) { 72 | graphCmd, _ := tf.graphCmd(context.Background(), 73 | GraphPlan("teststate"), 74 | DrawCycles(true), 75 | GraphType("output")) 76 | 77 | assertCmd(t, []string{ 78 | "graph", 79 | "-plan=teststate", 80 | "-draw-cycles", 81 | "-type=output", 82 | }, nil, graphCmd) 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /tfexec/import.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "os/exec" 9 | "strconv" 10 | ) 11 | 12 | type importConfig struct { 13 | addr string 14 | id string 15 | backup string 16 | config string 17 | allowMissingConfig bool 18 | lock bool 19 | lockTimeout string 20 | reattachInfo ReattachInfo 21 | state string 22 | stateOut string 23 | vars []string 24 | varFiles []string 25 | } 26 | 27 | var defaultImportOptions = importConfig{ 28 | allowMissingConfig: false, 29 | lock: true, 30 | lockTimeout: "0s", 31 | } 32 | 33 | // ImportOption represents options used in the Import method. 34 | type ImportOption interface { 35 | configureImport(*importConfig) 36 | } 37 | 38 | func (opt *BackupOption) configureImport(conf *importConfig) { 39 | conf.backup = opt.path 40 | } 41 | 42 | func (opt *ConfigOption) configureImport(conf *importConfig) { 43 | conf.config = opt.path 44 | } 45 | 46 | func (opt *AllowMissingConfigOption) configureImport(conf *importConfig) { 47 | conf.allowMissingConfig = opt.allowMissingConfig 48 | } 49 | 50 | func (opt *LockOption) configureImport(conf *importConfig) { 51 | conf.lock = opt.lock 52 | } 53 | 54 | func (opt *LockTimeoutOption) configureImport(conf *importConfig) { 55 | conf.lockTimeout = opt.timeout 56 | } 57 | 58 | func (opt *ReattachOption) configureImport(conf *importConfig) { 59 | conf.reattachInfo = opt.info 60 | } 61 | 62 | func (opt *StateOption) configureImport(conf *importConfig) { 63 | conf.state = opt.path 64 | } 65 | 66 | func (opt *StateOutOption) configureImport(conf *importConfig) { 67 | conf.stateOut = opt.path 68 | } 69 | 70 | func (opt *VarOption) configureImport(conf *importConfig) { 71 | conf.vars = append(conf.vars, opt.assignment) 72 | } 73 | 74 | func (opt *VarFileOption) configureImport(conf *importConfig) { 75 | conf.varFiles = append(conf.varFiles, opt.path) 76 | } 77 | 78 | // Import represents the terraform import subcommand. 79 | func (tf *Terraform) Import(ctx context.Context, address, id string, opts ...ImportOption) error { 80 | cmd, err := tf.importCmd(ctx, address, id, opts...) 81 | if err != nil { 82 | return err 83 | } 84 | return tf.runTerraformCmd(ctx, cmd) 85 | } 86 | 87 | func (tf *Terraform) importCmd(ctx context.Context, address, id string, opts ...ImportOption) (*exec.Cmd, error) { 88 | c := defaultImportOptions 89 | 90 | for _, o := range opts { 91 | o.configureImport(&c) 92 | } 93 | 94 | args := []string{"import", "-no-color", "-input=false"} 95 | 96 | // string opts: only pass if set 97 | if c.backup != "" { 98 | args = append(args, "-backup="+c.backup) 99 | } 100 | if c.config != "" { 101 | args = append(args, "-config="+c.config) 102 | } 103 | if c.lockTimeout != "" { 104 | args = append(args, "-lock-timeout="+c.lockTimeout) 105 | } 106 | if c.state != "" { 107 | args = append(args, "-state="+c.state) 108 | } 109 | if c.stateOut != "" { 110 | args = append(args, "-state-out="+c.stateOut) 111 | } 112 | for _, vf := range c.varFiles { 113 | args = append(args, "-var-file="+vf) 114 | } 115 | 116 | // boolean and numerical opts: always pass 117 | args = append(args, "-lock="+strconv.FormatBool(c.lock)) 118 | 119 | // unary flags: pass if true 120 | if c.allowMissingConfig { 121 | args = append(args, "-allow-missing-config") 122 | } 123 | 124 | // string slice opts: split into separate args 125 | if c.vars != nil { 126 | for _, v := range c.vars { 127 | args = append(args, "-var", v) 128 | } 129 | } 130 | 131 | // required args, always pass 132 | args = append(args, address, id) 133 | 134 | mergeEnv := map[string]string{} 135 | if c.reattachInfo != nil { 136 | reattachStr, err := c.reattachInfo.marshalString() 137 | if err != nil { 138 | return nil, err 139 | } 140 | mergeEnv[reattachEnvVar] = reattachStr 141 | } 142 | 143 | return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil 144 | } 145 | -------------------------------------------------------------------------------- /tfexec/import_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestImportCmd(t *testing.T) { 14 | td := t.TempDir() 15 | 16 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | // empty env, to avoid environ mismatch in testing 22 | tf.SetEnv(map[string]string{}) 23 | 24 | t.Run("defaults", func(t *testing.T) { 25 | importCmd, err := tf.importCmd(context.Background(), "my-addr", "my-id") 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | 30 | assertCmd(t, []string{ 31 | "import", 32 | "-no-color", 33 | "-input=false", 34 | "-lock-timeout=0s", 35 | "-lock=true", 36 | "my-addr", 37 | "my-id", 38 | }, nil, importCmd) 39 | }) 40 | 41 | t.Run("override all defaults", func(t *testing.T) { 42 | importCmd, err := tf.importCmd(context.Background(), "my-addr2", "my-id2", 43 | Backup("testbackup"), 44 | LockTimeout("200s"), 45 | State("teststate"), 46 | StateOut("teststateout"), 47 | VarFile("testvarfile"), 48 | Lock(false), 49 | Var("var1=foo"), 50 | Var("var2=bar"), 51 | AllowMissingConfig(true), 52 | ) 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | 57 | assertCmd(t, []string{ 58 | "import", 59 | "-no-color", 60 | "-input=false", 61 | "-backup=testbackup", 62 | "-lock-timeout=200s", 63 | "-state=teststate", 64 | "-state-out=teststateout", 65 | "-var-file=testvarfile", 66 | "-lock=false", 67 | "-allow-missing-config", 68 | "-var", "var1=foo", 69 | "-var", "var2=bar", 70 | "my-addr2", 71 | "my-id2", 72 | }, nil, importCmd) 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /tfexec/init_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "runtime" 9 | "testing" 10 | 11 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 12 | ) 13 | 14 | func TestInitCmd_v012(t *testing.T) { 15 | if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { 16 | t.Skip("Terraform for darwin/arm64 is not available until v1") 17 | } 18 | 19 | td := t.TempDir() 20 | 21 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest012)) 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | 26 | // empty env, to avoid environ mismatch in testing 27 | tf.SetEnv(map[string]string{}) 28 | 29 | t.Run("defaults", func(t *testing.T) { 30 | // defaults 31 | initCmd, err := tf.initCmd(context.Background()) 32 | if err != nil { 33 | t.Fatal(err) 34 | } 35 | 36 | assertCmd(t, []string{ 37 | "init", 38 | "-no-color", 39 | "-input=false", 40 | "-lock-timeout=0s", 41 | "-backend=true", 42 | "-get=true", 43 | "-upgrade=false", 44 | "-lock=true", 45 | "-get-plugins=true", 46 | "-verify-plugins=true", 47 | }, nil, initCmd) 48 | }) 49 | 50 | t.Run("override all defaults", func(t *testing.T) { 51 | initCmd, err := tf.initCmd(context.Background(), Backend(false), BackendConfig("confpath1"), BackendConfig("confpath2"), ForceCopy(true), FromModule("testsource"), Get(false), GetPlugins(false), Lock(false), LockTimeout("999s"), PluginDir("testdir1"), PluginDir("testdir2"), Reconfigure(true), Upgrade(true), VerifyPlugins(false), Dir("initdir")) 52 | if err != nil { 53 | t.Fatal(err) 54 | } 55 | 56 | assertCmd(t, []string{ 57 | "init", 58 | "-no-color", 59 | "-input=false", 60 | "-from-module=testsource", 61 | "-lock-timeout=999s", 62 | "-backend=false", 63 | "-get=false", 64 | "-upgrade=true", 65 | "-lock=false", 66 | "-get-plugins=false", 67 | "-verify-plugins=false", 68 | "-force-copy", 69 | "-reconfigure", 70 | "-backend-config=confpath1", 71 | "-backend-config=confpath2", 72 | "-plugin-dir=testdir1", 73 | "-plugin-dir=testdir2", 74 | "initdir", 75 | }, nil, initCmd) 76 | }) 77 | } 78 | 79 | func TestInitCmd_v1(t *testing.T) { 80 | td := t.TempDir() 81 | 82 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 83 | if err != nil { 84 | t.Fatal(err) 85 | } 86 | 87 | // empty env, to avoid environ mismatch in testing 88 | tf.SetEnv(map[string]string{}) 89 | 90 | t.Run("defaults", func(t *testing.T) { 91 | // defaults 92 | initCmd, err := tf.initCmd(context.Background()) 93 | if err != nil { 94 | t.Fatal(err) 95 | } 96 | 97 | assertCmd(t, []string{ 98 | "init", 99 | "-no-color", 100 | "-input=false", 101 | "-backend=true", 102 | "-get=true", 103 | "-upgrade=false", 104 | }, nil, initCmd) 105 | }) 106 | 107 | t.Run("override all defaults", func(t *testing.T) { 108 | initCmd, err := tf.initCmd(context.Background(), Backend(false), BackendConfig("confpath1"), BackendConfig("confpath2"), FromModule("testsource"), Get(false), PluginDir("testdir1"), PluginDir("testdir2"), Reconfigure(true), Upgrade(true), Dir("initdir")) 109 | if err != nil { 110 | t.Fatal(err) 111 | } 112 | 113 | assertCmd(t, []string{ 114 | "init", 115 | "-no-color", 116 | "-input=false", 117 | "-from-module=testsource", 118 | "-backend=false", 119 | "-get=false", 120 | "-upgrade=true", 121 | "-reconfigure", 122 | "-backend-config=confpath1", 123 | "-backend-config=confpath2", 124 | "-plugin-dir=testdir1", 125 | "-plugin-dir=testdir2", 126 | "initdir", 127 | }, nil, initCmd) 128 | }) 129 | } 130 | 131 | func TestInitJSONCmd(t *testing.T) { 132 | td := t.TempDir() 133 | 134 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1_9)) 135 | if err != nil { 136 | t.Fatal(err) 137 | } 138 | 139 | // empty env, to avoid environ mismatch in testing 140 | tf.SetEnv(map[string]string{}) 141 | 142 | t.Run("defaults", func(t *testing.T) { 143 | // defaults 144 | initCmd, err := tf.initJSONCmd(context.Background()) 145 | if err != nil { 146 | t.Fatal(err) 147 | } 148 | 149 | assertCmd(t, []string{ 150 | "init", 151 | "-no-color", 152 | "-input=false", 153 | "-backend=true", 154 | "-get=true", 155 | "-upgrade=false", 156 | "-json", 157 | }, nil, initCmd) 158 | }) 159 | 160 | t.Run("override all defaults", func(t *testing.T) { 161 | initCmd, err := tf.initJSONCmd(context.Background(), Backend(false), BackendConfig("confpath1"), BackendConfig("confpath2"), FromModule("testsource"), Get(false), PluginDir("testdir1"), PluginDir("testdir2"), Reconfigure(true), Upgrade(true), Dir("initdir")) 162 | if err != nil { 163 | t.Fatal(err) 164 | } 165 | 166 | assertCmd(t, []string{ 167 | "init", 168 | "-no-color", 169 | "-input=false", 170 | "-from-module=testsource", 171 | "-backend=false", 172 | "-get=false", 173 | "-upgrade=true", 174 | "-reconfigure", 175 | "-backend-config=confpath1", 176 | "-backend-config=confpath2", 177 | "-plugin-dir=testdir1", 178 | "-plugin-dir=testdir2", 179 | "-json", 180 | "initdir", 181 | }, nil, initCmd) 182 | }) 183 | } 184 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/apply_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "io" 9 | "regexp" 10 | "testing" 11 | 12 | "github.com/hashicorp/go-version" 13 | 14 | "github.com/hashicorp/terraform-exec/tfexec" 15 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 16 | ) 17 | 18 | var ( 19 | applyDestroyMinVersion = version.Must(version.NewVersion("0.15.2")) 20 | ) 21 | 22 | func TestApply(t *testing.T) { 23 | runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 24 | err := tf.Init(context.Background()) 25 | if err != nil { 26 | t.Fatalf("error running Init in test directory: %s", err) 27 | } 28 | 29 | err = tf.Apply(context.Background()) 30 | if err != nil { 31 | t.Fatalf("error running Apply: %s", err) 32 | } 33 | }) 34 | } 35 | 36 | func TestApplyJSON_TF014AndEarlier(t *testing.T) { 37 | versions := []string{testutil.Latest011, testutil.Latest012, testutil.Latest013, testutil.Latest014} 38 | 39 | runTestWithVersions(t, versions, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 40 | err := tf.Init(context.Background()) 41 | if err != nil { 42 | t.Fatalf("error running Init in test directory: %s", err) 43 | } 44 | 45 | re := regexp.MustCompile("terraform apply -json was added in 0.15.3") 46 | 47 | err = tf.ApplyJSON(context.Background(), io.Discard) 48 | if err != nil && !re.MatchString(err.Error()) { 49 | t.Fatalf("error running Apply: %s", err) 50 | } 51 | }) 52 | } 53 | 54 | func TestApplyJSON_TF015AndLater(t *testing.T) { 55 | versions := []string{testutil.Latest015, testutil.Latest_v1, testutil.Latest_v1_1} 56 | 57 | runTestWithVersions(t, versions, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 58 | err := tf.Init(context.Background()) 59 | if err != nil { 60 | t.Fatalf("error running Init in test directory: %s", err) 61 | } 62 | 63 | err = tf.ApplyJSON(context.Background(), io.Discard) 64 | if err != nil { 65 | t.Fatalf("error running Apply: %s", err) 66 | } 67 | }) 68 | } 69 | 70 | func TestApplyDestroy(t *testing.T) { 71 | runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 72 | if tfv.LessThan(applyDestroyMinVersion) { 73 | t.Skip("terraform apply -destroy was added in Terraform 0.15.2, so test is not valid") 74 | } 75 | err := tf.Init(context.Background()) 76 | if err != nil { 77 | t.Fatalf("error running Init in test directory: %s", err) 78 | } 79 | 80 | err = tf.Apply(context.Background()) 81 | if err != nil { 82 | t.Fatalf("error running Apply: %s", err) 83 | } 84 | 85 | err = tf.Apply(context.Background(), tfexec.Destroy(true)) 86 | if err != nil { 87 | t.Fatalf("error running Apply -destroy: %s", err) 88 | } 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/cmp.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "github.com/google/go-cmp/cmp" 8 | "github.com/google/go-cmp/cmp/cmpopts" 9 | tfjson "github.com/hashicorp/terraform-json" 10 | "github.com/zclconf/go-cty-debug/ctydebug" 11 | ) 12 | 13 | // comparison functions for tfjson structs used in tests 14 | 15 | // diffState returns a human-readable report of the differences between two 16 | // state values. It returns an empty string if the two values are equal. 17 | func diffState(expected *tfjson.State, actual *tfjson.State) string { 18 | return cmp.Diff(expected, actual, cmpopts.IgnoreFields(tfjson.State{}, "TerraformVersion"), cmpopts.IgnoreFields(tfjson.State{}, "useJSONNumber")) 19 | } 20 | 21 | // diffPlan returns a human-readable report of the differences between two 22 | // plan values. It returns an empty string if the two values are equal. 23 | func diffPlan(expected *tfjson.Plan, actual *tfjson.Plan, opts ...cmp.Option) string { 24 | opts = append(opts, cmpopts.IgnoreFields(tfjson.Plan{}, "TerraformVersion", "useJSONNumber")) 25 | 26 | return cmp.Diff(expected, actual, opts...) 27 | } 28 | 29 | // diffSchema returns a human-readable report of the differences between two 30 | // schema values. It returns an empty string if the two values are equal. 31 | func diffSchema(expected *tfjson.ProviderSchemas, actual *tfjson.ProviderSchemas) string { 32 | return cmp.Diff(expected, actual, ctydebug.CmpOptions) 33 | } 34 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/destroy_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "io" 9 | "regexp" 10 | "testing" 11 | 12 | "github.com/hashicorp/go-version" 13 | 14 | "github.com/hashicorp/terraform-exec/tfexec" 15 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 16 | ) 17 | 18 | func TestDestroy(t *testing.T) { 19 | runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 20 | err := tf.Init(context.Background()) 21 | if err != nil { 22 | t.Fatalf("error running Init in test directory: %s", err) 23 | } 24 | 25 | err = tf.Apply(context.Background()) 26 | if err != nil { 27 | t.Fatalf("error running Apply: %s", err) 28 | } 29 | 30 | err = tf.Destroy(context.Background()) 31 | if err != nil { 32 | t.Fatalf("error running Destroy: %s", err) 33 | } 34 | }) 35 | } 36 | 37 | func TestDestroyJSON_TF014AndEarlier(t *testing.T) { 38 | versions := []string{testutil.Latest011, testutil.Latest012, testutil.Latest013, testutil.Latest014} 39 | 40 | runTestWithVersions(t, versions, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 41 | err := tf.Init(context.Background()) 42 | if err != nil { 43 | t.Fatalf("error running Init in test directory: %s", err) 44 | } 45 | 46 | re := regexp.MustCompile("terraform destroy -json was added in 0.15.3") 47 | 48 | err = tf.DestroyJSON(context.Background(), io.Discard) 49 | if err != nil && !re.MatchString(err.Error()) { 50 | t.Fatalf("error running Apply: %s", err) 51 | } 52 | }) 53 | } 54 | 55 | func TestDestroyJSON_TF015AndLater(t *testing.T) { 56 | versions := []string{testutil.Latest015, testutil.Latest_v1, testutil.Latest_v1_1} 57 | 58 | runTestWithVersions(t, versions, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 59 | err := tf.Init(context.Background()) 60 | if err != nil { 61 | t.Fatalf("error running Init in test directory: %s", err) 62 | } 63 | 64 | err = tf.DestroyJSON(context.Background(), io.Discard) 65 | if err != nil { 66 | t.Fatalf("error running Apply: %s", err) 67 | } 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/doc.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | // Package e2etest contains end-to-end acceptance tests for the tfexec 5 | // package. It aims to cover as many realistic use cases for tfexec as possible; 6 | // to serve as a smoke test for the incidental usage of hc-install with tfexec; 7 | // and, eventually, to define the known and expected behaviour of the entire 8 | // Terraform CLI. 9 | // Test files inside the tfexec package are intended as unit tests covering the 10 | // behaviour of *Cmd functions. 11 | package e2etest 12 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/fmt_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "io" 9 | "io/ioutil" 10 | "path/filepath" 11 | "reflect" 12 | "strings" 13 | "testing" 14 | "time" 15 | 16 | "github.com/hashicorp/go-version" 17 | 18 | "github.com/hashicorp/terraform-exec/tfexec" 19 | ) 20 | 21 | func TestFormatString(t *testing.T) { 22 | runTest(t, "", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 23 | unformatted := strings.TrimSpace(` 24 | resource "foo" "bar" { 25 | baz = 1 26 | qux = 2 27 | } 28 | `) 29 | 30 | expected := strings.TrimSpace(` 31 | resource "foo" "bar" { 32 | baz = 1 33 | qux = 2 34 | } 35 | `) 36 | 37 | actual, err := tf.FormatString(context.Background(), unformatted) 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | 42 | actual = strings.TrimSpace(actual) 43 | 44 | if actual != expected { 45 | t.Fatalf("expected:\n%s\ngot:\n%s\n", expected, actual) 46 | } 47 | }) 48 | } 49 | 50 | func TestFormatCheck(t *testing.T) { 51 | runTest(t, "unformatted", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 52 | checksums := map[string]uint32{ 53 | "file1.tf": checkSum(t, filepath.Join(tf.WorkingDir(), "file1.tf")), 54 | "file2.tf": checkSum(t, filepath.Join(tf.WorkingDir(), "file2.tf")), 55 | } 56 | 57 | formatted, files, err := tf.FormatCheck(context.Background()) 58 | if err != nil { 59 | t.Fatalf("error from FormatCheck: %T %q", err, err) 60 | } 61 | 62 | if formatted { 63 | t.Fatal("expected unformatted") 64 | } 65 | 66 | if !reflect.DeepEqual(files, []string{"file1.tf", "file2.tf"}) { 67 | t.Fatalf("unexpected files list: %#v", files) 68 | } 69 | 70 | for file, checksum := range checksums { 71 | if checksum != checkSum(t, filepath.Join(tf.WorkingDir(), file)) { 72 | t.Fatalf("%s should not have changed", file) 73 | } 74 | } 75 | }) 76 | } 77 | 78 | func TestFormatWrite(t *testing.T) { 79 | runTest(t, "unformatted", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 80 | err := tf.FormatWrite(context.Background()) 81 | if err != nil { 82 | t.Fatalf("error from FormatWrite: %T %q", err, err) 83 | } 84 | 85 | for file, golden := range map[string]string{ 86 | "file1.tf": "file1.golden.txt", 87 | "file2.tf": "file2.golden.txt", 88 | } { 89 | textFilesEqual(t, filepath.Join(tf.WorkingDir(), golden), filepath.Join(tf.WorkingDir(), file)) 90 | } 91 | }) 92 | } 93 | 94 | func TestFormat(t *testing.T) { 95 | runTest(t, "", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 96 | unformatted := strings.TrimSpace(` 97 | resource "foo" "bar" { 98 | baz = 1 99 | qux = 2 100 | } 101 | `) 102 | 103 | expected := strings.TrimSpace(` 104 | resource "foo" "bar" { 105 | baz = 1 106 | qux = 2 107 | } 108 | `) 109 | 110 | start := time.Now() 111 | var actual strings.Builder 112 | err := tf.Format(context.Background(), strings.NewReader(unformatted), &actual) 113 | if err != nil { 114 | t.Fatal(err) 115 | } 116 | duration := time.Since(start) 117 | t.Logf("formatting took %dms", duration.Milliseconds()) 118 | 119 | actualString := strings.TrimSpace(actual.String()) 120 | if actualString != expected { 121 | t.Fatalf("expected:\n%s\ngot:\n%s\n", expected, actualString) 122 | } 123 | }) 124 | } 125 | 126 | func TestFormat_warmFormatter(t *testing.T) { 127 | runTest(t, "", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 128 | unformatted := strings.TrimSpace(` 129 | resource "foo" "bar" { 130 | baz = 1 131 | qux = 2 132 | } 133 | `) 134 | 135 | expected := strings.TrimSpace(` 136 | resource "foo" "bar" { 137 | baz = 1 138 | qux = 2 139 | } 140 | `) 141 | 142 | inR, inW := io.Pipe() 143 | outR, outW := io.Pipe() 144 | 145 | go func() { 146 | err := tf.Format(context.Background(), inR, outW) 147 | if err != nil { 148 | outW.CloseWithError(err) 149 | } 150 | _ = outW.Close() 151 | }() 152 | 153 | t.Log("Sleeping while CLI is warmed...") 154 | time.Sleep(5 * time.Second) 155 | t.Log("Sending unformatted data...") 156 | start := time.Now() 157 | _, err := inW.Write([]byte(unformatted)) 158 | if err != nil { 159 | t.Fatal(err) 160 | } 161 | err = inW.Close() 162 | if err != nil { 163 | t.Fatal(err) 164 | } 165 | 166 | actual, err := ioutil.ReadAll(outR) 167 | if err != nil { 168 | t.Fatal(err) 169 | } 170 | duration := time.Since(start) 171 | t.Logf("formatting took %dms", duration.Milliseconds()) 172 | 173 | actualString := strings.TrimSpace(string(actual)) 174 | if actualString != expected { 175 | t.Fatalf("expected:\n%s\ngot:\n%s\n", expected, actualString) 176 | } 177 | }) 178 | } 179 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/force_unlock_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/go-version" 11 | 12 | "github.com/hashicorp/terraform-exec/tfexec" 13 | ) 14 | 15 | // LockID set in the test fixture 16 | const inmemLockID = "2b6a6738-5dd5-50d6-c0ae-f6352977666b" 17 | 18 | var forceUnlockDirArgMaxVersion = version.Must(version.NewVersion("0.15.0")) 19 | 20 | func TestForceUnlock(t *testing.T) { 21 | runTest(t, "inmem_backend_locked", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 22 | err := tf.Init(context.Background()) 23 | if err != nil { 24 | t.Fatalf("error running Init: %v", err) 25 | } 26 | 27 | err = tf.ForceUnlock(context.Background(), inmemLockID) 28 | if err != nil { 29 | t.Fatalf("error running ForceUnlock: %v", err) 30 | } 31 | }) 32 | runTest(t, "inmem_backend_locked", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 33 | if tfv.GreaterThanOrEqual(forceUnlockDirArgMaxVersion) { 34 | t.Skip("legacy positional path argument deprecated in favor of global -chdir flag") 35 | } 36 | err := tf.Init(context.Background()) 37 | if err != nil { 38 | t.Fatalf("error running Init: %v", err) 39 | } 40 | 41 | err = tf.ForceUnlock(context.Background(), inmemLockID, tfexec.Dir(tf.WorkingDir())) 42 | if err != nil { 43 | t.Fatalf("error running ForceUnlock: %v", err) 44 | } 45 | }) 46 | runTest(t, "inmem_backend_locked", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 47 | err := tf.Init(context.Background()) 48 | if err != nil { 49 | t.Fatalf("error running Init: %v", err) 50 | } 51 | 52 | err = tf.ForceUnlock(context.Background(), "badlockid") 53 | if err == nil { 54 | t.Fatalf("expected error when running ForceUnlock with invalid lock id") 55 | } 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/graph_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/google/go-cmp/cmp" 11 | "github.com/hashicorp/go-version" 12 | 13 | "github.com/hashicorp/terraform-exec/tfexec" 14 | ) 15 | 16 | func TestGraph(t *testing.T) { 17 | runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 18 | err := tf.Init(context.Background()) 19 | if err != nil { 20 | t.Fatalf("error running Init in test directory: %s", err) 21 | } 22 | 23 | err = tf.Apply(context.Background()) 24 | if err != nil { 25 | t.Fatalf("error running Apply: %s", err) 26 | } 27 | 28 | graphOutput, err := tf.Graph(context.Background()) 29 | if err != nil { 30 | t.Fatalf("error running Graph: %s", err) 31 | } 32 | 33 | if diff := cmp.Diff(expectedGraphOutput(tfv), graphOutput); diff != "" { 34 | t.Fatalf("Graph output does not match: %s", diff) 35 | } 36 | }) 37 | } 38 | 39 | func expectedGraphOutput(tfv *version.Version) string { 40 | v := tfv.Core() 41 | 42 | if v.LessThan(v0_12_0) { 43 | // TF <=0.11.15 44 | return `digraph { 45 | compound = "true" 46 | newrank = "true" 47 | subgraph "root" { 48 | "[root] null_resource.foo" [label = "null_resource.foo", shape = "box"] 49 | "[root] provider.null" [label = "provider.null", shape = "diamond"] 50 | "[root] meta.count-boundary (count boundary fixup)" -> "[root] null_resource.foo" 51 | "[root] null_resource.foo" -> "[root] provider.null" 52 | "[root] provider.null (close)" -> "[root] null_resource.foo" 53 | "[root] root" -> "[root] meta.count-boundary (count boundary fixup)" 54 | "[root] root" -> "[root] provider.null (close)" 55 | } 56 | } 57 | 58 | ` 59 | } 60 | 61 | if v.GreaterThanOrEqual(v0_12_0) && v.LessThan(v0_13_0) { 62 | // TF 0.12.20 - 0.12.31 63 | return `digraph { 64 | compound = "true" 65 | newrank = "true" 66 | subgraph "root" { 67 | "[root] null_resource.foo" [label = "null_resource.foo", shape = "box"] 68 | "[root] provider.null" [label = "provider.null", shape = "diamond"] 69 | "[root] meta.count-boundary (EachMode fixup)" -> "[root] null_resource.foo" 70 | "[root] null_resource.foo" -> "[root] provider.null" 71 | "[root] provider.null (close)" -> "[root] null_resource.foo" 72 | "[root] root" -> "[root] meta.count-boundary (EachMode fixup)" 73 | "[root] root" -> "[root] provider.null (close)" 74 | } 75 | } 76 | 77 | ` 78 | } 79 | 80 | if v.GreaterThanOrEqual(v0_13_0) && v.LessThan(v1_1) { 81 | // 0.13.0 - 1.0.11 82 | return `digraph { 83 | compound = "true" 84 | newrank = "true" 85 | subgraph "root" { 86 | "[root] null_resource.foo (expand)" [label = "null_resource.foo", shape = "box"] 87 | "[root] provider[\"registry.terraform.io/hashicorp/null\"]" [label = "provider[\"registry.terraform.io/hashicorp/null\"]", shape = "diamond"] 88 | "[root] meta.count-boundary (EachMode fixup)" -> "[root] null_resource.foo (expand)" 89 | "[root] null_resource.foo (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/null\"]" 90 | "[root] provider[\"registry.terraform.io/hashicorp/null\"] (close)" -> "[root] null_resource.foo (expand)" 91 | "[root] root" -> "[root] meta.count-boundary (EachMode fixup)" 92 | "[root] root" -> "[root] provider[\"registry.terraform.io/hashicorp/null\"] (close)" 93 | } 94 | } 95 | 96 | ` 97 | } 98 | 99 | if v.GreaterThanOrEqual(v1_1) && v.LessThan(v1_7) { 100 | // 1.1.0 - 1.7.0 101 | return `digraph { 102 | compound = "true" 103 | newrank = "true" 104 | subgraph "root" { 105 | "[root] null_resource.foo (expand)" [label = "null_resource.foo", shape = "box"] 106 | "[root] provider[\"registry.terraform.io/hashicorp/null\"]" [label = "provider[\"registry.terraform.io/hashicorp/null\"]", shape = "diamond"] 107 | "[root] null_resource.foo (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/null\"]" 108 | "[root] provider[\"registry.terraform.io/hashicorp/null\"] (close)" -> "[root] null_resource.foo (expand)" 109 | "[root] root" -> "[root] provider[\"registry.terraform.io/hashicorp/null\"] (close)" 110 | } 111 | } 112 | 113 | ` 114 | } 115 | 116 | // 1.7.0+ 117 | return `digraph G { 118 | rankdir = "RL"; 119 | node [shape = rect, fontname = "sans-serif"]; 120 | "null_resource.foo" [label="null_resource.foo"]; 121 | } 122 | ` 123 | } 124 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/import_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/go-version" 11 | 12 | "github.com/hashicorp/terraform-exec/tfexec" 13 | ) 14 | 15 | func TestImport(t *testing.T) { 16 | const ( 17 | expectedID = "asdlfjksdlfkjsdlfk" 18 | resourceAddress = "random_string.random_string" 19 | ) 20 | 21 | runTest(t, "import", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 22 | ctx := context.Background() 23 | 24 | err := tf.Init(ctx) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | // Config is unnecessary here since its already the working dir, but just testing an additional flag 30 | err = tf.Import(ctx, resourceAddress, expectedID, tfexec.DisableBackup(), tfexec.Config(tf.WorkingDir())) 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | 35 | if tfv.LessThan(version.Must(version.NewVersion("0.12.0"))) { 36 | t.Logf("skipping state assertion for 0.11") 37 | return 38 | } 39 | 40 | state, err := tf.Show(ctx) 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | for _, r := range state.Values.RootModule.Resources { 46 | if r.Address != resourceAddress { 47 | continue 48 | } 49 | 50 | raw, ok := r.AttributeValues["id"] 51 | if !ok { 52 | t.Fatal("value not found for \"id\" attribute") 53 | } 54 | actual, ok := raw.(string) 55 | if !ok { 56 | t.Fatalf("unable to cast %T to string: %#v", raw, raw) 57 | } 58 | 59 | if actual != expectedID { 60 | t.Fatalf("expected %q, got %q", expectedID, actual) 61 | } 62 | 63 | // success 64 | return 65 | } 66 | 67 | t.Fatalf("imported resource %q not found", resourceAddress) 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/init_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "io" 9 | "regexp" 10 | "testing" 11 | 12 | "github.com/hashicorp/go-version" 13 | 14 | "github.com/hashicorp/terraform-exec/tfexec" 15 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 16 | ) 17 | 18 | func TestInit(t *testing.T) { 19 | runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 20 | err := tf.Init(context.Background()) 21 | if err != nil { 22 | t.Fatalf("error running Init in test directory: %s", err) 23 | } 24 | }) 25 | } 26 | 27 | func TestInitJSON_TF18AndEarlier(t *testing.T) { 28 | versions := []string{ 29 | testutil.Latest011, 30 | testutil.Latest012, 31 | testutil.Latest013, 32 | testutil.Latest_v1_6, 33 | testutil.Latest_v1_7, 34 | testutil.Latest_v1_8, 35 | } 36 | 37 | runTestWithVersions(t, versions, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 38 | err := tf.Init(context.Background()) 39 | if err != nil { 40 | t.Fatalf("error running Init in test directory: %s", err) 41 | } 42 | 43 | re := regexp.MustCompile("terraform init -json was added in 1.9.0") 44 | 45 | err = tf.InitJSON(context.Background(), io.Discard) 46 | if err != nil && !re.MatchString(err.Error()) { 47 | t.Fatalf("error running Init: %s", err) 48 | } 49 | }) 50 | } 51 | 52 | func TestInitJSON_TF19AndLater(t *testing.T) { 53 | versions := []string{ 54 | testutil.Latest_v1_9, 55 | testutil.Latest_Alpha_v1_9, 56 | testutil.Latest_Alpha_v1_10, 57 | } 58 | 59 | runTestWithVersions(t, versions, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 60 | err := tf.Init(context.Background()) 61 | if err != nil { 62 | t.Fatalf("error running Init in test directory: %s", err) 63 | } 64 | 65 | err = tf.InitJSON(context.Background(), io.Discard) 66 | if err != nil { 67 | t.Fatalf("error running Init: %s", err) 68 | } 69 | }) 70 | } 71 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/main_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "io/ioutil" 8 | "os" 9 | "testing" 10 | 11 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 12 | ) 13 | 14 | var tfcache *testutil.TFCache 15 | 16 | func TestMain(m *testing.M) { 17 | os.Exit(func() int { 18 | installDir, err := ioutil.TempDir("", "tfinstall") 19 | if err != nil { 20 | panic(err) 21 | } 22 | defer os.RemoveAll(installDir) 23 | 24 | tfcache = testutil.NewTFCache(installDir) 25 | return m.Run() 26 | }()) 27 | } 28 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/metadata_functions_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/go-version" 11 | 12 | "github.com/hashicorp/terraform-exec/tfexec" 13 | ) 14 | 15 | func TestMetadataFunctions(t *testing.T) { 16 | runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 17 | if tfv.LessThan(metadataFunctionsMinVersion) { 18 | t.Skip("metadata functions command is not available in this Terraform version") 19 | } 20 | 21 | _, err := tf.MetadataFunctions(context.Background()) 22 | if err != nil { 23 | t.Fatalf("error running MetadataFunctions: %s", err) 24 | } 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/output_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/go-version" 11 | 12 | "github.com/hashicorp/terraform-exec/tfexec" 13 | ) 14 | 15 | func TestOutput_noOutputs(t *testing.T) { 16 | runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 17 | if tfv.LessThan(version.Must(version.NewVersion("0.12.14"))) { 18 | // https://github.com/hashicorp/terraform/blob/v0.12/CHANGELOG.md#01214-november-13-2019 19 | t.Skip("no outputs being success (instead of error) was changed in 0.12.14") 20 | } 21 | 22 | err := tf.Init(context.Background()) 23 | if err != nil { 24 | t.Fatalf("error running Init in test directory: %s", err) 25 | } 26 | 27 | _, err = tf.Output(context.Background()) 28 | if err != nil { 29 | t.Fatalf("error running Output: %s", err) 30 | } 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/plan_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "io" 9 | "regexp" 10 | "testing" 11 | 12 | "github.com/hashicorp/go-version" 13 | 14 | "github.com/hashicorp/terraform-exec/tfexec" 15 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 16 | ) 17 | 18 | func TestPlan(t *testing.T) { 19 | runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 20 | err := tf.Init(context.Background()) 21 | if err != nil { 22 | t.Fatalf("error running Init in test directory: %s", err) 23 | } 24 | 25 | hasChanges, err := tf.Plan(context.Background()) 26 | if err != nil { 27 | t.Fatalf("error running Plan: %s", err) 28 | } 29 | if !hasChanges { 30 | t.Fatalf("expected: true, got: %t", hasChanges) 31 | } 32 | }) 33 | 34 | } 35 | 36 | func TestPlanWithState(t *testing.T) { 37 | runTest(t, "basic_with_state", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 38 | if tfv.LessThan(providerAddressMinVersion) { 39 | t.Skip("state file provider FQNs not compatible with this Terraform version") 40 | } 41 | err := tf.Init(context.Background()) 42 | if err != nil { 43 | t.Fatalf("error running Init in test directory: %s", err) 44 | } 45 | 46 | hasChanges, err := tf.Plan(context.Background()) 47 | if err != nil { 48 | t.Fatalf("error running Plan: %s", err) 49 | } 50 | if hasChanges { 51 | t.Fatalf("expected: false, got: %t", hasChanges) 52 | } 53 | }) 54 | } 55 | 56 | func TestPlanJSON_TF014AndEarlier(t *testing.T) { 57 | versions := []string{testutil.Latest011, testutil.Latest012, testutil.Latest013, testutil.Latest014} 58 | 59 | runTestWithVersions(t, versions, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 60 | err := tf.Init(context.Background()) 61 | if err != nil { 62 | t.Fatalf("error running Init in test directory: %s", err) 63 | } 64 | 65 | re := regexp.MustCompile("terraform plan -json was added in 0.15.3") 66 | 67 | hasChanges, err := tf.PlanJSON(context.Background(), io.Discard) 68 | if err != nil && !re.MatchString(err.Error()) { 69 | t.Fatalf("error running Apply: %s", err) 70 | } 71 | if hasChanges { 72 | t.Fatalf("expected: false, got: %t", hasChanges) 73 | } 74 | }) 75 | } 76 | 77 | func TestPlanJSON_TF015AndLater(t *testing.T) { 78 | versions := []string{testutil.Latest015, testutil.Latest_v1, testutil.Latest_v1_1} 79 | 80 | runTestWithVersions(t, versions, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 81 | err := tf.Init(context.Background()) 82 | if err != nil { 83 | t.Fatalf("error running Init in test directory: %s", err) 84 | } 85 | 86 | hasChanges, err := tf.PlanJSON(context.Background(), io.Discard) 87 | if err != nil { 88 | t.Fatalf("error running Apply: %s", err) 89 | } 90 | if !hasChanges { 91 | t.Fatalf("expected: true, got: %t", hasChanges) 92 | } 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/providers_lock_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/go-version" 11 | 12 | "github.com/hashicorp/terraform-exec/tfexec" 13 | ) 14 | 15 | var ( 16 | providersLockMinVersion = version.Must(version.NewVersion("0.14.0")) 17 | ) 18 | 19 | func TestProvidersLock(t *testing.T) { 20 | runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 21 | if tfv.LessThan(providersLockMinVersion) { 22 | t.Skip("terraform providers lock was added in Terraform 0.14, so test is not valid") 23 | } 24 | err := tf.Init(context.Background()) 25 | if err != nil { 26 | t.Fatalf("error running Init in test directory: %s", err) 27 | } 28 | 29 | err = tf.ProvidersLock(context.Background()) 30 | if err != nil { 31 | t.Fatalf("error running provider lock: %s", err) 32 | } 33 | }) 34 | 35 | } 36 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/refresh_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "io" 9 | "regexp" 10 | "testing" 11 | 12 | "github.com/hashicorp/go-version" 13 | 14 | "github.com/hashicorp/terraform-exec/tfexec" 15 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 16 | ) 17 | 18 | func TestRefresh(t *testing.T) { 19 | runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 20 | err := tf.Init(context.Background()) 21 | if err != nil { 22 | t.Fatalf("error running Init in test directory: %s", err) 23 | } 24 | 25 | err = tf.Apply(context.Background()) 26 | if err != nil { 27 | t.Fatalf("error running Apply: %s", err) 28 | } 29 | 30 | err = tf.Refresh(context.Background()) 31 | if err != nil { 32 | t.Fatalf("error running Refresh: %s", err) 33 | } 34 | }) 35 | } 36 | 37 | func TestRefreshJSON_TF014AndEarlier(t *testing.T) { 38 | versions := []string{testutil.Latest011, testutil.Latest012, testutil.Latest013, testutil.Latest014} 39 | 40 | runTestWithVersions(t, versions, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 41 | err := tf.Init(context.Background()) 42 | if err != nil { 43 | t.Fatalf("error running Init in test directory: %s", err) 44 | } 45 | 46 | re := regexp.MustCompile("terraform refresh -json was added in 0.15.3") 47 | 48 | err = tf.RefreshJSON(context.Background(), io.Discard) 49 | if err != nil && !re.MatchString(err.Error()) { 50 | t.Fatalf("error running Apply: %s", err) 51 | } 52 | }) 53 | } 54 | 55 | func TestRefreshJSON_TF015AndLater(t *testing.T) { 56 | versions := []string{testutil.Latest015, testutil.Latest_v1, testutil.Latest_v1_1} 57 | 58 | runTestWithVersions(t, versions, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 59 | err := tf.Init(context.Background()) 60 | if err != nil { 61 | t.Fatalf("error running Init in test directory: %s", err) 62 | } 63 | 64 | err = tf.RefreshJSON(context.Background(), io.Discard) 65 | if err != nil { 66 | t.Fatalf("error running Apply: %s", err) 67 | } 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/state_mv_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "testing" 10 | 11 | "github.com/hashicorp/go-version" 12 | tfjson "github.com/hashicorp/terraform-json" 13 | 14 | "github.com/hashicorp/terraform-exec/tfexec" 15 | ) 16 | 17 | func TestStateMv(t *testing.T) { 18 | runTest(t, "basic_with_state", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 19 | if tfv.LessThan(providerAddressMinVersion) { 20 | t.Skip("state file provider FQNs not compatible with this Terraform version") 21 | } 22 | 23 | providerName := "registry.terraform.io/hashicorp/null" 24 | 25 | err := tf.Init(context.Background()) 26 | if err != nil { 27 | t.Fatalf("error running Init in test directory: %s", err) 28 | } 29 | 30 | err = tf.StateMv(context.Background(), "null_resource.foo", "null_resource.bar") 31 | if err != nil { 32 | t.Fatalf("error running StateMv: %s", err) 33 | } 34 | 35 | formatVersion := "0.1" 36 | var sensitiveValues json.RawMessage 37 | if tfv.Core().GreaterThanOrEqual(v1_0_1) { 38 | formatVersion = "0.2" 39 | sensitiveValues = json.RawMessage([]byte("{}")) 40 | } 41 | if tfv.Core().GreaterThanOrEqual(v1_1) { 42 | formatVersion = "1.0" 43 | } 44 | 45 | // test that the new state is as expected 46 | expected := &tfjson.State{ 47 | FormatVersion: formatVersion, 48 | // TerraformVersion is ignored to facilitate latest version testing 49 | Values: &tfjson.StateValues{ 50 | RootModule: &tfjson.StateModule{ 51 | Resources: []*tfjson.StateResource{{ 52 | Address: "null_resource.bar", 53 | AttributeValues: map[string]interface{}{ 54 | "id": "5510719323588825107", 55 | "triggers": nil, 56 | }, 57 | SensitiveValues: sensitiveValues, 58 | Mode: tfjson.ManagedResourceMode, 59 | Type: "null_resource", 60 | Name: "bar", 61 | ProviderName: providerName, 62 | }}, 63 | }, 64 | }, 65 | } 66 | 67 | actual, err := tf.Show(context.Background()) 68 | if err != nil { 69 | t.Fatal(err) 70 | } 71 | 72 | if diff := diffState(expected, actual); diff != "" { 73 | t.Fatalf("mismatch (-want +got):\n%s", diff) 74 | } 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/state_pull_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/go-version" 11 | 12 | "github.com/hashicorp/terraform-exec/tfexec" 13 | ) 14 | 15 | func TestStatePull(t *testing.T) { 16 | runTest(t, "basic_with_state", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 17 | if tfv.LessThan(providerAddressMinVersion) { 18 | t.Skip("state file provider FQNs not compatible with this Terraform version") 19 | } 20 | 21 | err := tf.Init(context.Background()) 22 | if err != nil { 23 | t.Fatalf("error running Init in test directory: %s", err) 24 | } 25 | 26 | _, err = tf.StatePull(context.Background()) 27 | if err != nil { 28 | t.Fatalf("error running StatePull: %s", err) 29 | } 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/state_push_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/go-version" 11 | 12 | "github.com/hashicorp/terraform-exec/tfexec" 13 | ) 14 | 15 | func TestStatePush(t *testing.T) { 16 | runTest(t, "basic_with_state", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 17 | if tfv.LessThan(providerAddressMinVersion) { 18 | t.Skip("state file provider FQNs not compatible with this Terraform version") 19 | } 20 | 21 | err := tf.Init(context.Background()) 22 | if err != nil { 23 | t.Fatalf("error running Init in test directory: %s", err) 24 | } 25 | 26 | err = tf.StatePush(context.Background(), "terraform.tfstate") 27 | if err != nil { 28 | t.Fatalf("error running StatePush: %s", err) 29 | } 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/state_rm_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/go-version" 11 | tfjson "github.com/hashicorp/terraform-json" 12 | 13 | "github.com/hashicorp/terraform-exec/tfexec" 14 | ) 15 | 16 | func TestStateRm(t *testing.T) { 17 | runTest(t, "basic_with_state", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 18 | if tfv.LessThan(providerAddressMinVersion) { 19 | t.Skip("state file provider FQNs not compatible with this Terraform version") 20 | } 21 | 22 | err := tf.Init(context.Background()) 23 | if err != nil { 24 | t.Fatalf("error running Init in test directory: %s", err) 25 | } 26 | 27 | err = tf.StateRm(context.Background(), "null_resource.foo") 28 | if err != nil { 29 | t.Fatalf("error running StateRm: %s", err) 30 | } 31 | 32 | formatVersion := "0.1" 33 | if tfv.Core().GreaterThanOrEqual(v1_0_1) { 34 | formatVersion = "0.2" 35 | } 36 | if tfv.Core().GreaterThanOrEqual(v1_1) { 37 | formatVersion = "1.0" 38 | } 39 | 40 | // test that the new state is as expected 41 | expected := &tfjson.State{ 42 | FormatVersion: formatVersion, 43 | // TerraformVersion is ignored to facilitate latest version testing 44 | Values: nil, 45 | } 46 | 47 | actual, err := tf.Show(context.Background()) 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | 52 | if diff := diffState(expected, actual); diff != "" { 53 | t.Fatalf("mismatch (-want +got):\n%s", diff) 54 | } 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/taint_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/go-version" 11 | 12 | "github.com/hashicorp/terraform-exec/tfexec" 13 | ) 14 | 15 | func TestTaint(t *testing.T) { 16 | runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 17 | err := tf.Init(context.Background()) 18 | if err != nil { 19 | t.Fatalf("error running Init in test directory: %s", err) 20 | } 21 | 22 | err = tf.Apply(context.Background()) 23 | if err != nil { 24 | t.Fatalf("error running Apply: %s", err) 25 | } 26 | 27 | err = tf.Taint(context.Background(), "null_resource.foo") 28 | if err != nil { 29 | t.Fatalf("error running Taint: %s", err) 30 | } 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/test_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "io" 9 | "regexp" 10 | "testing" 11 | 12 | "github.com/hashicorp/go-version" 13 | 14 | "github.com/hashicorp/terraform-exec/tfexec" 15 | ) 16 | 17 | var ( 18 | testMinVersion = version.Must(version.NewVersion("1.6.0")) 19 | ) 20 | 21 | func TestTest(t *testing.T) { 22 | runTest(t, "test_command_passing", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 23 | // Use Core() to enable pre-release support 24 | if tfv.Core().LessThan(testMinVersion) { 25 | t.Skip("test command is not available in this Terraform version") 26 | } 27 | 28 | err := tf.Test(context.Background(), nil) 29 | 30 | if err != nil { 31 | t.Fatalf("error running test command: %s", err) 32 | } 33 | }) 34 | } 35 | 36 | func TestTestError(t *testing.T) { 37 | runTest(t, "test_command_failing", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 38 | // Use Core() to enable pre-release support 39 | if tfv.Core().LessThan(testMinVersion) { 40 | t.Skip("test command is not available in this Terraform version") 41 | } 42 | 43 | err := tf.Test(context.Background(), io.Discard) 44 | 45 | if err == nil { 46 | t.Fatal("expected error, got none") 47 | } 48 | 49 | got := err.Error() 50 | expected := regexp.MustCompile("exit status 1") 51 | 52 | if !expected.MatchString(got) { 53 | t.Fatalf("expected error matching '%s', got: %s", expected, got) 54 | } 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/basic/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | null = { 4 | version = "3.1.0" 5 | } 6 | } 7 | } 8 | 9 | resource null_resource "foo" { 10 | } 11 | 12 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/basic_with_state/main.tf: -------------------------------------------------------------------------------- 1 | resource null_resource "foo" { 2 | } 3 | 4 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/basic_with_state/terraform.tfstate: -------------------------------------------------------------------------------- 1 | { 2 | "version": 4, 3 | "terraform_version": "0.12.24", 4 | "serial": 1, 5 | "lineage": "3d011417-36e1-8302-77c5-7e45fdf14235", 6 | "outputs": {}, 7 | "resources": [ 8 | { 9 | "mode": "managed", 10 | "type": "null_resource", 11 | "name": "foo", 12 | "provider": "provider[\"registry.terraform.io/hashicorp/null\"]", 13 | "instances": [ 14 | { 15 | "schema_version": 0, 16 | "attributes": { 17 | "id": "5510719323588825107", 18 | "triggers": null 19 | }, 20 | "private": "bnVsbA==" 21 | } 22 | ] 23 | } 24 | ] 25 | } 26 | 27 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/bigint/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | random = { 4 | version = "3.1.3" 5 | } 6 | } 7 | } 8 | 9 | resource "random_integer" "bigint" { 10 | max = 7227701560655103598 11 | min = 7227701560655103597 12 | seed = 12345 13 | } 14 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/cloud_backend/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "cloud" { 3 | } 4 | } 5 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/deep_module/foo/bar/main.tf: -------------------------------------------------------------------------------- 1 | resource "null_resource" "baz" {} 2 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/deep_module/foo/main.tf: -------------------------------------------------------------------------------- 1 | module "bar" { 2 | source = "./bar" 3 | } 4 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/deep_module/main.tf: -------------------------------------------------------------------------------- 1 | module "foo" { 2 | source = "./foo" 3 | } 4 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/deep_module/plan.json: -------------------------------------------------------------------------------- 1 | {"format_version":"0.1","terraform_version":"0.12.6","planned_values":{"root_module":{}},"resource_changes":[{"address":"module.foo.module.bar.null_resource.baz","module_address":"module.foo.module.bar","mode":"managed","type":"null_resource","name":"baz","provider_name":"null","change":{"actions":["create"],"before":null,"after":{"triggers":null},"after_unknown":{"id":true}}}],"configuration":{"root_module":{"module_calls":{"foo":{"source":"./foo","module":{"module_calls":{"bar":{"source":"./bar","module":{"resources":[{"address":"null_resource.baz","mode":"managed","type":"null_resource","name":"baz","provider_config_key":"bar:null","schema_version":0}]}}}}}}}}} 2 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/deep_module/schemas.json: -------------------------------------------------------------------------------- 1 | {"format_version":"0.1","provider_schemas":{"null":{"provider":{"version":0,"block":{}},"resource_schemas":{"null_resource":{"version":0,"block":{"attributes":{"id":{"type":"string","optional":true,"computed":true},"triggers":{"type":["map","string"],"optional":true}}}}},"data_source_schemas":{"null_data_source":{"version":0,"block":{"attributes":{"has_computed_default":{"type":"string","optional":true,"computed":true},"id":{"type":"string","optional":true,"computed":true},"inputs":{"type":["map","string"],"optional":true},"outputs":{"type":["map","string"],"computed":true},"random":{"type":"string","computed":true}}}}}}}} 2 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/empty/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/terraform-exec/6f588fa53340c424106720ec3c8c6da11c2afaee/tfexec/internal/e2etest/testdata/empty/.empty -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/empty_with_tf_file/main.tf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/terraform-exec/6f588fa53340c424106720ec3c8c6da11c2afaee/tfexec/internal/e2etest/testdata/empty_with_tf_file/main.tf -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/etcd_backend/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "etcd" { 3 | } 4 | } 5 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/import/main.tf: -------------------------------------------------------------------------------- 1 | provider "random" { 2 | } 3 | 4 | resource "random_string" "random_string" { 5 | } -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/inmem_backend/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "inmem" { 3 | } 4 | } 5 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/inmem_backend_locked/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "inmem" { 3 | lock_id = "2b6a6738-5dd5-50d6-c0ae-f6352977666b" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/invalid/main.tf: -------------------------------------------------------------------------------- 1 | bad_block { 2 | } 3 | 4 | terraform { 5 | bad_attribute = "string" 6 | } -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/local_backend_non_default_state/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "local" { 3 | path = "foo.tfstate" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_planfile_012/human_readable_output.txt: -------------------------------------------------------------------------------- 1 | An execution plan has been generated and is shown below. 2 | Resource actions are indicated with the following symbols: 3 | + create 4 | 5 | Terraform will perform the following actions: 6 | 7 | # null_resource.foo will be created 8 | + resource "null_resource" "foo" { 9 | + id = (known after apply) 10 | } 11 | 12 | Plan: 1 to add, 0 to change, 0 to destroy. 13 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_planfile_012/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | null = { 4 | version = "3.0.0" 5 | } 6 | } 7 | } 8 | 9 | resource null_resource "foo" { 10 | } 11 | 12 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_planfile_012/planfilefoo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/terraform-exec/6f588fa53340c424106720ec3c8c6da11c2afaee/tfexec/internal/e2etest/testdata/non_default_planfile_012/planfilefoo -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_planfile_013/.gitignore: -------------------------------------------------------------------------------- 1 | .terraform 2 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_planfile_013/human_readable_output.txt: -------------------------------------------------------------------------------- 1 | An execution plan has been generated and is shown below. 2 | Resource actions are indicated with the following symbols: 3 | + create 4 | 5 | Terraform will perform the following actions: 6 | 7 | # null_resource.foo will be created 8 | + resource "null_resource" "foo" { 9 | + id = (known after apply) 10 | } 11 | 12 | Plan: 1 to add, 0 to change, 0 to destroy. 13 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_planfile_013/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | null = { 4 | version = "3.0.0" 5 | } 6 | } 7 | } 8 | 9 | resource null_resource "foo" { 10 | } 11 | 12 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_planfile_013/planfilefoo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/terraform-exec/6f588fa53340c424106720ec3c8c6da11c2afaee/tfexec/internal/e2etest/testdata/non_default_planfile_013/planfilefoo -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_planfile_014/.gitignore: -------------------------------------------------------------------------------- 1 | .terraform 2 | .terraform.lock.hcl 3 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_planfile_014/human_readable_output.txt: -------------------------------------------------------------------------------- 1 | An execution plan has been generated and is shown below. 2 | Resource actions are indicated with the following symbols: 3 | + create 4 | 5 | Terraform will perform the following actions: 6 | 7 | # null_resource.foo will be created 8 | + resource "null_resource" "foo" { 9 | + id = (known after apply) 10 | } 11 | 12 | Plan: 1 to add, 0 to change, 0 to destroy. 13 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_planfile_014/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | null = { 4 | version = "3.0.0" 5 | } 6 | } 7 | } 8 | 9 | resource null_resource "foo" { 10 | } 11 | 12 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_planfile_014/planfilefoo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/terraform-exec/6f588fa53340c424106720ec3c8c6da11c2afaee/tfexec/internal/e2etest/testdata/non_default_planfile_014/planfilefoo -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_statefile_012/.gitignore: -------------------------------------------------------------------------------- 1 | .terraform 2 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_statefile_012/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | null = { 4 | version = "3.0.0" 5 | } 6 | } 7 | } 8 | 9 | resource null_resource "foo" { 10 | } 11 | 12 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_statefile_012/statefilefoo: -------------------------------------------------------------------------------- 1 | { 2 | "version": 4, 3 | "terraform_version": "0.12.31", 4 | "serial": 1, 5 | "lineage": "4aa0bd09-a697-86b7-fff6-679a70918d26", 6 | "outputs": {}, 7 | "resources": [ 8 | { 9 | "mode": "managed", 10 | "type": "null_resource", 11 | "name": "foo", 12 | "provider": "provider.null", 13 | "instances": [ 14 | { 15 | "schema_version": 0, 16 | "attributes": { 17 | "id": "2363759301357831073", 18 | "triggers": null 19 | }, 20 | "private": "bnVsbA==" 21 | } 22 | ] 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_statefile_013/.gitignore: -------------------------------------------------------------------------------- 1 | .terraform 2 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_statefile_013/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | null = { 4 | version = "3.0.0" 5 | } 6 | } 7 | } 8 | 9 | resource null_resource "foo" { 10 | } 11 | 12 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_statefile_013/statefilefoo: -------------------------------------------------------------------------------- 1 | { 2 | "version": 4, 3 | "terraform_version": "0.13.7", 4 | "serial": 1, 5 | "lineage": "edfa7f71-a342-76e1-f384-be7157756164", 6 | "outputs": {}, 7 | "resources": [ 8 | { 9 | "mode": "managed", 10 | "type": "null_resource", 11 | "name": "foo", 12 | "provider": "provider[\"registry.terraform.io/hashicorp/null\"]", 13 | "instances": [ 14 | { 15 | "schema_version": 0, 16 | "attributes": { 17 | "id": "6724959521006014491", 18 | "triggers": null 19 | }, 20 | "private": "bnVsbA==" 21 | } 22 | ] 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_statefile_014/.gitignore: -------------------------------------------------------------------------------- 1 | .terraform 2 | .terraform.lock.hcl 3 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_statefile_014/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | null = { 4 | version = "3.0.0" 5 | } 6 | } 7 | } 8 | 9 | resource null_resource "foo" { 10 | } 11 | 12 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/non_default_statefile_014/statefilefoo: -------------------------------------------------------------------------------- 1 | { 2 | "version": 4, 3 | "terraform_version": "0.14.11", 4 | "serial": 1, 5 | "lineage": "0530476a-aa4b-ba1a-eab1-4c1fd097d997", 6 | "outputs": {}, 7 | "resources": [ 8 | { 9 | "mode": "managed", 10 | "type": "null_resource", 11 | "name": "foo", 12 | "provider": "provider[\"registry.terraform.io/hashicorp/null\"]", 13 | "instances": [ 14 | { 15 | "schema_version": 0, 16 | "attributes": { 17 | "id": "3544690470898862261", 18 | "triggers": null 19 | }, 20 | "sensitive_attributes": [], 21 | "private": "bnVsbA==" 22 | } 23 | ] 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/pre_011_syntax/file1.golden.txt: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "web" { 2 | ami = data.aws_ami.amazon.id 3 | instance_type = "t3.micro" 4 | count = 2 5 | 6 | tags = { 7 | Name = "HelloWorld" 8 | } 9 | } 10 | 11 | resource "aws_elb" "web" { 12 | instances = aws_instance.web.*.id 13 | subnets = aws_subnet.test.*.id 14 | listener { 15 | instance_port = 8000 16 | instance_protocol = "http" 17 | lb_port = 80 18 | lb_protocol = "http" 19 | } 20 | } 21 | 22 | data "aws_ami" "amazon" { 23 | most_recent = true 24 | owners = ["amazon"] 25 | 26 | filter { 27 | name = "name" 28 | values = ["amzn-ami-hvm-*-x86_64-gp2"] 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/pre_011_syntax/file1.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "web" { 2 | ami = "${data.aws_ami.amazon.id}" 3 | instance_type = "t3.micro" 4 | count = 2 5 | 6 | tags = { 7 | Name = "HelloWorld" 8 | } 9 | } 10 | 11 | resource "aws_elb" "web" { 12 | instances = ["${aws_instance.web.*.id}"] 13 | subnets = ["${aws_subnet.test.*.id}"] 14 | listener { 15 | instance_port = 8000 16 | instance_protocol = "http" 17 | lb_port = 80 18 | lb_protocol = "http" 19 | } 20 | } 21 | 22 | data "aws_ami" "amazon" { 23 | most_recent = true 24 | owners = ["amazon"] 25 | 26 | filter { 27 | name = "name" 28 | values = ["amzn-ami-hvm-*-x86_64-gp2"] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/pre_011_syntax/file2.golden.txt: -------------------------------------------------------------------------------- 1 | data "aws_availability_zones" "available" { 2 | state = "available" 3 | 4 | filter { 5 | name = "opt-in-status" 6 | values = ["opt-in-not-required"] 7 | } 8 | } 9 | 10 | resource "aws_vpc" "test" { 11 | cidr_block = var.vpc_cidr 12 | enable_dns_hostnames = true 13 | } 14 | 15 | resource "aws_subnet" "test" { 16 | count = 2 17 | vpc_id = aws_vpc.test.id 18 | 19 | cidr_block = cidrsubnet(var.vpc_cidr, 2, count.index) 20 | availability_zone = data.aws_availability_zones.available.names[count.index] 21 | } 22 | 23 | variable "vpc_cidr" { 24 | default = "10.1.0.0/16" 25 | } 26 | 27 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/pre_011_syntax/file2.tf: -------------------------------------------------------------------------------- 1 | data "aws_availability_zones" "available" { 2 | state = "available" 3 | 4 | filter { 5 | name = "opt-in-status" 6 | values = ["opt-in-not-required"] 7 | } 8 | } 9 | 10 | resource "aws_vpc" "test" { 11 | cidr_block = "${var.vpc_cidr}" 12 | enable_dns_hostnames = true 13 | } 14 | 15 | resource "aws_subnet" "test" { 16 | count = 2 17 | vpc_id = "${aws_vpc.test.id}" 18 | 19 | cidr_block = "${cidrsubnet(var.vpc_cidr, 2, count.index)}" 20 | availability_zone = "${data.aws_availability_zones.available.names[count.index]}" 21 | } 22 | 23 | variable "vpc_cidr" { 24 | default = "10.1.0.0/16" 25 | } 26 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/registry_module/main.tf: -------------------------------------------------------------------------------- 1 | module "module" { 2 | source = "vancluever/module/null" 3 | version = "~> 1.0.1" 4 | } 5 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/registry_module/plan.json: -------------------------------------------------------------------------------- 1 | {"format_version":"0.1","terraform_version":"0.12.6","planned_values":{"root_module":{"child_modules":[{"resources":[{"address":"module.module.null_resource.resource","mode":"managed","type":"null_resource","name":"resource","provider_name":"null","schema_version":0,"values":{"triggers":{"number":"one"}}}],"address":"module.module"}]}},"resource_changes":[{"address":"module.module.null_resource.resource","module_address":"module.module","mode":"managed","type":"null_resource","name":"resource","provider_name":"null","change":{"actions":["create"],"before":null,"after":{"triggers":{"number":"one"}},"after_unknown":{"id":true,"triggers":{}}}}],"prior_state":{"format_version":"0.1","terraform_version":"0.12.6","values":{"root_module":{"child_modules":[{"resources":[{"address":"data.null_data_source.data","mode":"data","type":"null_data_source","name":"data","provider_name":"null","schema_version":0,"values":{"has_computed_default":"default","id":"static","inputs":{"key":"foo"},"outputs":{"key":"foo"},"random":"7444238690891135114"}}],"address":"module.module"}]}}},"configuration":{"root_module":{"module_calls":{"module":{"source":"vancluever/module/null","module":{"outputs":{"null_data_source_id":{"expression":{"references":["data.null_data_source.data"]},"description":"The `id` of the `null_data_source` data source in this module."},"null_resource_id":{"expression":{"references":["null_resource.resource"]},"description":"The `id` of the `null_resource` resource in this module."}},"resources":[{"address":"null_resource.resource","mode":"managed","type":"null_resource","name":"resource","provider_config_key":"module:null","expressions":{"triggers":{"references":["var.trigger"]}},"schema_version":0},{"address":"data.null_data_source.data","mode":"data","type":"null_data_source","name":"data","provider_config_key":"module:null","expressions":{"inputs":{"references":["var.input"]}},"schema_version":0}],"variables":{"input":{"default":"foo","description":"The input value for the `null_data_source` data source in this module."},"trigger":{"default":"one","description":"The trigger value for the `null_resource` resource in this module."}}},"version_constraint":"~\u003e 1.0.1"}}}}} 2 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/registry_module/schemas.json: -------------------------------------------------------------------------------- 1 | {"format_version":"0.1","provider_schemas":{"null":{"provider":{"version":0,"block":{}},"resource_schemas":{"null_resource":{"version":0,"block":{"attributes":{"id":{"type":"string","optional":true,"computed":true},"triggers":{"type":["map","string"],"optional":true}}}}},"data_source_schemas":{"null_data_source":{"version":0,"block":{"attributes":{"has_computed_default":{"type":"string","optional":true,"computed":true},"id":{"type":"string","optional":true,"computed":true},"inputs":{"type":["map","string"],"optional":true},"outputs":{"type":["map","string"],"computed":true},"random":{"type":"string","computed":true}}}}}}}} 2 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/remote_backend/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "remote" { 3 | } 4 | } 5 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/sleep/main.tf: -------------------------------------------------------------------------------- 1 | variable "create_duration" { 2 | type = string 3 | default = "60s" 4 | } 5 | 6 | variable "destroy_duration" { 7 | type = string 8 | default = null 9 | } 10 | 11 | resource "time_sleep" "sleep" { 12 | create_duration = var.create_duration 13 | destroy_duration = var.destroy_duration 14 | } 15 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/test_command_failing/main.tf: -------------------------------------------------------------------------------- 1 | variable "test" { 2 | type = string 3 | } 4 | 5 | resource "terraform_data" "test" { 6 | input = var.test 7 | } 8 | 9 | output "test" { 10 | value = terraform_data.test.output 11 | } 12 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/test_command_failing/tests/passthrough.tftest.hcl: -------------------------------------------------------------------------------- 1 | variables { 2 | test = "test value" 3 | } 4 | 5 | run "variable_output_passthrough" { 6 | command = apply 7 | 8 | assert { 9 | condition = output.test == "not test value" # intentionally incorrect 10 | error_message = "variable was not passed through to output" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/test_command_passing/main.tf: -------------------------------------------------------------------------------- 1 | variable "test" { 2 | type = string 3 | } 4 | 5 | resource "terraform_data" "test" { 6 | input = var.test 7 | } 8 | 9 | output "test" { 10 | value = terraform_data.test.output 11 | } 12 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/test_command_passing/tests/passthrough.tftest.hcl: -------------------------------------------------------------------------------- 1 | variables { 2 | test = "test value" 3 | } 4 | 5 | run "variable_output_passthrough" { 6 | command = apply 7 | 8 | assert { 9 | condition = output.test == "test value" 10 | error_message = "variable was not passed through to output" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/tf99/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">99.0.0" 3 | } 4 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/unformatted/file1.golden.txt: -------------------------------------------------------------------------------- 1 | resource "foo" "bar" { 2 | baz = 1 3 | qux = 2 4 | } 5 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/unformatted/file1.tf: -------------------------------------------------------------------------------- 1 | resource "foo" "bar" { 2 | baz = 1 3 | qux = 2 4 | } 5 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/unformatted/file2.golden.txt: -------------------------------------------------------------------------------- 1 | resource "foo" "baz" { 2 | baz = 1 3 | qux = 2 4 | } 5 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/unformatted/file2.tf: -------------------------------------------------------------------------------- 1 | resource "foo" "baz" { 2 | baz = 1 3 | qux = 2 4 | } 5 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/var/main.tf: -------------------------------------------------------------------------------- 1 | variable "default" { 2 | default = "foo" 3 | } 4 | 5 | variable "no_default" { 6 | } 7 | 8 | variable "no_default_really_long_variable_name_that_will_line_wrap_tf_output" { 9 | } 10 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/workspaces/.terraform/environment: -------------------------------------------------------------------------------- 1 | foo -------------------------------------------------------------------------------- /tfexec/internal/e2etest/testdata/workspaces/terraform.tfstate.d/foo/.keep: -------------------------------------------------------------------------------- 1 | this file is used to hold this directory in git -------------------------------------------------------------------------------- /tfexec/internal/e2etest/untaint_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/go-version" 11 | 12 | "github.com/hashicorp/terraform-exec/tfexec" 13 | ) 14 | 15 | func TestUntaint(t *testing.T) { 16 | runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 17 | err := tf.Init(context.Background()) 18 | if err != nil { 19 | t.Fatalf("error running Init in test directory: %s", err) 20 | } 21 | 22 | err = tf.Apply(context.Background()) 23 | if err != nil { 24 | t.Fatalf("error running Apply: %s", err) 25 | } 26 | 27 | err = tf.Taint(context.Background(), "null_resource.foo") 28 | if err != nil { 29 | t.Fatalf("error running Taint: %s", err) 30 | } 31 | 32 | err = tf.Untaint(context.Background(), "null_resource.foo") 33 | if err != nil { 34 | t.Fatalf("error running Untaint: %s", err) 35 | } 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/upgrade012_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "path/filepath" 9 | "testing" 10 | 11 | "github.com/hashicorp/go-version" 12 | 13 | "github.com/hashicorp/terraform-exec/tfexec" 14 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 15 | ) 16 | 17 | func TestUpgrade012(t *testing.T) { 18 | runTestWithVersions(t, []string{testutil.Latest012}, "pre_011_syntax", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 19 | err := tf.Init(context.Background()) 20 | if err != nil { 21 | t.Fatalf("error running Init in test directory: %s", err) 22 | } 23 | 24 | err = tf.Upgrade012(context.Background()) 25 | if err != nil { 26 | t.Fatalf("error from FormatWrite: %T %q", err, err) 27 | } 28 | 29 | for file, golden := range map[string]string{ 30 | "file1.tf": "file1.golden.txt", 31 | "file2.tf": "file2.golden.txt", 32 | } { 33 | textFilesEqual(t, filepath.Join(tf.WorkingDir(), golden), filepath.Join(tf.WorkingDir(), file)) 34 | } 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/validate_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/google/go-cmp/cmp" 11 | "github.com/hashicorp/go-version" 12 | 13 | "github.com/hashicorp/terraform-exec/tfexec" 14 | tfjson "github.com/hashicorp/terraform-json" 15 | ) 16 | 17 | var ( 18 | validateMinVersion = version.Must(version.NewVersion("0.12.0")) 19 | ) 20 | 21 | func TestValidate(t *testing.T) { 22 | runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 23 | if tfv.LessThan(validateMinVersion) { 24 | t.Skip("terraform validate -json was added in Terraform 0.12, so test is not valid") 25 | } 26 | 27 | err := tf.Init(context.Background()) 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | 32 | validation, err := tf.Validate(context.Background()) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | if !validation.Valid { 38 | t.Fatalf("expected valid, got %#v", validation) 39 | } 40 | }) 41 | 42 | runTest(t, "invalid", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 43 | if tfv.LessThan(validateMinVersion) { 44 | t.Skip("terraform validate -json was added in Terraform 0.12, so test is not valid") 45 | } 46 | 47 | err := tf.Init(context.Background()) 48 | if err != nil { 49 | t.Logf("error initializing: %s", err) 50 | 51 | // 0.13 will error, 0.12 will not 52 | // unsure why 0.12 terraform init does not have a non-zero exit code for syntax problems 53 | if err == nil { 54 | t.Fatalf("expected error, but did not get one") 55 | } 56 | } 57 | 58 | var expectedDiags []tfjson.Diagnostic 59 | 60 | if tfv.Core().GreaterThanOrEqual(v0_15_0) { 61 | expectedDiags = []tfjson.Diagnostic{ 62 | { 63 | Severity: "error", 64 | Summary: "Unsupported block type", 65 | Detail: "Blocks of type \"bad_block\" are not expected here.", 66 | Range: &tfjson.Range{ 67 | Filename: "main.tf", 68 | Start: tfjson.Pos{ 69 | Line: 1, 70 | Column: 1, 71 | }, 72 | End: tfjson.Pos{ 73 | Line: 1, 74 | Column: 10, 75 | }, 76 | }, 77 | Snippet: &tfjson.DiagnosticSnippet{ 78 | Code: "bad_block {", 79 | StartLine: 1, 80 | HighlightStartOffset: 0, 81 | HighlightEndOffset: 9, 82 | Values: []tfjson.DiagnosticExpressionValue{}, 83 | }, 84 | }, 85 | { 86 | Severity: "error", 87 | Summary: "Unsupported argument", 88 | Detail: "An argument named \"bad_attribute\" is not expected here.", 89 | Range: &tfjson.Range{ 90 | Filename: "main.tf", 91 | Start: tfjson.Pos{ 92 | Line: 5, 93 | Column: 5, 94 | }, 95 | End: tfjson.Pos{ 96 | Line: 5, 97 | Column: 18, 98 | }, 99 | }, 100 | Snippet: &tfjson.DiagnosticSnippet{ 101 | Context: ptrToString("terraform"), 102 | Code: " bad_attribute = \"string\"", 103 | StartLine: 5, 104 | HighlightStartOffset: 4, 105 | HighlightEndOffset: 17, 106 | Values: []tfjson.DiagnosticExpressionValue{}, 107 | }, 108 | }, 109 | } 110 | } else { 111 | expectedDiags = []tfjson.Diagnostic{ 112 | { 113 | Severity: "error", 114 | Summary: "Unsupported block type", 115 | Detail: "Blocks of type \"bad_block\" are not expected here.", 116 | Range: &tfjson.Range{ 117 | Filename: "main.tf", 118 | Start: tfjson.Pos{ 119 | Line: 1, 120 | Column: 1, 121 | }, 122 | End: tfjson.Pos{ 123 | Line: 1, 124 | Column: 10, 125 | }, 126 | }, 127 | }, 128 | { 129 | Severity: "error", 130 | Summary: "Unsupported argument", 131 | Detail: "An argument named \"bad_attribute\" is not expected here.", 132 | Range: &tfjson.Range{ 133 | Filename: "main.tf", 134 | Start: tfjson.Pos{ 135 | Line: 5, 136 | Column: 5, 137 | }, 138 | End: tfjson.Pos{ 139 | Line: 5, 140 | Column: 18, 141 | }, 142 | }, 143 | }, 144 | } 145 | } 146 | 147 | actual, err := tf.Validate(context.Background()) 148 | if err != nil { 149 | t.Fatal(err) 150 | } 151 | 152 | // reset byte locations in actual as CRLF issues render them off between operating systems 153 | cleanActual := []tfjson.Diagnostic{} 154 | for _, diag := range actual.Diagnostics { 155 | diag.Range.Start.Byte = 0 156 | diag.Range.End.Byte = 0 157 | cleanActual = append(cleanActual, diag) 158 | } 159 | 160 | if diff := cmp.Diff(expectedDiags, cleanActual); diff != "" { 161 | t.Fatalf("diags do not match: %s", diff) 162 | } 163 | }) 164 | } 165 | 166 | func ptrToString(value string) *string { 167 | return &value 168 | } 169 | -------------------------------------------------------------------------------- /tfexec/internal/e2etest/version_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package e2etest 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/go-version" 11 | 12 | "github.com/hashicorp/terraform-exec/tfexec" 13 | ) 14 | 15 | func TestVersion(t *testing.T) { 16 | runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { 17 | ctx := context.Background() 18 | 19 | err := tf.Init(ctx) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | 24 | v, _, err := tf.Version(ctx, false) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | if !v.Equal(tfv) { 29 | t.Fatalf("expected version %q, got %q", tfv, v) 30 | } 31 | 32 | // TODO: test/assert provider info 33 | 34 | // force execution / skip cache as well 35 | v, _, err = tf.Version(ctx, true) 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | if !v.Equal(tfv) { 40 | t.Fatalf("expected version %q, got %q", tfv, v) 41 | } 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /tfexec/internal/testutil/logger.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package testutil 5 | 6 | import ( 7 | "io/ioutil" 8 | "log" 9 | "os" 10 | "testing" 11 | ) 12 | 13 | func TestLogger() *log.Logger { 14 | if testing.Verbose() { 15 | return log.New(os.Stdout, "", log.LstdFlags|log.Lshortfile) 16 | } 17 | return log.New(ioutil.Discard, "", 0) 18 | } 19 | -------------------------------------------------------------------------------- /tfexec/internal/testutil/tfcache.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package testutil 5 | 6 | import ( 7 | "context" 8 | "sync" 9 | "testing" 10 | 11 | "github.com/hashicorp/go-version" 12 | "github.com/hashicorp/hc-install/build" 13 | "github.com/hashicorp/hc-install/product" 14 | "github.com/hashicorp/hc-install/releases" 15 | ) 16 | 17 | const ( 18 | Latest011 = "0.11.15" 19 | Latest012 = "0.12.31" 20 | Latest013 = "0.13.7" 21 | Latest014 = "0.14.11" 22 | Latest015 = "0.15.5" 23 | Latest_v1 = "1.0.11" 24 | Latest_v1_1 = "1.1.9" 25 | Latest_v1_5 = "1.5.3" 26 | Latest_v1_6 = "1.6.6" 27 | Latest_v1_7 = "1.7.5" 28 | Latest_v1_8 = "1.8.5" 29 | Latest_Beta_v1_8 = "1.8.0-beta1" 30 | Latest_v1_9 = "1.9.7" 31 | Latest_Alpha_v1_9 = "1.9.0-alpha20240516" 32 | Latest_Alpha_v1_10 = "1.10.0-alpha20240926" 33 | ) 34 | 35 | const appendUserAgent = "tfexec-testutil" 36 | 37 | type TFCache struct { 38 | sync.Mutex 39 | 40 | dir string 41 | execs map[string]string 42 | } 43 | 44 | func NewTFCache(dir string) *TFCache { 45 | return &TFCache{ 46 | dir: dir, 47 | execs: map[string]string{}, 48 | } 49 | } 50 | 51 | func (tf *TFCache) GitRef(t *testing.T, ref string) string { 52 | t.Helper() 53 | 54 | key := "gitref:" + ref 55 | 56 | return tf.find(t, key, func(ctx context.Context) (string, error) { 57 | gr := &build.GitRevision{ 58 | Product: product.Terraform, 59 | Ref: ref, 60 | } 61 | gr.SetLogger(TestLogger()) 62 | 63 | return gr.Build(ctx) 64 | }) 65 | } 66 | 67 | func (tf *TFCache) Version(t *testing.T, v string) string { 68 | t.Helper() 69 | 70 | key := "v:" + v 71 | 72 | return tf.find(t, key, func(ctx context.Context) (string, error) { 73 | ev := &releases.ExactVersion{ 74 | Product: product.Terraform, 75 | Version: version.Must(version.NewVersion(v)), 76 | } 77 | ev.SetLogger(TestLogger()) 78 | 79 | return ev.Install(ctx) 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /tfexec/internal/testutil/tfcache_find.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package testutil 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "testing" 13 | ) 14 | 15 | func (tf *TFCache) find(t *testing.T, key string, execPathFunc func(context.Context) (string, error)) string { 16 | t.Helper() 17 | 18 | if tf.dir == "" { 19 | // panic instead of t.fatal as this is going to affect all downstream tests reusing the cache entry 20 | panic("installDir not yet configured") 21 | } 22 | 23 | tf.Lock() 24 | defer tf.Unlock() 25 | 26 | if path, ok := tf.execs[key]; ok { 27 | return path 28 | } 29 | 30 | keyDir := key 31 | keyDir = strings.ReplaceAll(keyDir, ":", "-") 32 | keyDir = strings.ReplaceAll(keyDir, "/", "-") 33 | 34 | dir := filepath.Join(tf.dir, keyDir) 35 | 36 | t.Logf("caching exec %q in dir %q", key, dir) 37 | 38 | err := os.MkdirAll(dir, 0777) 39 | if err != nil { 40 | // panic instead of t.fatal as this is going to affect all downstream tests reusing the cache entry 41 | panic(fmt.Sprintf("unable to mkdir %q: %s", dir, err)) 42 | } 43 | 44 | ctx, cancelFunc := context.WithCancel(context.Background()) 45 | t.Cleanup(cancelFunc) 46 | 47 | execPath, err := execPathFunc(ctx) 48 | if err != nil { 49 | // panic instead of t.fatal as this is going to affect all downstream tests reusing the cache entry 50 | panic(fmt.Sprintf("error installing terraform %q: %s", key, err)) 51 | } 52 | 53 | tf.execs[key] = execPath 54 | 55 | return execPath 56 | } 57 | -------------------------------------------------------------------------------- /tfexec/metadata_functions.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "os/exec" 10 | 11 | tfjson "github.com/hashicorp/terraform-json" 12 | ) 13 | 14 | // MetadataFunctions represents the terraform metadata functions -json subcommand. 15 | func (tf *Terraform) MetadataFunctions(ctx context.Context) (*tfjson.MetadataFunctions, error) { 16 | err := tf.compatible(ctx, tf1_4_0, nil) 17 | if err != nil { 18 | return nil, fmt.Errorf("terraform metadata functions was added in 1.4.0: %w", err) 19 | } 20 | 21 | functionsCmd := tf.metadataFunctionsCmd(ctx) 22 | 23 | var ret tfjson.MetadataFunctions 24 | err = tf.runTerraformCmdJSON(ctx, functionsCmd, &ret) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | return &ret, nil 30 | } 31 | 32 | func (tf *Terraform) metadataFunctionsCmd(ctx context.Context, args ...string) *exec.Cmd { 33 | allArgs := []string{"metadata", "functions", "-json"} 34 | allArgs = append(allArgs, args...) 35 | 36 | return tf.buildTerraformCmd(ctx, nil, allArgs...) 37 | } 38 | -------------------------------------------------------------------------------- /tfexec/metadata_functions_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestMetadataFunctionsCmd(t *testing.T) { 14 | td := t.TempDir() 15 | 16 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1_1)) 17 | // tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1_4)) // TODO! enable after 1.4 release 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | 22 | // empty env, to avoid environ mismatch in testing 23 | tf.SetEnv(map[string]string{}) 24 | 25 | functionsCmd := tf.metadataFunctionsCmd(context.Background()) 26 | 27 | assertCmd(t, []string{ 28 | "metadata", 29 | "functions", 30 | "-json", 31 | }, nil, functionsCmd) 32 | } 33 | -------------------------------------------------------------------------------- /tfexec/output.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "os/exec" 10 | ) 11 | 12 | type outputConfig struct { 13 | state string 14 | json bool 15 | } 16 | 17 | var defaultOutputOptions = outputConfig{} 18 | 19 | // OutputOption represents options used in the Output method. 20 | type OutputOption interface { 21 | configureOutput(*outputConfig) 22 | } 23 | 24 | func (opt *StateOption) configureOutput(conf *outputConfig) { 25 | conf.state = opt.path 26 | } 27 | 28 | // OutputMeta represents the JSON output of 'terraform output -json', 29 | // which resembles state format version 3 due to a historical accident. 30 | // Please see hashicorp/terraform/command/output.go. 31 | // TODO KEM: Should this type be in terraform-json? 32 | type OutputMeta struct { 33 | Sensitive bool `json:"sensitive"` 34 | Type json.RawMessage `json:"type"` 35 | Value json.RawMessage `json:"value"` 36 | } 37 | 38 | // Output represents the terraform output subcommand. 39 | func (tf *Terraform) Output(ctx context.Context, opts ...OutputOption) (map[string]OutputMeta, error) { 40 | outputCmd := tf.outputCmd(ctx, opts...) 41 | 42 | outputs := map[string]OutputMeta{} 43 | err := tf.runTerraformCmdJSON(ctx, outputCmd, &outputs) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | return outputs, nil 49 | } 50 | 51 | func (tf *Terraform) outputCmd(ctx context.Context, opts ...OutputOption) *exec.Cmd { 52 | c := defaultOutputOptions 53 | 54 | for _, o := range opts { 55 | o.configureOutput(&c) 56 | } 57 | 58 | args := []string{"output", "-no-color", "-json"} 59 | 60 | // string opts: only pass if set 61 | if c.state != "" { 62 | args = append(args, "-state="+c.state) 63 | } 64 | 65 | return tf.buildTerraformCmd(ctx, nil, args...) 66 | } 67 | -------------------------------------------------------------------------------- /tfexec/output_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestOutputCmd(t *testing.T) { 14 | td := t.TempDir() 15 | 16 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | // empty env, to avoid environ mismatch in testing 22 | tf.SetEnv(map[string]string{}) 23 | 24 | t.Run("defaults", func(t *testing.T) { 25 | outputCmd := tf.outputCmd(context.Background()) 26 | 27 | assertCmd(t, []string{ 28 | "output", 29 | "-no-color", 30 | "-json", 31 | }, nil, outputCmd) 32 | }) 33 | 34 | t.Run("override all defaults", func(t *testing.T) { 35 | outputCmd := tf.outputCmd(context.Background(), 36 | State("teststate")) 37 | 38 | assertCmd(t, []string{ 39 | "output", 40 | "-no-color", 41 | "-json", 42 | "-state=teststate", 43 | }, nil, outputCmd) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /tfexec/plan_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestPlanCmd(t *testing.T) { 14 | td := t.TempDir() 15 | 16 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | // empty env, to avoid environ mismatch in testing 22 | tf.SetEnv(map[string]string{}) 23 | 24 | t.Run("defaults", func(t *testing.T) { 25 | planCmd, err := tf.planCmd(context.Background()) 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | 30 | assertCmd(t, []string{ 31 | "plan", 32 | "-no-color", 33 | "-input=false", 34 | "-detailed-exitcode", 35 | "-lock-timeout=0s", 36 | "-lock=true", 37 | "-parallelism=10", 38 | "-refresh=true", 39 | }, nil, planCmd) 40 | }) 41 | 42 | t.Run("override all defaults", func(t *testing.T) { 43 | planCmd, err := tf.planCmd(context.Background(), 44 | Destroy(true), 45 | Lock(false), 46 | LockTimeout("22s"), 47 | Out("whale"), 48 | Parallelism(42), 49 | Refresh(false), 50 | Replace("ford.prefect"), 51 | Replace("arthur.dent"), 52 | State("marvin"), 53 | Target("zaphod"), 54 | Target("beeblebrox"), 55 | Var("android=paranoid"), 56 | Var("brain_size=planet"), 57 | VarFile("trillian"), 58 | Dir("earth")) 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | 63 | assertCmd(t, []string{ 64 | "plan", 65 | "-no-color", 66 | "-input=false", 67 | "-detailed-exitcode", 68 | "-lock-timeout=22s", 69 | "-out=whale", 70 | "-state=marvin", 71 | "-var-file=trillian", 72 | "-lock=false", 73 | "-parallelism=42", 74 | "-refresh=false", 75 | "-replace=ford.prefect", 76 | "-replace=arthur.dent", 77 | "-destroy", 78 | "-target=zaphod", 79 | "-target=beeblebrox", 80 | "-var", "android=paranoid", 81 | "-var", "brain_size=planet", 82 | "earth", 83 | }, nil, planCmd) 84 | }) 85 | 86 | t.Run("run a refresh-only plan", func(t *testing.T) { 87 | planCmd, err := tf.planCmd(context.Background(), RefreshOnly(true)) 88 | if err != nil { 89 | t.Fatal(err) 90 | } 91 | 92 | assertCmd(t, []string{ 93 | "plan", 94 | "-no-color", 95 | "-input=false", 96 | "-detailed-exitcode", 97 | "-lock-timeout=0s", 98 | "-lock=true", 99 | "-parallelism=10", 100 | "-refresh=true", 101 | "-refresh-only", 102 | }, nil, planCmd) 103 | }) 104 | } 105 | 106 | func TestPlanJSONCmd(t *testing.T) { 107 | td := t.TempDir() 108 | 109 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 110 | if err != nil { 111 | t.Fatal(err) 112 | } 113 | 114 | // empty env, to avoid environ mismatch in testing 115 | tf.SetEnv(map[string]string{}) 116 | 117 | t.Run("defaults", func(t *testing.T) { 118 | planCmd, err := tf.planJSONCmd(context.Background()) 119 | if err != nil { 120 | t.Fatal(err) 121 | } 122 | 123 | assertCmd(t, []string{ 124 | "plan", 125 | "-no-color", 126 | "-input=false", 127 | "-detailed-exitcode", 128 | "-lock-timeout=0s", 129 | "-lock=true", 130 | "-parallelism=10", 131 | "-refresh=true", 132 | "-json", 133 | }, nil, planCmd) 134 | }) 135 | 136 | t.Run("override all defaults", func(t *testing.T) { 137 | planCmd, err := tf.planJSONCmd(context.Background(), 138 | Destroy(true), 139 | Lock(false), 140 | LockTimeout("22s"), 141 | Out("whale"), 142 | Parallelism(42), 143 | Refresh(false), 144 | Replace("ford.prefect"), 145 | Replace("arthur.dent"), 146 | State("marvin"), 147 | Target("zaphod"), 148 | Target("beeblebrox"), 149 | Var("android=paranoid"), 150 | Var("brain_size=planet"), 151 | VarFile("trillian"), 152 | Dir("earth")) 153 | if err != nil { 154 | t.Fatal(err) 155 | } 156 | 157 | assertCmd(t, []string{ 158 | "plan", 159 | "-no-color", 160 | "-input=false", 161 | "-detailed-exitcode", 162 | "-lock-timeout=22s", 163 | "-out=whale", 164 | "-state=marvin", 165 | "-var-file=trillian", 166 | "-lock=false", 167 | "-parallelism=42", 168 | "-refresh=false", 169 | "-replace=ford.prefect", 170 | "-replace=arthur.dent", 171 | "-destroy", 172 | "-target=zaphod", 173 | "-target=beeblebrox", 174 | "-var", "android=paranoid", 175 | "-var", "brain_size=planet", 176 | "-json", 177 | "earth", 178 | }, nil, planCmd) 179 | }) 180 | } 181 | 182 | func TestPlanCmd_AllowDeferral(t *testing.T) { 183 | td := t.TempDir() 184 | 185 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_Alpha_v1_9)) 186 | if err != nil { 187 | t.Fatal(err) 188 | } 189 | 190 | // empty env, to avoid environ mismatch in testing 191 | tf.SetEnv(map[string]string{}) 192 | 193 | t.Run("allow deferrals during plan", func(t *testing.T) { 194 | planCmd, err := tf.planCmd(context.Background(), AllowDeferral(true)) 195 | if err != nil { 196 | t.Fatal(err) 197 | } 198 | 199 | assertCmd(t, []string{ 200 | "plan", 201 | "-no-color", 202 | "-input=false", 203 | "-detailed-exitcode", 204 | "-lock-timeout=0s", 205 | "-lock=true", 206 | "-parallelism=10", 207 | "-refresh=true", 208 | "-allow-deferral", 209 | }, nil, planCmd) 210 | }) 211 | } 212 | -------------------------------------------------------------------------------- /tfexec/providers_lock.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "os/exec" 10 | ) 11 | 12 | type providersLockConfig struct { 13 | fsMirror string 14 | netMirror string 15 | platforms []string 16 | providers []string 17 | } 18 | 19 | var defaultProvidersLockOptions = providersLockConfig{} 20 | 21 | type ProvidersLockOption interface { 22 | configureProvidersLock(*providersLockConfig) 23 | } 24 | 25 | func (opt *FSMirrorOption) configureProvidersLock(conf *providersLockConfig) { 26 | conf.fsMirror = opt.fsMirror 27 | } 28 | 29 | func (opt *NetMirrorOption) configureProvidersLock(conf *providersLockConfig) { 30 | conf.netMirror = opt.netMirror 31 | } 32 | 33 | func (opt *PlatformOption) configureProvidersLock(conf *providersLockConfig) { 34 | conf.platforms = append(conf.platforms, opt.platform) 35 | } 36 | 37 | func (opt *ProviderOption) configureProvidersLock(conf *providersLockConfig) { 38 | conf.providers = append(conf.providers, opt.provider) 39 | } 40 | 41 | // ProvidersLock represents the `terraform providers lock` command 42 | func (tf *Terraform) ProvidersLock(ctx context.Context, opts ...ProvidersLockOption) error { 43 | err := tf.compatible(ctx, tf0_14_0, nil) 44 | if err != nil { 45 | return fmt.Errorf("terraform providers lock was added in 0.14.0: %w", err) 46 | } 47 | 48 | lockCmd := tf.providersLockCmd(ctx, opts...) 49 | 50 | err = tf.runTerraformCmd(ctx, lockCmd) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | return err 56 | } 57 | 58 | func (tf *Terraform) providersLockCmd(ctx context.Context, opts ...ProvidersLockOption) *exec.Cmd { 59 | c := defaultProvidersLockOptions 60 | 61 | for _, o := range opts { 62 | o.configureProvidersLock(&c) 63 | } 64 | args := []string{"providers", "lock"} 65 | 66 | // string options, only pass if set 67 | if c.fsMirror != "" { 68 | args = append(args, "-fs-mirror="+c.fsMirror) 69 | } 70 | 71 | if c.netMirror != "" { 72 | args = append(args, "-net-mirror="+c.netMirror) 73 | } 74 | 75 | for _, p := range c.platforms { 76 | args = append(args, "-platform="+p) 77 | } 78 | 79 | // positional providers argument 80 | for _, p := range c.providers { 81 | args = append(args, p) 82 | } 83 | 84 | return tf.buildTerraformCmd(ctx, nil, args...) 85 | } 86 | -------------------------------------------------------------------------------- /tfexec/providers_lock_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestProvidersLockCmd(t *testing.T) { 14 | td := t.TempDir() 15 | 16 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | // empty env, to avoid environ mismatch in testing 22 | tf.SetEnv(map[string]string{}) 23 | 24 | t.Run("defaults", func(t *testing.T) { 25 | lockCmd := tf.providersLockCmd(context.Background()) 26 | 27 | assertCmd(t, []string{ 28 | "providers", 29 | "lock", 30 | }, nil, lockCmd) 31 | }) 32 | 33 | t.Run("override all defaults", func(t *testing.T) { 34 | lockCmd := tf.providersLockCmd(context.Background(), FSMirror("test"), NetMirror("test"), Platform("linux_amd64"), Provider("workingdir")) 35 | 36 | assertCmd(t, []string{ 37 | "providers", 38 | "lock", 39 | "-fs-mirror=test", 40 | "-net-mirror=test", 41 | "-platform=linux_amd64", 42 | "workingdir", 43 | }, nil, lockCmd) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /tfexec/providers_schema.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "os/exec" 9 | 10 | tfjson "github.com/hashicorp/terraform-json" 11 | ) 12 | 13 | // ProvidersSchema represents the terraform providers schema -json subcommand. 14 | func (tf *Terraform) ProvidersSchema(ctx context.Context) (*tfjson.ProviderSchemas, error) { 15 | schemaCmd := tf.providersSchemaCmd(ctx) 16 | 17 | var ret tfjson.ProviderSchemas 18 | err := tf.runTerraformCmdJSON(ctx, schemaCmd, &ret) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | err = ret.Validate() 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | return &ret, nil 29 | } 30 | 31 | func (tf *Terraform) providersSchemaCmd(ctx context.Context, args ...string) *exec.Cmd { 32 | allArgs := []string{"providers", "schema", "-json", "-no-color"} 33 | allArgs = append(allArgs, args...) 34 | 35 | return tf.buildTerraformCmd(ctx, nil, allArgs...) 36 | } 37 | -------------------------------------------------------------------------------- /tfexec/providers_schema_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestProvidersSchemaCmd(t *testing.T) { 14 | td := t.TempDir() 15 | 16 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | // empty env, to avoid environ mismatch in testing 22 | tf.SetEnv(map[string]string{}) 23 | 24 | schemaCmd := tf.providersSchemaCmd(context.Background()) 25 | 26 | assertCmd(t, []string{ 27 | "providers", 28 | "schema", 29 | "-json", 30 | "-no-color", 31 | }, nil, schemaCmd) 32 | } 33 | -------------------------------------------------------------------------------- /tfexec/refresh.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "io" 10 | "os/exec" 11 | "strconv" 12 | ) 13 | 14 | type refreshConfig struct { 15 | backup string 16 | dir string 17 | lock bool 18 | lockTimeout string 19 | reattachInfo ReattachInfo 20 | state string 21 | stateOut string 22 | targets []string 23 | vars []string 24 | varFiles []string 25 | } 26 | 27 | var defaultRefreshOptions = refreshConfig{ 28 | lock: true, 29 | lockTimeout: "0s", 30 | } 31 | 32 | // RefreshCmdOption represents options used in the Refresh method. 33 | type RefreshCmdOption interface { 34 | configureRefresh(*refreshConfig) 35 | } 36 | 37 | func (opt *BackupOption) configureRefresh(conf *refreshConfig) { 38 | conf.backup = opt.path 39 | } 40 | 41 | func (opt *DirOption) configureRefresh(conf *refreshConfig) { 42 | conf.dir = opt.path 43 | } 44 | 45 | func (opt *LockOption) configureRefresh(conf *refreshConfig) { 46 | conf.lock = opt.lock 47 | } 48 | 49 | func (opt *LockTimeoutOption) configureRefresh(conf *refreshConfig) { 50 | conf.lockTimeout = opt.timeout 51 | } 52 | 53 | func (opt *ReattachOption) configureRefresh(conf *refreshConfig) { 54 | conf.reattachInfo = opt.info 55 | } 56 | 57 | func (opt *StateOption) configureRefresh(conf *refreshConfig) { 58 | conf.state = opt.path 59 | } 60 | 61 | func (opt *StateOutOption) configureRefresh(conf *refreshConfig) { 62 | conf.stateOut = opt.path 63 | } 64 | 65 | func (opt *TargetOption) configureRefresh(conf *refreshConfig) { 66 | conf.targets = append(conf.targets, opt.target) 67 | } 68 | 69 | func (opt *VarOption) configureRefresh(conf *refreshConfig) { 70 | conf.vars = append(conf.vars, opt.assignment) 71 | } 72 | 73 | func (opt *VarFileOption) configureRefresh(conf *refreshConfig) { 74 | conf.varFiles = append(conf.varFiles, opt.path) 75 | } 76 | 77 | // Refresh represents the terraform refresh subcommand. 78 | func (tf *Terraform) Refresh(ctx context.Context, opts ...RefreshCmdOption) error { 79 | cmd, err := tf.refreshCmd(ctx, opts...) 80 | if err != nil { 81 | return err 82 | } 83 | return tf.runTerraformCmd(ctx, cmd) 84 | } 85 | 86 | // RefreshJSON represents the terraform refresh subcommand with the `-json` flag. 87 | // Using the `-json` flag will result in 88 | // [machine-readable](https://developer.hashicorp.com/terraform/internals/machine-readable-ui) 89 | // JSON being written to the supplied `io.Writer`. RefreshJSON is likely to be 90 | // removed in a future major version in favour of Refresh returning JSON by default. 91 | func (tf *Terraform) RefreshJSON(ctx context.Context, w io.Writer, opts ...RefreshCmdOption) error { 92 | err := tf.compatible(ctx, tf0_15_3, nil) 93 | if err != nil { 94 | return fmt.Errorf("terraform refresh -json was added in 0.15.3: %w", err) 95 | } 96 | 97 | tf.SetStdout(w) 98 | 99 | cmd, err := tf.refreshJSONCmd(ctx, opts...) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | return tf.runTerraformCmd(ctx, cmd) 105 | } 106 | 107 | func (tf *Terraform) refreshCmd(ctx context.Context, opts ...RefreshCmdOption) (*exec.Cmd, error) { 108 | c := defaultRefreshOptions 109 | 110 | for _, o := range opts { 111 | o.configureRefresh(&c) 112 | } 113 | 114 | args := tf.buildRefreshArgs(c) 115 | 116 | return tf.buildRefreshCmd(ctx, c, args) 117 | 118 | } 119 | 120 | func (tf *Terraform) refreshJSONCmd(ctx context.Context, opts ...RefreshCmdOption) (*exec.Cmd, error) { 121 | c := defaultRefreshOptions 122 | 123 | for _, o := range opts { 124 | o.configureRefresh(&c) 125 | } 126 | 127 | args := tf.buildRefreshArgs(c) 128 | args = append(args, "-json") 129 | 130 | return tf.buildRefreshCmd(ctx, c, args) 131 | } 132 | 133 | func (tf *Terraform) buildRefreshArgs(c refreshConfig) []string { 134 | args := []string{"refresh", "-no-color", "-input=false"} 135 | 136 | // string opts: only pass if set 137 | if c.backup != "" { 138 | args = append(args, "-backup="+c.backup) 139 | } 140 | if c.lockTimeout != "" { 141 | args = append(args, "-lock-timeout="+c.lockTimeout) 142 | } 143 | if c.state != "" { 144 | args = append(args, "-state="+c.state) 145 | } 146 | if c.stateOut != "" { 147 | args = append(args, "-state-out="+c.stateOut) 148 | } 149 | for _, vf := range c.varFiles { 150 | args = append(args, "-var-file="+vf) 151 | } 152 | 153 | // boolean and numerical opts: always pass 154 | args = append(args, "-lock="+strconv.FormatBool(c.lock)) 155 | 156 | // string slice opts: split into separate args 157 | if c.targets != nil { 158 | for _, ta := range c.targets { 159 | args = append(args, "-target="+ta) 160 | } 161 | } 162 | if c.vars != nil { 163 | for _, v := range c.vars { 164 | args = append(args, "-var", v) 165 | } 166 | } 167 | 168 | return args 169 | } 170 | 171 | func (tf *Terraform) buildRefreshCmd(ctx context.Context, c refreshConfig, args []string) (*exec.Cmd, error) { 172 | // optional positional argument 173 | if c.dir != "" { 174 | args = append(args, c.dir) 175 | } 176 | 177 | mergeEnv := map[string]string{} 178 | if c.reattachInfo != nil { 179 | reattachStr, err := c.reattachInfo.marshalString() 180 | if err != nil { 181 | return nil, err 182 | } 183 | mergeEnv[reattachEnvVar] = reattachStr 184 | } 185 | 186 | return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil 187 | } 188 | -------------------------------------------------------------------------------- /tfexec/refresh_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestRefreshCmd(t *testing.T) { 14 | td := t.TempDir() 15 | 16 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | // empty env, to avoid environ mismatch in testing 22 | tf.SetEnv(map[string]string{}) 23 | 24 | t.Run("defaults", func(t *testing.T) { 25 | refreshCmd, err := tf.refreshCmd(context.Background()) 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | 30 | assertCmd(t, []string{ 31 | "refresh", 32 | "-no-color", 33 | "-input=false", 34 | "-lock-timeout=0s", 35 | "-lock=true", 36 | }, nil, refreshCmd) 37 | }) 38 | 39 | t.Run("override all defaults", func(t *testing.T) { 40 | refreshCmd, err := tf.refreshCmd(context.Background(), Backup("testbackup"), LockTimeout("200s"), State("teststate"), StateOut("teststateout"), VarFile("testvarfile"), Lock(false), Target("target1"), Target("target2"), Var("var1=foo"), Var("var2=bar"), Dir("refreshdir")) 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | assertCmd(t, []string{ 46 | "refresh", 47 | "-no-color", 48 | "-input=false", 49 | "-backup=testbackup", 50 | "-lock-timeout=200s", 51 | "-state=teststate", 52 | "-state-out=teststateout", 53 | "-var-file=testvarfile", 54 | "-lock=false", 55 | "-target=target1", 56 | "-target=target2", 57 | "-var", "var1=foo", 58 | "-var", "var2=bar", 59 | "refreshdir", 60 | }, nil, refreshCmd) 61 | }) 62 | } 63 | 64 | func TestRefreshJSONCmd(t *testing.T) { 65 | td := t.TempDir() 66 | 67 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 68 | if err != nil { 69 | t.Fatal(err) 70 | } 71 | 72 | // empty env, to avoid environ mismatch in testing 73 | tf.SetEnv(map[string]string{}) 74 | 75 | t.Run("defaults", func(t *testing.T) { 76 | refreshCmd, err := tf.refreshJSONCmd(context.Background()) 77 | if err != nil { 78 | t.Fatal(err) 79 | } 80 | 81 | assertCmd(t, []string{ 82 | "refresh", 83 | "-no-color", 84 | "-input=false", 85 | "-lock-timeout=0s", 86 | "-lock=true", 87 | "-json", 88 | }, nil, refreshCmd) 89 | }) 90 | 91 | t.Run("override all defaults", func(t *testing.T) { 92 | refreshCmd, err := tf.refreshJSONCmd(context.Background(), Backup("testbackup"), LockTimeout("200s"), State("teststate"), StateOut("teststateout"), VarFile("testvarfile"), Lock(false), Target("target1"), Target("target2"), Var("var1=foo"), Var("var2=bar"), Dir("refreshdir")) 93 | if err != nil { 94 | t.Fatal(err) 95 | } 96 | 97 | assertCmd(t, []string{ 98 | "refresh", 99 | "-no-color", 100 | "-input=false", 101 | "-backup=testbackup", 102 | "-lock-timeout=200s", 103 | "-state=teststate", 104 | "-state-out=teststateout", 105 | "-var-file=testvarfile", 106 | "-lock=false", 107 | "-target=target1", 108 | "-target=target2", 109 | "-var", "var1=foo", 110 | "-var", "var2=bar", 111 | "-json", 112 | "refreshdir", 113 | }, nil, refreshCmd) 114 | }) 115 | } 116 | -------------------------------------------------------------------------------- /tfexec/show_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestShowCmd(t *testing.T) { 14 | td := t.TempDir() 15 | 16 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | // empty env, to avoid environ mismatch in testing 22 | tf.SetEnv(map[string]string{}) 23 | 24 | // defaults 25 | showCmd := tf.showCmd(context.Background(), true, nil) 26 | 27 | assertCmd(t, []string{ 28 | "show", 29 | "-json", 30 | "-no-color", 31 | }, nil, showCmd) 32 | } 33 | 34 | func TestShowStateFileCmd(t *testing.T) { 35 | td := t.TempDir() 36 | 37 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | 42 | // empty env, to avoid environ mismatch in testing 43 | tf.SetEnv(map[string]string{}) 44 | 45 | showCmd := tf.showCmd(context.Background(), true, nil, "statefilepath") 46 | 47 | assertCmd(t, []string{ 48 | "show", 49 | "-json", 50 | "-no-color", 51 | "statefilepath", 52 | }, nil, showCmd) 53 | } 54 | 55 | func TestShowPlanFileCmd(t *testing.T) { 56 | td := t.TempDir() 57 | 58 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | 63 | // empty env, to avoid environ mismatch in testing 64 | tf.SetEnv(map[string]string{}) 65 | 66 | showCmd := tf.showCmd(context.Background(), true, nil, "planfilepath") 67 | 68 | assertCmd(t, []string{ 69 | "show", 70 | "-json", 71 | "-no-color", 72 | "planfilepath", 73 | }, nil, showCmd) 74 | } 75 | 76 | func TestShowPlanFileRawCmd(t *testing.T) { 77 | td := t.TempDir() 78 | 79 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 80 | if err != nil { 81 | t.Fatal(err) 82 | } 83 | 84 | // empty env, to avoid environ mismatch in testing 85 | tf.SetEnv(map[string]string{}) 86 | 87 | showCmd := tf.showCmd(context.Background(), false, nil, "planfilepath") 88 | 89 | assertCmd(t, []string{ 90 | "show", 91 | "-no-color", 92 | "planfilepath", 93 | }, nil, showCmd) 94 | } 95 | -------------------------------------------------------------------------------- /tfexec/sleepmock_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | "os" 10 | "os/signal" 11 | "time" 12 | ) 13 | 14 | func sleepMock(rawDuration string) { 15 | signal.Ignore(os.Interrupt) 16 | 17 | d, err := time.ParseDuration(rawDuration) 18 | if err != nil { 19 | log.Fatalf("invalid duration format: %s", err) 20 | } 21 | 22 | fmt.Printf("sleeping for %s\n", d) 23 | 24 | time.Sleep(d) 25 | } 26 | -------------------------------------------------------------------------------- /tfexec/state_mv.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "os/exec" 9 | "strconv" 10 | ) 11 | 12 | type stateMvConfig struct { 13 | backup string 14 | backupOut string 15 | dryRun bool 16 | lock bool 17 | lockTimeout string 18 | state string 19 | stateOut string 20 | } 21 | 22 | var defaultStateMvOptions = stateMvConfig{ 23 | lock: true, 24 | lockTimeout: "0s", 25 | } 26 | 27 | // StateMvCmdOption represents options used in the Refresh method. 28 | type StateMvCmdOption interface { 29 | configureStateMv(*stateMvConfig) 30 | } 31 | 32 | func (opt *BackupOption) configureStateMv(conf *stateMvConfig) { 33 | conf.backup = opt.path 34 | } 35 | 36 | func (opt *BackupOutOption) configureStateMv(conf *stateMvConfig) { 37 | conf.backupOut = opt.path 38 | } 39 | 40 | func (opt *DryRunOption) configureStateMv(conf *stateMvConfig) { 41 | conf.dryRun = opt.dryRun 42 | } 43 | 44 | func (opt *LockOption) configureStateMv(conf *stateMvConfig) { 45 | conf.lock = opt.lock 46 | } 47 | 48 | func (opt *LockTimeoutOption) configureStateMv(conf *stateMvConfig) { 49 | conf.lockTimeout = opt.timeout 50 | } 51 | 52 | func (opt *StateOption) configureStateMv(conf *stateMvConfig) { 53 | conf.state = opt.path 54 | } 55 | 56 | func (opt *StateOutOption) configureStateMv(conf *stateMvConfig) { 57 | conf.stateOut = opt.path 58 | } 59 | 60 | // StateMv represents the terraform state mv subcommand. 61 | func (tf *Terraform) StateMv(ctx context.Context, source string, destination string, opts ...StateMvCmdOption) error { 62 | cmd, err := tf.stateMvCmd(ctx, source, destination, opts...) 63 | if err != nil { 64 | return err 65 | } 66 | return tf.runTerraformCmd(ctx, cmd) 67 | } 68 | 69 | func (tf *Terraform) stateMvCmd(ctx context.Context, source string, destination string, opts ...StateMvCmdOption) (*exec.Cmd, error) { 70 | c := defaultStateMvOptions 71 | 72 | for _, o := range opts { 73 | o.configureStateMv(&c) 74 | } 75 | 76 | args := []string{"state", "mv", "-no-color"} 77 | 78 | // string opts: only pass if set 79 | if c.backup != "" { 80 | args = append(args, "-backup="+c.backup) 81 | } 82 | if c.backupOut != "" { 83 | args = append(args, "-backup-out="+c.backupOut) 84 | } 85 | if c.lockTimeout != "" { 86 | args = append(args, "-lock-timeout="+c.lockTimeout) 87 | } 88 | if c.state != "" { 89 | args = append(args, "-state="+c.state) 90 | } 91 | if c.stateOut != "" { 92 | args = append(args, "-state-out="+c.stateOut) 93 | } 94 | 95 | // boolean and numerical opts: always pass 96 | args = append(args, "-lock="+strconv.FormatBool(c.lock)) 97 | 98 | // unary flags: pass if true 99 | if c.dryRun { 100 | args = append(args, "-dry-run") 101 | } 102 | 103 | // positional arguments 104 | args = append(args, source) 105 | args = append(args, destination) 106 | 107 | return tf.buildTerraformCmd(ctx, nil, args...), nil 108 | } 109 | -------------------------------------------------------------------------------- /tfexec/state_mv_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestStateMvCmd(t *testing.T) { 14 | td := t.TempDir() 15 | 16 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | // empty env, to avoid environ mismatch in testing 22 | tf.SetEnv(map[string]string{}) 23 | 24 | t.Run("defaults", func(t *testing.T) { 25 | stateMvCmd, err := tf.stateMvCmd(context.Background(), "testsource", "testdestination") 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | 30 | assertCmd(t, []string{ 31 | "state", 32 | "mv", 33 | "-no-color", 34 | "-lock-timeout=0s", 35 | "-lock=true", 36 | "testsource", 37 | "testdestination", 38 | }, nil, stateMvCmd) 39 | }) 40 | 41 | t.Run("override all defaults", func(t *testing.T) { 42 | stateMvCmd, err := tf.stateMvCmd(context.Background(), "testsrc", "testdest", Backup("testbackup"), BackupOut("testbackupout"), LockTimeout("200s"), State("teststate"), StateOut("teststateout"), Lock(false)) 43 | if err != nil { 44 | t.Fatal(err) 45 | } 46 | 47 | assertCmd(t, []string{ 48 | "state", 49 | "mv", 50 | "-no-color", 51 | "-backup=testbackup", 52 | "-backup-out=testbackupout", 53 | "-lock-timeout=200s", 54 | "-state=teststate", 55 | "-state-out=teststateout", 56 | "-lock=false", 57 | "testsrc", 58 | "testdest", 59 | }, nil, stateMvCmd) 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /tfexec/state_pull.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "bytes" 8 | "context" 9 | "os/exec" 10 | ) 11 | 12 | type statePullConfig struct { 13 | reattachInfo ReattachInfo 14 | } 15 | 16 | var defaultStatePullConfig = statePullConfig{} 17 | 18 | type StatePullOption interface { 19 | configureShow(*statePullConfig) 20 | } 21 | 22 | func (opt *ReattachOption) configureStatePull(conf *statePullConfig) { 23 | conf.reattachInfo = opt.info 24 | } 25 | 26 | func (tf *Terraform) StatePull(ctx context.Context, opts ...StatePullOption) (string, error) { 27 | c := defaultStatePullConfig 28 | 29 | for _, o := range opts { 30 | o.configureShow(&c) 31 | } 32 | 33 | mergeEnv := map[string]string{} 34 | if c.reattachInfo != nil { 35 | reattachStr, err := c.reattachInfo.marshalString() 36 | if err != nil { 37 | return "", err 38 | } 39 | mergeEnv[reattachEnvVar] = reattachStr 40 | } 41 | 42 | cmd := tf.statePullCmd(ctx, mergeEnv) 43 | 44 | var ret bytes.Buffer 45 | cmd.Stdout = &ret 46 | err := tf.runTerraformCmd(ctx, cmd) 47 | if err != nil { 48 | return "", err 49 | } 50 | 51 | return ret.String(), nil 52 | } 53 | 54 | func (tf *Terraform) statePullCmd(ctx context.Context, mergeEnv map[string]string) *exec.Cmd { 55 | args := []string{"state", "pull"} 56 | 57 | return tf.buildTerraformCmd(ctx, mergeEnv, args...) 58 | } 59 | -------------------------------------------------------------------------------- /tfexec/state_pull_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestStatePull(t *testing.T) { 14 | tf, err := NewTerraform(t.TempDir(), tfVersion(t, testutil.Latest_v1)) 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | 19 | tf.SetEnv(map[string]string{}) 20 | 21 | t.Run("tfstate", func(t *testing.T) { 22 | statePullCmd := tf.statePullCmd(context.Background(), nil) 23 | 24 | assertCmd(t, []string{ 25 | "state", 26 | "pull", 27 | }, nil, statePullCmd) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /tfexec/state_push.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "os/exec" 9 | "strconv" 10 | ) 11 | 12 | type statePushConfig struct { 13 | force bool 14 | lock bool 15 | lockTimeout string 16 | } 17 | 18 | var defaultStatePushOptions = statePushConfig{ 19 | lock: false, 20 | lockTimeout: "0s", 21 | } 22 | 23 | // StatePushCmdOption represents options used in the Refresh method. 24 | type StatePushCmdOption interface { 25 | configureStatePush(*statePushConfig) 26 | } 27 | 28 | func (opt *ForceOption) configureStatePush(conf *statePushConfig) { 29 | conf.force = opt.force 30 | } 31 | 32 | func (opt *LockOption) configureStatePush(conf *statePushConfig) { 33 | conf.lock = opt.lock 34 | } 35 | 36 | func (opt *LockTimeoutOption) configureStatePush(conf *statePushConfig) { 37 | conf.lockTimeout = opt.timeout 38 | } 39 | 40 | func (tf *Terraform) StatePush(ctx context.Context, path string, opts ...StatePushCmdOption) error { 41 | cmd, err := tf.statePushCmd(ctx, path, opts...) 42 | if err != nil { 43 | return err 44 | } 45 | return tf.runTerraformCmd(ctx, cmd) 46 | } 47 | 48 | func (tf *Terraform) statePushCmd(ctx context.Context, path string, opts ...StatePushCmdOption) (*exec.Cmd, error) { 49 | c := defaultStatePushOptions 50 | 51 | for _, o := range opts { 52 | o.configureStatePush(&c) 53 | } 54 | 55 | args := []string{"state", "push"} 56 | 57 | if c.force { 58 | args = append(args, "-force") 59 | } 60 | 61 | args = append(args, "-lock="+strconv.FormatBool(c.lock)) 62 | 63 | if c.lockTimeout != "" { 64 | args = append(args, "-lock-timeout="+c.lockTimeout) 65 | } 66 | 67 | args = append(args, path) 68 | 69 | return tf.buildTerraformCmd(ctx, nil, args...), nil 70 | } 71 | -------------------------------------------------------------------------------- /tfexec/state_push_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestStatePushCmd(t *testing.T) { 14 | tf, err := NewTerraform(t.TempDir(), tfVersion(t, testutil.Latest_v1)) 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | 19 | tf.SetEnv(map[string]string{}) 20 | 21 | t.Run("defaults", func(t *testing.T) { 22 | statePushCmd, err := tf.statePushCmd(context.Background(), "testpath") 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | assertCmd(t, []string{ 28 | "state", 29 | "push", 30 | "-lock=false", 31 | "-lock-timeout=0s", 32 | "testpath", 33 | }, nil, statePushCmd) 34 | }) 35 | 36 | t.Run("override all defaults", func(t *testing.T) { 37 | statePushCmd, err := tf.statePushCmd(context.Background(), "testpath", Force(true), Lock(true), LockTimeout("10s")) 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | 42 | assertCmd(t, []string{ 43 | "state", 44 | "push", 45 | "-force", 46 | "-lock=true", 47 | "-lock-timeout=10s", 48 | "testpath", 49 | }, nil, statePushCmd) 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /tfexec/state_rm.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "os/exec" 9 | "strconv" 10 | ) 11 | 12 | type stateRmConfig struct { 13 | backup string 14 | backupOut string 15 | dryRun bool 16 | lock bool 17 | lockTimeout string 18 | state string 19 | stateOut string 20 | } 21 | 22 | var defaultStateRmOptions = stateRmConfig{ 23 | lock: true, 24 | lockTimeout: "0s", 25 | } 26 | 27 | // StateRmCmdOption represents options used in the Refresh method. 28 | type StateRmCmdOption interface { 29 | configureStateRm(*stateRmConfig) 30 | } 31 | 32 | func (opt *BackupOption) configureStateRm(conf *stateRmConfig) { 33 | conf.backup = opt.path 34 | } 35 | 36 | func (opt *BackupOutOption) configureStateRm(conf *stateRmConfig) { 37 | conf.backupOut = opt.path 38 | } 39 | 40 | func (opt *DryRunOption) configureStateRm(conf *stateRmConfig) { 41 | conf.dryRun = opt.dryRun 42 | } 43 | 44 | func (opt *LockOption) configureStateRm(conf *stateRmConfig) { 45 | conf.lock = opt.lock 46 | } 47 | 48 | func (opt *LockTimeoutOption) configureStateRm(conf *stateRmConfig) { 49 | conf.lockTimeout = opt.timeout 50 | } 51 | 52 | func (opt *StateOption) configureStateRm(conf *stateRmConfig) { 53 | conf.state = opt.path 54 | } 55 | 56 | func (opt *StateOutOption) configureStateRm(conf *stateRmConfig) { 57 | conf.stateOut = opt.path 58 | } 59 | 60 | // StateRm represents the terraform state rm subcommand. 61 | func (tf *Terraform) StateRm(ctx context.Context, address string, opts ...StateRmCmdOption) error { 62 | cmd, err := tf.stateRmCmd(ctx, address, opts...) 63 | if err != nil { 64 | return err 65 | } 66 | return tf.runTerraformCmd(ctx, cmd) 67 | } 68 | 69 | func (tf *Terraform) stateRmCmd(ctx context.Context, address string, opts ...StateRmCmdOption) (*exec.Cmd, error) { 70 | c := defaultStateRmOptions 71 | 72 | for _, o := range opts { 73 | o.configureStateRm(&c) 74 | } 75 | 76 | args := []string{"state", "rm", "-no-color"} 77 | 78 | // string opts: only pass if set 79 | if c.backup != "" { 80 | args = append(args, "-backup="+c.backup) 81 | } 82 | if c.backupOut != "" { 83 | args = append(args, "-backup-out="+c.backupOut) 84 | } 85 | if c.lockTimeout != "" { 86 | args = append(args, "-lock-timeout="+c.lockTimeout) 87 | } 88 | if c.state != "" { 89 | args = append(args, "-state="+c.state) 90 | } 91 | if c.stateOut != "" { 92 | args = append(args, "-state-out="+c.stateOut) 93 | } 94 | 95 | // boolean and numerical opts: always pass 96 | args = append(args, "-lock="+strconv.FormatBool(c.lock)) 97 | 98 | // unary flags: pass if true 99 | if c.dryRun { 100 | args = append(args, "-dry-run") 101 | } 102 | 103 | // positional arguments 104 | args = append(args, address) 105 | 106 | return tf.buildTerraformCmd(ctx, nil, args...), nil 107 | } 108 | -------------------------------------------------------------------------------- /tfexec/state_rm_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestStateRmCmd(t *testing.T) { 14 | td := t.TempDir() 15 | 16 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | // empty env, to avoid environ mismatch in testing 22 | tf.SetEnv(map[string]string{}) 23 | 24 | t.Run("defaults", func(t *testing.T) { 25 | stateRmCmd, err := tf.stateRmCmd(context.Background(), "testAddress") 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | 30 | assertCmd(t, []string{ 31 | "state", 32 | "rm", 33 | "-no-color", 34 | "-lock-timeout=0s", 35 | "-lock=true", 36 | "testAddress", 37 | }, nil, stateRmCmd) 38 | }) 39 | 40 | t.Run("override all defaults", func(t *testing.T) { 41 | stateRmCmd, err := tf.stateRmCmd(context.Background(), "testAddress", Backup("testbackup"), BackupOut("testbackupout"), LockTimeout("200s"), State("teststate"), StateOut("teststateout"), Lock(false)) 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | 46 | assertCmd(t, []string{ 47 | "state", 48 | "rm", 49 | "-no-color", 50 | "-backup=testbackup", 51 | "-backup-out=testbackupout", 52 | "-lock-timeout=200s", 53 | "-state=teststate", 54 | "-state-out=teststateout", 55 | "-lock=false", 56 | "testAddress", 57 | }, nil, stateRmCmd) 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /tfexec/taint.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "os/exec" 10 | "strconv" 11 | ) 12 | 13 | type taintConfig struct { 14 | state string 15 | allowMissing bool 16 | lock bool 17 | lockTimeout string 18 | } 19 | 20 | var defaultTaintOptions = taintConfig{ 21 | allowMissing: false, 22 | lock: true, 23 | } 24 | 25 | // TaintOption represents options used in the Taint method. 26 | type TaintOption interface { 27 | configureTaint(*taintConfig) 28 | } 29 | 30 | func (opt *StateOption) configureTaint(conf *taintConfig) { 31 | conf.state = opt.path 32 | } 33 | 34 | func (opt *AllowMissingOption) configureTaint(conf *taintConfig) { 35 | conf.allowMissing = opt.allowMissing 36 | } 37 | 38 | func (opt *LockOption) configureTaint(conf *taintConfig) { 39 | conf.lock = opt.lock 40 | } 41 | 42 | func (opt *LockTimeoutOption) configureTaint(conf *taintConfig) { 43 | conf.lockTimeout = opt.timeout 44 | } 45 | 46 | // Taint represents the terraform taint subcommand. 47 | func (tf *Terraform) Taint(ctx context.Context, address string, opts ...TaintOption) error { 48 | err := tf.compatible(ctx, tf0_4_1, nil) 49 | if err != nil { 50 | return fmt.Errorf("taint was first introduced in Terraform 0.4.1: %w", err) 51 | } 52 | taintCmd := tf.taintCmd(ctx, address, opts...) 53 | return tf.runTerraformCmd(ctx, taintCmd) 54 | } 55 | 56 | func (tf *Terraform) taintCmd(ctx context.Context, address string, opts ...TaintOption) *exec.Cmd { 57 | c := defaultTaintOptions 58 | 59 | for _, o := range opts { 60 | o.configureTaint(&c) 61 | } 62 | 63 | args := []string{"taint", "-no-color"} 64 | 65 | if c.lockTimeout != "" { 66 | args = append(args, "-lock-timeout="+c.lockTimeout) 67 | } 68 | 69 | // string opts: only pass if set 70 | if c.state != "" { 71 | args = append(args, "-state="+c.state) 72 | } 73 | 74 | args = append(args, "-lock="+strconv.FormatBool(c.lock)) 75 | if c.allowMissing { 76 | args = append(args, "-allow-missing") 77 | } 78 | args = append(args, address) 79 | 80 | return tf.buildTerraformCmd(ctx, nil, args...) 81 | } 82 | -------------------------------------------------------------------------------- /tfexec/taint_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestTaintCmd(t *testing.T) { 14 | td := t.TempDir() 15 | 16 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | // empty env, to avoid environ mismatch in testing 22 | tf.SetEnv(map[string]string{}) 23 | 24 | t.Run("defaults", func(t *testing.T) { 25 | taintCmd := tf.taintCmd(context.Background(), "aws_instance.foo") 26 | 27 | assertCmd(t, []string{ 28 | "taint", 29 | "-no-color", 30 | "-lock=true", 31 | "aws_instance.foo", 32 | }, nil, taintCmd) 33 | }) 34 | 35 | t.Run("override all defaults", func(t *testing.T) { 36 | taintCmd := tf.taintCmd(context.Background(), "aws_instance.foo", 37 | State("teststate"), 38 | AllowMissing(true), 39 | LockTimeout("200s"), 40 | Lock(false)) 41 | 42 | assertCmd(t, []string{ 43 | "taint", 44 | "-no-color", 45 | "-lock-timeout=200s", 46 | "-state=teststate", 47 | "-lock=false", 48 | "-allow-missing", 49 | "aws_instance.foo", 50 | }, nil, taintCmd) 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /tfexec/test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "io" 10 | "os/exec" 11 | ) 12 | 13 | type testConfig struct { 14 | testsDirectory string 15 | } 16 | 17 | var defaultTestOptions = testConfig{} 18 | 19 | type TestOption interface { 20 | configureTest(*testConfig) 21 | } 22 | 23 | func (opt *TestsDirectoryOption) configureTest(conf *testConfig) { 24 | conf.testsDirectory = opt.testsDirectory 25 | } 26 | 27 | // Test represents the terraform test -json subcommand. 28 | // 29 | // The given io.Writer, if specified, will receive 30 | // [machine-readable](https://developer.hashicorp.com/terraform/internals/machine-readable-ui) 31 | // JSON from Terraform including test results. 32 | func (tf *Terraform) Test(ctx context.Context, w io.Writer, opts ...TestOption) error { 33 | err := tf.compatible(ctx, tf1_6_0, nil) 34 | 35 | if err != nil { 36 | return fmt.Errorf("terraform test was added in 1.6.0: %w", err) 37 | } 38 | 39 | tf.SetStdout(w) 40 | 41 | testCmd := tf.testCmd(ctx) 42 | 43 | err = tf.runTerraformCmd(ctx, testCmd) 44 | 45 | if err != nil { 46 | return err 47 | } 48 | 49 | return nil 50 | } 51 | 52 | func (tf *Terraform) testCmd(ctx context.Context, opts ...TestOption) *exec.Cmd { 53 | c := defaultTestOptions 54 | 55 | for _, o := range opts { 56 | o.configureTest(&c) 57 | } 58 | 59 | args := []string{"test", "-json"} 60 | 61 | if c.testsDirectory != "" { 62 | args = append(args, "-tests-directory="+c.testsDirectory) 63 | } 64 | 65 | return tf.buildTerraformCmd(ctx, nil, args...) 66 | } 67 | -------------------------------------------------------------------------------- /tfexec/test_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestTestCmd(t *testing.T) { 14 | td := t.TempDir() 15 | 16 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1_6)) 17 | 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | 22 | // empty env, to avoid environ mismatch in testing 23 | tf.SetEnv(map[string]string{}) 24 | 25 | t.Run("defaults", func(t *testing.T) { 26 | testCmd := tf.testCmd(context.Background()) 27 | 28 | assertCmd(t, []string{ 29 | "test", 30 | "-json", 31 | }, nil, testCmd) 32 | }) 33 | 34 | t.Run("override all defaults", func(t *testing.T) { 35 | testCmd := tf.testCmd(context.Background(), TestsDirectory("test")) 36 | 37 | assertCmd(t, []string{ 38 | "test", 39 | "-json", 40 | "-tests-directory=test", 41 | }, nil, testCmd) 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /tfexec/untaint.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "os/exec" 10 | "strconv" 11 | ) 12 | 13 | type untaintConfig struct { 14 | state string 15 | allowMissing bool 16 | lock bool 17 | lockTimeout string 18 | } 19 | 20 | var defaultUntaintOptions = untaintConfig{ 21 | allowMissing: false, 22 | lock: true, 23 | } 24 | 25 | // OutputOption represents options used in the Output method. 26 | type UntaintOption interface { 27 | configureUntaint(*untaintConfig) 28 | } 29 | 30 | func (opt *StateOption) configureUntaint(conf *untaintConfig) { 31 | conf.state = opt.path 32 | } 33 | 34 | func (opt *AllowMissingOption) configureUntaint(conf *untaintConfig) { 35 | conf.allowMissing = opt.allowMissing 36 | } 37 | 38 | func (opt *LockOption) configureUntaint(conf *untaintConfig) { 39 | conf.lock = opt.lock 40 | } 41 | 42 | func (opt *LockTimeoutOption) configureUntaint(conf *untaintConfig) { 43 | conf.lockTimeout = opt.timeout 44 | } 45 | 46 | // Untaint represents the terraform untaint subcommand. 47 | func (tf *Terraform) Untaint(ctx context.Context, address string, opts ...UntaintOption) error { 48 | err := tf.compatible(ctx, tf0_6_13, nil) 49 | if err != nil { 50 | return fmt.Errorf("untaint was first introduced in Terraform 0.6.13: %w", err) 51 | } 52 | untaintCmd := tf.untaintCmd(ctx, address, opts...) 53 | return tf.runTerraformCmd(ctx, untaintCmd) 54 | } 55 | 56 | func (tf *Terraform) untaintCmd(ctx context.Context, address string, opts ...UntaintOption) *exec.Cmd { 57 | c := defaultUntaintOptions 58 | 59 | for _, o := range opts { 60 | o.configureUntaint(&c) 61 | } 62 | 63 | args := []string{"untaint", "-no-color"} 64 | 65 | if c.lockTimeout != "" { 66 | args = append(args, "-lock-timeout="+c.lockTimeout) 67 | } 68 | 69 | // string opts: only pass if set 70 | if c.state != "" { 71 | args = append(args, "-state="+c.state) 72 | } 73 | 74 | args = append(args, "-lock="+strconv.FormatBool(c.lock)) 75 | if c.allowMissing { 76 | args = append(args, "-allow-missing") 77 | } 78 | args = append(args, address) 79 | 80 | return tf.buildTerraformCmd(ctx, nil, args...) 81 | } 82 | -------------------------------------------------------------------------------- /tfexec/untaint_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestUntaintCmd(t *testing.T) { 14 | td := t.TempDir() 15 | 16 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | // empty env, to avoid environ mismatch in testing 22 | tf.SetEnv(map[string]string{}) 23 | 24 | t.Run("defaults", func(t *testing.T) { 25 | untaintCmd := tf.untaintCmd(context.Background(), "aws_instance.foo") 26 | 27 | assertCmd(t, []string{ 28 | "untaint", 29 | "-no-color", 30 | "-lock=true", 31 | "aws_instance.foo", 32 | }, nil, untaintCmd) 33 | }) 34 | 35 | t.Run("override all defaults", func(t *testing.T) { 36 | untaintCmd := tf.untaintCmd(context.Background(), "aws_instance.foo", 37 | State("teststate"), 38 | AllowMissing(true), 39 | LockTimeout("200s"), 40 | Lock(false)) 41 | 42 | assertCmd(t, []string{ 43 | "untaint", 44 | "-no-color", 45 | "-lock-timeout=200s", 46 | "-state=teststate", 47 | "-lock=false", 48 | "-allow-missing", 49 | "aws_instance.foo", 50 | }, nil, untaintCmd) 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /tfexec/upgrade012.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "os/exec" 10 | ) 11 | 12 | type upgrade012Config struct { 13 | dir string 14 | force bool 15 | 16 | reattachInfo ReattachInfo 17 | } 18 | 19 | var defaultUpgrade012Options = upgrade012Config{ 20 | force: false, 21 | } 22 | 23 | // Upgrade012Option represents options used in the Destroy method. 24 | type Upgrade012Option interface { 25 | configureUpgrade012(*upgrade012Config) 26 | } 27 | 28 | func (opt *DirOption) configureUpgrade012(conf *upgrade012Config) { 29 | conf.dir = opt.path 30 | } 31 | 32 | func (opt *ForceOption) configureUpgrade012(conf *upgrade012Config) { 33 | conf.force = opt.force 34 | } 35 | 36 | func (opt *ReattachOption) configureUpgrade012(conf *upgrade012Config) { 37 | conf.reattachInfo = opt.info 38 | } 39 | 40 | // Upgrade012 represents the terraform 0.12upgrade subcommand. 41 | func (tf *Terraform) Upgrade012(ctx context.Context, opts ...Upgrade012Option) error { 42 | cmd, err := tf.upgrade012Cmd(ctx, opts...) 43 | if err != nil { 44 | return err 45 | } 46 | return tf.runTerraformCmd(ctx, cmd) 47 | } 48 | 49 | func (tf *Terraform) upgrade012Cmd(ctx context.Context, opts ...Upgrade012Option) (*exec.Cmd, error) { 50 | err := tf.compatible(ctx, tf0_12_0, tf0_13_0) 51 | if err != nil { 52 | return nil, fmt.Errorf("terraform 0.12upgrade is only supported in 0.12 releases: %w", err) 53 | } 54 | 55 | c := defaultUpgrade012Options 56 | 57 | for _, o := range opts { 58 | o.configureUpgrade012(&c) 59 | } 60 | 61 | args := []string{"0.12upgrade", "-no-color", "-yes"} 62 | 63 | // boolean opts: only pass if set 64 | if c.force { 65 | args = append(args, "-force") 66 | } 67 | 68 | // optional positional argument 69 | if c.dir != "" { 70 | args = append(args, c.dir) 71 | } 72 | 73 | mergeEnv := map[string]string{} 74 | if c.reattachInfo != nil { 75 | reattachStr, err := c.reattachInfo.marshalString() 76 | if err != nil { 77 | return nil, err 78 | } 79 | mergeEnv[reattachEnvVar] = reattachStr 80 | } 81 | 82 | return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil 83 | } 84 | -------------------------------------------------------------------------------- /tfexec/upgrade012_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "runtime" 11 | "testing" 12 | 13 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 14 | ) 15 | 16 | func TestUpgrade012(t *testing.T) { 17 | if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { 18 | t.Skip("Terraform for darwin/arm64 is not available until v1") 19 | } 20 | 21 | td := t.TempDir() 22 | 23 | t.Run("defaults", func(t *testing.T) { 24 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest012)) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | // empty env, to avoid environ mismatch in testing 30 | tf.SetEnv(map[string]string{}) 31 | 32 | upgrade012Cmd, err := tf.upgrade012Cmd(context.Background()) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | assertCmd(t, []string{ 38 | "0.12upgrade", 39 | "-no-color", 40 | "-yes", 41 | }, nil, upgrade012Cmd) 42 | }) 43 | 44 | t.Run("override all defaults", func(t *testing.T) { 45 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest012)) 46 | if err != nil { 47 | t.Fatal(err) 48 | } 49 | 50 | // empty env, to avoid environ mismatch in testing 51 | tf.SetEnv(map[string]string{}) 52 | 53 | upgrade012Cmd, err := tf.upgrade012Cmd(context.Background(), Force(true), Dir("upgrade012dir")) 54 | if err != nil { 55 | t.Fatal(err) 56 | } 57 | 58 | assertCmd(t, []string{ 59 | "0.12upgrade", 60 | "-no-color", 61 | "-yes", 62 | "-force", 63 | "upgrade012dir", 64 | }, nil, upgrade012Cmd) 65 | }) 66 | 67 | unsupportedVersions := []string{ 68 | testutil.Latest011, 69 | testutil.Latest013, 70 | } 71 | for _, tfv := range unsupportedVersions { 72 | t.Run(fmt.Sprintf("unsupported on %s", tfv), func(t *testing.T) { 73 | tf, err := NewTerraform(td, tfVersion(t, tfv)) 74 | if err != nil { 75 | t.Fatal(err) 76 | } 77 | 78 | // empty env, to avoid environ mismatch in testing 79 | tf.SetEnv(map[string]string{}) 80 | 81 | _, err = tf.upgrade012Cmd(context.Background()) 82 | if err == nil { 83 | t.Fatalf("expected unsupported version %s to fail", tfv) 84 | } 85 | 86 | var expectedErr *ErrVersionMismatch 87 | if !errors.As(err, &expectedErr) { 88 | t.Fatalf("error doesn't match: %#v", err) 89 | } 90 | }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tfexec/upgrade013.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "os/exec" 10 | ) 11 | 12 | type upgrade013Config struct { 13 | dir string 14 | 15 | reattachInfo ReattachInfo 16 | } 17 | 18 | var defaultUpgrade013Options = upgrade013Config{} 19 | 20 | // Upgrade013Option represents options used in the Destroy method. 21 | type Upgrade013Option interface { 22 | configureUpgrade013(*upgrade013Config) 23 | } 24 | 25 | func (opt *DirOption) configureUpgrade013(conf *upgrade013Config) { 26 | conf.dir = opt.path 27 | } 28 | 29 | func (opt *ReattachOption) configureUpgrade013(conf *upgrade013Config) { 30 | conf.reattachInfo = opt.info 31 | } 32 | 33 | // Upgrade013 represents the terraform 0.13upgrade subcommand. 34 | func (tf *Terraform) Upgrade013(ctx context.Context, opts ...Upgrade013Option) error { 35 | cmd, err := tf.upgrade013Cmd(ctx, opts...) 36 | if err != nil { 37 | return err 38 | } 39 | return tf.runTerraformCmd(ctx, cmd) 40 | } 41 | 42 | func (tf *Terraform) upgrade013Cmd(ctx context.Context, opts ...Upgrade013Option) (*exec.Cmd, error) { 43 | err := tf.compatible(ctx, tf0_13_0, tf0_14_0) 44 | if err != nil { 45 | return nil, fmt.Errorf("terraform 0.13upgrade is only supported in 0.13 releases: %w", err) 46 | } 47 | 48 | c := defaultUpgrade013Options 49 | 50 | for _, o := range opts { 51 | o.configureUpgrade013(&c) 52 | } 53 | 54 | args := []string{"0.13upgrade", "-no-color", "-yes"} 55 | 56 | // optional positional argument 57 | if c.dir != "" { 58 | args = append(args, c.dir) 59 | } 60 | 61 | mergeEnv := map[string]string{} 62 | if c.reattachInfo != nil { 63 | reattachStr, err := c.reattachInfo.marshalString() 64 | if err != nil { 65 | return nil, err 66 | } 67 | mergeEnv[reattachEnvVar] = reattachStr 68 | } 69 | 70 | return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil 71 | } 72 | -------------------------------------------------------------------------------- /tfexec/upgrade013_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "runtime" 11 | "testing" 12 | 13 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 14 | ) 15 | 16 | func TestUpgrade013(t *testing.T) { 17 | if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { 18 | t.Skip("Terraform for darwin/arm64 is not available until v1") 19 | } 20 | 21 | td := t.TempDir() 22 | 23 | t.Run("defaults", func(t *testing.T) { 24 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest013)) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | // empty env, to avoid environ mismatch in testing 30 | tf.SetEnv(map[string]string{}) 31 | 32 | upgrade013Cmd, err := tf.upgrade013Cmd(context.Background()) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | assertCmd(t, []string{ 38 | "0.13upgrade", 39 | "-no-color", 40 | "-yes", 41 | }, nil, upgrade013Cmd) 42 | }) 43 | 44 | t.Run("override all defaults", func(t *testing.T) { 45 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest013)) 46 | if err != nil { 47 | t.Fatal(err) 48 | } 49 | 50 | // empty env, to avoid environ mismatch in testing 51 | tf.SetEnv(map[string]string{}) 52 | 53 | upgrade013Cmd, err := tf.upgrade013Cmd(context.Background(), Dir("upgrade013dir")) 54 | if err != nil { 55 | t.Fatal(err) 56 | } 57 | 58 | assertCmd(t, []string{ 59 | "0.13upgrade", 60 | "-no-color", 61 | "-yes", 62 | "upgrade013dir", 63 | }, nil, upgrade013Cmd) 64 | }) 65 | 66 | unsupportedVersions := []string{ 67 | testutil.Latest011, 68 | testutil.Latest012, 69 | testutil.Latest014, 70 | testutil.Latest015, 71 | } 72 | for _, tfv := range unsupportedVersions { 73 | t.Run(fmt.Sprintf("unsupported on %s", tfv), func(t *testing.T) { 74 | tf, err := NewTerraform(td, tfVersion(t, tfv)) 75 | if err != nil { 76 | t.Fatal(err) 77 | } 78 | 79 | // empty env, to avoid environ mismatch in testing 80 | tf.SetEnv(map[string]string{}) 81 | 82 | _, err = tf.upgrade013Cmd(context.Background()) 83 | if err == nil { 84 | t.Fatalf("expected unsupported version %s to fail", tfv) 85 | } 86 | 87 | var expectedErr *ErrVersionMismatch 88 | if !errors.As(err, &expectedErr) { 89 | t.Fatalf("error doesn't match: %#v", err) 90 | } 91 | }) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tfexec/validate.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "bytes" 8 | "context" 9 | "encoding/json" 10 | "fmt" 11 | 12 | tfjson "github.com/hashicorp/terraform-json" 13 | ) 14 | 15 | // Validate represents the validate subcommand to the Terraform CLI. The -json 16 | // flag support was added in 0.12.0, so this will not work on earlier versions. 17 | func (tf *Terraform) Validate(ctx context.Context) (*tfjson.ValidateOutput, error) { 18 | err := tf.compatible(ctx, tf0_12_0, nil) 19 | if err != nil { 20 | return nil, fmt.Errorf("terraform validate -json was added in 0.12.0: %w", err) 21 | } 22 | 23 | cmd := tf.buildTerraformCmd(ctx, nil, "validate", "-no-color", "-json") 24 | 25 | var outBuf = bytes.Buffer{} 26 | cmd.Stdout = &outBuf 27 | 28 | err = tf.runTerraformCmd(ctx, cmd) 29 | // TODO: this command should not exit 1 if you pass -json as its hard to differentiate other errors 30 | if err != nil && cmd.ProcessState.ExitCode() != 1 { 31 | return nil, err 32 | } 33 | 34 | var ret tfjson.ValidateOutput 35 | // TODO: ret.UseJSONNumber(true) validate output should support JSON numbers 36 | jsonErr := json.Unmarshal(outBuf.Bytes(), &ret) 37 | if jsonErr != nil { 38 | // the original call was possibly bad, if it has an error, actually just return that 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return nil, jsonErr 44 | } 45 | 46 | return &ret, nil 47 | } 48 | -------------------------------------------------------------------------------- /tfexec/workspace_delete.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "os/exec" 10 | "strconv" 11 | ) 12 | 13 | type workspaceDeleteConfig struct { 14 | lock bool 15 | lockTimeout string 16 | force bool 17 | } 18 | 19 | var defaultWorkspaceDeleteOptions = workspaceDeleteConfig{ 20 | lock: true, 21 | lockTimeout: "0s", 22 | } 23 | 24 | // WorkspaceDeleteCmdOption represents options that are applicable to the WorkspaceDelete method. 25 | type WorkspaceDeleteCmdOption interface { 26 | configureWorkspaceDelete(*workspaceDeleteConfig) 27 | } 28 | 29 | func (opt *LockOption) configureWorkspaceDelete(conf *workspaceDeleteConfig) { 30 | conf.lock = opt.lock 31 | } 32 | 33 | func (opt *LockTimeoutOption) configureWorkspaceDelete(conf *workspaceDeleteConfig) { 34 | conf.lockTimeout = opt.timeout 35 | } 36 | 37 | func (opt *ForceOption) configureWorkspaceDelete(conf *workspaceDeleteConfig) { 38 | conf.force = opt.force 39 | } 40 | 41 | // WorkspaceDelete represents the workspace delete subcommand to the Terraform CLI. 42 | func (tf *Terraform) WorkspaceDelete(ctx context.Context, workspace string, opts ...WorkspaceDeleteCmdOption) error { 43 | cmd, err := tf.workspaceDeleteCmd(ctx, workspace, opts...) 44 | if err != nil { 45 | return err 46 | } 47 | return tf.runTerraformCmd(ctx, cmd) 48 | } 49 | 50 | func (tf *Terraform) workspaceDeleteCmd(ctx context.Context, workspace string, opts ...WorkspaceDeleteCmdOption) (*exec.Cmd, error) { 51 | c := defaultWorkspaceDeleteOptions 52 | 53 | for _, o := range opts { 54 | switch o.(type) { 55 | case *LockOption, *LockTimeoutOption: 56 | err := tf.compatible(ctx, tf0_12_0, nil) 57 | if err != nil { 58 | return nil, fmt.Errorf("-lock and -lock-timeout were added to workspace delete in Terraform 0.12: %w", err) 59 | } 60 | } 61 | 62 | o.configureWorkspaceDelete(&c) 63 | } 64 | 65 | args := []string{"workspace", "delete", "-no-color"} 66 | 67 | if c.force { 68 | args = append(args, "-force") 69 | } 70 | if c.lockTimeout != "" && c.lockTimeout != defaultWorkspaceDeleteOptions.lockTimeout { 71 | // only pass if not default, so we don't need to worry about the 0.11 version check 72 | args = append(args, "-lock-timeout="+c.lockTimeout) 73 | } 74 | if !c.lock { 75 | // only pass if false, so we don't need to worry about the 0.11 version check 76 | args = append(args, "-lock="+strconv.FormatBool(c.lock)) 77 | } 78 | 79 | args = append(args, workspace) 80 | 81 | cmd := tf.buildTerraformCmd(ctx, nil, args...) 82 | 83 | return cmd, nil 84 | } 85 | -------------------------------------------------------------------------------- /tfexec/workspace_delete_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestWorkspaceDeleteCmd(t *testing.T) { 14 | tf, err := NewTerraform(t.TempDir(), tfVersion(t, testutil.Latest_v1)) 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | 19 | // empty env, to avoid environ mismatch in testing 20 | tf.SetEnv(map[string]string{}) 21 | 22 | t.Run("defaults", func(t *testing.T) { 23 | workspaceDeleteCmd, err := tf.workspaceDeleteCmd(context.Background(), "workspace-name") 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | assertCmd(t, []string{ 29 | "workspace", "delete", 30 | "-no-color", 31 | "workspace-name", 32 | }, nil, workspaceDeleteCmd) 33 | }) 34 | 35 | t.Run("override all defaults", func(t *testing.T) { 36 | workspaceDeleteCmd, err := tf.workspaceDeleteCmd(context.Background(), "workspace-name", 37 | LockTimeout("200s"), 38 | Force(true), 39 | Lock(false)) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | assertCmd(t, []string{ 45 | "workspace", "delete", 46 | "-no-color", 47 | "-force", 48 | "-lock-timeout=200s", 49 | "-lock=false", 50 | "workspace-name", 51 | }, nil, workspaceDeleteCmd) 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /tfexec/workspace_list.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "strings" 9 | ) 10 | 11 | // WorkspaceList represents the workspace list subcommand to the Terraform CLI. 12 | func (tf *Terraform) WorkspaceList(ctx context.Context) ([]string, string, error) { 13 | // TODO: [DIR] param option 14 | wlCmd := tf.buildTerraformCmd(ctx, nil, "workspace", "list", "-no-color") 15 | 16 | var outBuf strings.Builder 17 | wlCmd.Stdout = &outBuf 18 | 19 | err := tf.runTerraformCmd(ctx, wlCmd) 20 | if err != nil { 21 | return nil, "", err 22 | } 23 | 24 | ws, current := parseWorkspaceList(outBuf.String()) 25 | 26 | return ws, current, nil 27 | } 28 | 29 | const currentWorkspacePrefix = "* " 30 | 31 | func parseWorkspaceList(stdout string) ([]string, string) { 32 | lines := strings.Split(stdout, "\n") 33 | 34 | current := "" 35 | workspaces := []string{} 36 | for _, line := range lines { 37 | line = strings.TrimSpace(line) 38 | if line == "" { 39 | continue 40 | } 41 | if strings.HasPrefix(line, currentWorkspacePrefix) { 42 | line = strings.TrimPrefix(line, currentWorkspacePrefix) 43 | current = line 44 | } 45 | workspaces = append(workspaces, line) 46 | } 47 | 48 | return workspaces, current 49 | } 50 | -------------------------------------------------------------------------------- /tfexec/workspace_list_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "fmt" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestParseWorkspaceList(t *testing.T) { 13 | for i, c := range []struct { 14 | expected []string 15 | expectedCurrent string 16 | stdout string 17 | }{ 18 | { 19 | []string{"default"}, 20 | "default", 21 | `* default 22 | 23 | `, 24 | }, 25 | { 26 | []string{"default", "foo", "bar"}, 27 | "foo", 28 | ` default 29 | * foo 30 | bar 31 | 32 | `, 33 | }, 34 | 35 | // linux new lines 36 | { 37 | []string{"default", "foo"}, 38 | "foo", 39 | " default\n* foo\n\n", 40 | }, 41 | // windows new lines 42 | { 43 | []string{"default", "foo"}, 44 | "foo", 45 | " default\r\n* foo\r\n\r\n", 46 | }, 47 | } { 48 | t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { 49 | actualList, actualCurrent := parseWorkspaceList(c.stdout) 50 | 51 | if actualCurrent != c.expectedCurrent { 52 | t.Fatalf("expected selected %q, got %q", c.expectedCurrent, actualCurrent) 53 | } 54 | 55 | if !reflect.DeepEqual(c.expected, actualList) { 56 | t.Fatalf("expected %#v, got %#v", c.expected, actualList) 57 | } 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tfexec/workspace_new.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "os/exec" 10 | "strconv" 11 | ) 12 | 13 | type workspaceNewConfig struct { 14 | lock bool 15 | lockTimeout string 16 | copyState string 17 | } 18 | 19 | var defaultWorkspaceNewOptions = workspaceNewConfig{ 20 | lock: true, 21 | lockTimeout: "0s", 22 | } 23 | 24 | // WorkspaceNewCmdOption represents options that are applicable to the WorkspaceNew method. 25 | type WorkspaceNewCmdOption interface { 26 | configureWorkspaceNew(*workspaceNewConfig) 27 | } 28 | 29 | func (opt *LockOption) configureWorkspaceNew(conf *workspaceNewConfig) { 30 | conf.lock = opt.lock 31 | } 32 | 33 | func (opt *LockTimeoutOption) configureWorkspaceNew(conf *workspaceNewConfig) { 34 | conf.lockTimeout = opt.timeout 35 | } 36 | 37 | func (opt *CopyStateOption) configureWorkspaceNew(conf *workspaceNewConfig) { 38 | conf.copyState = opt.path 39 | } 40 | 41 | // WorkspaceNew represents the workspace new subcommand to the Terraform CLI. 42 | func (tf *Terraform) WorkspaceNew(ctx context.Context, workspace string, opts ...WorkspaceNewCmdOption) error { 43 | cmd, err := tf.workspaceNewCmd(ctx, workspace, opts...) 44 | if err != nil { 45 | return err 46 | } 47 | return tf.runTerraformCmd(ctx, cmd) 48 | } 49 | 50 | func (tf *Terraform) workspaceNewCmd(ctx context.Context, workspace string, opts ...WorkspaceNewCmdOption) (*exec.Cmd, error) { 51 | // TODO: [DIR] param option 52 | 53 | c := defaultWorkspaceNewOptions 54 | 55 | for _, o := range opts { 56 | switch o.(type) { 57 | case *LockOption, *LockTimeoutOption: 58 | err := tf.compatible(ctx, tf0_12_0, nil) 59 | if err != nil { 60 | return nil, fmt.Errorf("-lock and -lock-timeout were added to workspace new in Terraform 0.12: %w", err) 61 | } 62 | } 63 | 64 | o.configureWorkspaceNew(&c) 65 | } 66 | 67 | args := []string{"workspace", "new", "-no-color"} 68 | 69 | if c.lockTimeout != "" && c.lockTimeout != defaultWorkspaceNewOptions.lockTimeout { 70 | // only pass if not default, so we don't need to worry about the 0.11 version check 71 | args = append(args, "-lock-timeout="+c.lockTimeout) 72 | } 73 | if !c.lock { 74 | // only pass if false, so we don't need to worry about the 0.11 version check 75 | args = append(args, "-lock="+strconv.FormatBool(c.lock)) 76 | } 77 | if c.copyState != "" { 78 | args = append(args, "-state="+c.copyState) 79 | } 80 | 81 | args = append(args, workspace) 82 | 83 | cmd := tf.buildTerraformCmd(ctx, nil, args...) 84 | 85 | return cmd, nil 86 | } 87 | -------------------------------------------------------------------------------- /tfexec/workspace_new_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "os" 9 | "testing" 10 | 11 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 12 | ) 13 | 14 | func TestWorkspaceNewCmd(t *testing.T) { 15 | td := t.TempDir() 16 | 17 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | 22 | // empty env, to avoid environ mismatch in testing 23 | tf.SetEnv(map[string]string{ 24 | // propagate for temp dirs 25 | "TMPDIR": os.Getenv("TMPDIR"), 26 | "TMP": os.Getenv("TMP"), 27 | "TEMP": os.Getenv("TEMP"), 28 | "USERPROFILE": os.Getenv("USERPROFILE"), 29 | }) 30 | 31 | t.Run("defaults", func(t *testing.T) { 32 | workspaceNewCmd, err := tf.workspaceNewCmd(context.Background(), "workspace-name") 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | assertCmd(t, []string{ 38 | "workspace", "new", 39 | "-no-color", 40 | "workspace-name", 41 | }, nil, workspaceNewCmd) 42 | }) 43 | 44 | t.Run("override all defaults", func(t *testing.T) { 45 | workspaceNewCmd, err := tf.workspaceNewCmd(context.Background(), "workspace-name", LockTimeout("200s"), CopyState("teststate"), Lock(false)) 46 | if err != nil { 47 | t.Fatal(err) 48 | } 49 | 50 | assertCmd(t, []string{ 51 | "workspace", "new", 52 | "-no-color", 53 | "-lock-timeout=200s", 54 | "-lock=false", 55 | "-state=teststate", 56 | "workspace-name", 57 | }, nil, workspaceNewCmd) 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /tfexec/workspace_select.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import "context" 7 | 8 | // WorkspaceSelect represents the workspace select subcommand to the Terraform CLI. 9 | func (tf *Terraform) WorkspaceSelect(ctx context.Context, workspace string) error { 10 | // TODO: [DIR] param option 11 | 12 | return tf.runTerraformCmd(ctx, tf.buildTerraformCmd(ctx, nil, "workspace", "select", "-no-color", workspace)) 13 | } 14 | -------------------------------------------------------------------------------- /tfexec/workspace_show.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "os/exec" 10 | "strings" 11 | ) 12 | 13 | // WorkspaceShow represents the workspace show subcommand to the Terraform CLI. 14 | func (tf *Terraform) WorkspaceShow(ctx context.Context) (string, error) { 15 | workspaceShowCmd, err := tf.workspaceShowCmd(ctx) 16 | if err != nil { 17 | return "", err 18 | } 19 | 20 | var outBuffer strings.Builder 21 | workspaceShowCmd.Stdout = &outBuffer 22 | 23 | err = tf.runTerraformCmd(ctx, workspaceShowCmd) 24 | if err != nil { 25 | return "", err 26 | } 27 | 28 | return strings.TrimSpace(outBuffer.String()), nil 29 | } 30 | 31 | func (tf *Terraform) workspaceShowCmd(ctx context.Context) (*exec.Cmd, error) { 32 | err := tf.compatible(ctx, tf0_10_0, nil) 33 | if err != nil { 34 | return nil, fmt.Errorf("workspace show was first introduced in Terraform 0.10.0: %w", err) 35 | } 36 | 37 | return tf.buildTerraformCmd(ctx, nil, "workspace", "show", "-no-color"), nil 38 | } 39 | -------------------------------------------------------------------------------- /tfexec/workspace_show_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tfexec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" 11 | ) 12 | 13 | func TestWorkspaceShowCmd_v1(t *testing.T) { 14 | td := t.TempDir() 15 | 16 | tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1)) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | // empty env, to avoid environ mismatch in testing 22 | tf.SetEnv(map[string]string{}) 23 | 24 | cmd, err := tf.workspaceShowCmd(context.Background()) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | assertCmd(t, []string{ 30 | "workspace", 31 | "show", 32 | "-no-color", 33 | }, map[string]string{}, cmd) 34 | } 35 | --------------------------------------------------------------------------------