Merge branch 'hashicorp:main' into feat/browser

This commit is contained in:
Michael Brewer 2024-06-08 22:52:57 -07:00 committed by GitHub
commit 355bc51f50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
260 changed files with 13576 additions and 4755 deletions

23
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,23 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
labels:
- dependencies
- build
- security
reviewers:
- hashicorp/terraform-core
# only update HashiCorp actions, external actions managed by TSCCR
allow:
- dependency-name: hashicorp/*
groups:
github-actions-breaking:
update-types:
- major
github-actions-backward-compatible:
update-types:
- minor
- patch

View file

@ -38,8 +38,8 @@ jobs:
runs-on: ${{ inputs.runson }}
name: Terraform ${{ inputs.goos }} ${{ inputs.goarch }} v${{ inputs.product-version }}
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1
with:
go-version: ${{ inputs.go-version }}
- name: Build Terraform
@ -49,7 +49,7 @@ jobs:
GO_LDFLAGS: ${{ inputs.ld-flags }}
ACTIONSOS: ${{ inputs.runson }}
CGO_ENABLED: ${{ inputs.cgo-enabled }}
uses: hashicorp/actions-go-build@e20c6be7bf010e40e930dab20e6da63176725ec1 # v0.1.9
uses: hashicorp/actions-go-build@37358f6098ef21b09542d84a9814ebb843aa4e3e # v1
with:
product_name: ${{ inputs.package-name }}
product_version: ${{ inputs.product-version }}
@ -67,7 +67,7 @@ jobs:
run: |
mkdir -p "$LICENSE_DIR" && cp LICENSE "$LICENSE_DIR/LICENSE.txt"
- if: ${{ inputs.goos == 'linux' }}
uses: hashicorp/actions-packaging-linux@v1
uses: hashicorp/actions-packaging-linux@0596d94121d44bd00463ac9d245efea64ee282d0 # v1.7
with:
name: "terraform"
description: "Terraform enables you to safely and predictably create, change, and improve infrastructure. It is a tool that codifies APIs into declarative configuration files that can be shared amongst team members, treated as code, edited, reviewed, and versioned."
@ -86,13 +86,13 @@ jobs:
echo "RPM_PACKAGE=$(basename out/*.rpm)" >> $GITHUB_ENV
echo "DEB_PACKAGE=$(basename out/*.deb)" >> $GITHUB_ENV
- if: ${{ inputs.goos == 'linux' }}
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
with:
name: ${{ env.RPM_PACKAGE }}
path: out/${{ env.RPM_PACKAGE }}
if-no-files-found: error
- if: ${{ inputs.goos == 'linux' }}
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
with:
name: ${{ env.DEB_PACKAGE }}
path: out/${{ env.DEB_PACKAGE }}

View file

@ -12,6 +12,8 @@ on:
- main
- 'v[0-9]+.[0-9]+'
- releng/**
- tsccr-auto-pinning/**
- dependabot/**
tags:
- 'v[0-9]+.[0-9]+.[0-9]+*'
@ -35,7 +37,7 @@ jobs:
pkg-name: ${{ steps.get-pkg-name.outputs.pkg-name }}
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- name: Get Package Name
id: get-pkg-name
run: |
@ -43,7 +45,7 @@ jobs:
echo "pkg-name=${pkg_name}" | tee -a "${GITHUB_OUTPUT}"
- name: Decide version number
id: get-product-version
uses: hashicorp/actions-set-product-version@v1
uses: hashicorp/actions-set-product-version@e2c49d61aff17b1280ddfe7bb031331d02ca0140 # v1.0.1
- name: Determine experiments
id: get-ldflags
env:
@ -62,7 +64,7 @@ jobs:
go-version: ${{ steps.get-go-version.outputs.version }}
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- name: Determine Go version
id: get-go-version
uses: ./.github/actions/go-version
@ -75,15 +77,15 @@ jobs:
filepath: ${{ steps.generate-metadata-file.outputs.filepath }}
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- name: Generate package metadata
id: generate-metadata-file
uses: hashicorp/actions-generate-metadata@v1
uses: hashicorp/actions-generate-metadata@fdbc8803a0e53bcbb912ddeee3808329033d6357 # v1.1.1
with:
version: ${{ needs.get-product-version.outputs.product-version }}
product: ${{ env.PKG_NAME }}
- uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
- uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
with:
name: metadata.json
path: ${{ steps.generate-metadata-file.outputs.filepath }}
@ -119,8 +121,8 @@ jobs:
- {goos: "solaris", goarch: "amd64", runson: "ubuntu-latest", cgo-enabled: "0"}
- {goos: "windows", goarch: "386", runson: "ubuntu-latest", cgo-enabled: "0"}
- {goos: "windows", goarch: "amd64", runson: "ubuntu-latest", cgo-enabled: "0"}
- {goos: "darwin", goarch: "amd64", runson: "macos-latest", cgo-enabled: "1"}
- {goos: "darwin", goarch: "arm64", runson: "macos-latest", cgo-enabled: "1"}
- {goos: "darwin", goarch: "amd64", runson: "ubuntu-latest", cgo-enabled: "0"}
- {goos: "darwin", goarch: "arm64", runson: "ubuntu-latest", cgo-enabled: "0"}
fail-fast: false
package-docker:
@ -137,9 +139,9 @@ jobs:
repo: "terraform"
version: ${{needs.get-product-version.outputs.product-version}}
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- name: Build Docker images
uses: hashicorp/actions-docker-build@v1
uses: hashicorp/actions-docker-build@11d43ef520c65f58683d048ce9b47d6617893c9a # v2
with:
pkg_name: "terraform_${{env.version}}"
version: ${{env.version}}
@ -170,8 +172,8 @@ jobs:
- {goos: "windows", goarch: "386"}
- {goos: "linux", goarch: "386"}
- {goos: "linux", goarch: "amd64"}
- {goos: linux, goarch: "arm"}
- {goos: linux, goarch: "arm64"}
- {goos: "linux", goarch: "arm"}
- {goos: "linux", goarch: "arm64"}
fail-fast: false
env:
@ -185,10 +187,10 @@ jobs:
cache_path=internal/command/e2etest/build
echo "e2e-cache-key=${cache_key}" | tee -a "${GITHUB_OUTPUT}"
echo "e2e-cache-path=${cache_path}" | tee -a "${GITHUB_OUTPUT}"
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- name: Install Go toolchain
uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1
uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1
with:
go-version: ${{ needs.get-go-version.outputs.go-version }}
@ -205,7 +207,7 @@ jobs:
bash ./internal/command/e2etest/make-archive.sh
- name: Save test harness to cache
uses: actions/cache/save@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1
uses: actions/cache/save@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
with:
path: ${{ steps.set-cache-values.outputs.e2e-cache-path }}
key: ${{ steps.set-cache-values.outputs.e2e-cache-key }}_${{ matrix.goos }}_${{ matrix.goarch }}
@ -243,9 +245,9 @@ jobs:
# fresh build from source.)
- name: Checkout repo
if: ${{ (matrix.goos == 'linux') || (matrix.goos == 'darwin') }}
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- name: "Restore cache"
uses: actions/cache/restore@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1
uses: actions/cache/restore@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
id: e2etestpkg
with:
path: ${{ needs.e2etest-build.outputs.e2e-cache-path }}
@ -253,7 +255,7 @@ jobs:
fail-on-cache-miss: true
enableCrossOsArchive: true
- name: "Download Terraform CLI package"
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
id: clipkg
with:
name: terraform_${{env.version}}_${{ env.os }}_${{ env.arch }}.zip
@ -264,7 +266,7 @@ jobs:
unzip "${{ needs.e2etest-build.outputs.e2e-cache-path }}/terraform-e2etest_${{ env.os }}_${{ env.arch }}.zip"
unzip "./terraform_${{env.version}}_${{ env.os }}_${{ env.arch }}.zip"
- name: Set up QEMU
uses: docker/setup-qemu-action@2b82ce82d56a2a04d2637cd93a637ae1b359c0a7 # v2.2.0
uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0
if: ${{ contains(matrix.goarch, 'arm') }}
with:
platforms: all
@ -298,17 +300,17 @@ jobs:
steps:
- name: Install Go toolchain
uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1
uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1
with:
go-version: ${{ needs.get-go-version.outputs.go-version }}
- name: Download Terraform CLI package
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
id: clipkg
with:
name: terraform_${{ env.version }}_linux_amd64.zip
path: .
- name: Checkout terraform-exec repo
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
with:
repository: hashicorp/terraform-exec
path: terraform-exec

View file

@ -36,14 +36,14 @@ jobs:
steps:
- name: "Fetch source code"
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- name: Determine Go version
id: go
uses: ./.github/actions/go-version
- name: Install Go toolchain
uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1
uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1
with:
go-version: ${{ steps.go.outputs.version }}
@ -51,7 +51,7 @@ jobs:
# identical across the unit-tests, e2e-tests, and consistency-checks
# jobs, or else weird things could happen.
- name: Cache Go modules
uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
with:
path: "~/go/pkg"
key: go-mod-${{ hashFiles('go.sum') }}
@ -71,14 +71,14 @@ jobs:
steps:
- name: "Fetch source code"
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- name: Determine Go version
id: go
uses: ./.github/actions/go-version
- name: Install Go toolchain
uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1
uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1
with:
go-version: ${{ steps.go.outputs.version }}
@ -86,7 +86,7 @@ jobs:
# identical across the unit-tests, e2e-tests, and consistency-checks
# jobs, or else weird things could happen.
- name: Cache Go modules
uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
with:
path: "~/go/pkg"
key: go-mod-${{ hashFiles('go.sum') }}
@ -109,14 +109,14 @@ jobs:
steps:
- name: "Fetch source code"
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- name: Determine Go version
id: go
uses: ./.github/actions/go-version
- name: Install Go toolchain
uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1
uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1
with:
go-version: ${{ steps.go.outputs.version }}
@ -124,7 +124,7 @@ jobs:
# identical across the unit-tests, e2e-tests, and consistency-checks
# jobs, or else weird things could happen.
- name: Cache Go modules
uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
with:
path: "~/go/pkg"
key: go-mod-${{ hashFiles('go.sum') }}
@ -141,7 +141,7 @@ jobs:
steps:
- name: "Fetch source code"
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
with:
fetch-depth: 0 # We need to do comparisons against the main branch.
@ -150,7 +150,7 @@ jobs:
uses: ./.github/actions/go-version
- name: Install Go toolchain
uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1
uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1
with:
go-version: ${{ steps.go.outputs.version }}
@ -158,7 +158,7 @@ jobs:
# identical across the unit-tests, e2e-tests, and consistency-checks
# jobs, or else weird things could happen.
- name: Cache Go modules
uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
with:
path: "~/go/pkg"
key: go-mod-${{ hashFiles('go.sum') }}
@ -177,7 +177,7 @@ jobs:
fi
- name: Cache protobuf tools
uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
with:
path: "tools/protobuf-compile/.workdir"
key: protobuf-tools-${{ hashFiles('tools/protobuf-compile/protobuf-compile.go') }}

View file

@ -33,7 +33,7 @@ jobs:
needs:
- parse-metadata
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
with:
ref: ${{ needs.parse-metadata.outputs.target-branch }}
- uses: ./.github/actions/equivalence-test

View file

@ -10,7 +10,7 @@ jobs:
backport:
if: github.event.pull_request.merged
runs-on: ubuntu-latest
container: hashicorpdev/backport-assistant:0.3.4@sha256:1fb1e4dde82c28eaf27f4720eaffb2e19d490c8b42df244f834f5a550a703070
container: hashicorpdev/backport-assistant:0.4.3@sha256:2381806dd059c14515463b87e8a923d57734bd484beea6a561e411e25628e010
steps:
- name: Run Backport Assistant
run: |

View file

@ -25,7 +25,7 @@ jobs:
name: "Run equivalence tests"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
with:
ref: ${{ inputs.target-branch }}
- uses: ./.github/actions/equivalence-test

View file

@ -13,7 +13,7 @@ jobs:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1
- uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
github.rest.issues.createComment({

View file

@ -1,32 +1,19 @@
## 1.9.0 (Unreleased)
ENHANCEMENTS:
* `terraform console`: Now has basic support for multi-line input in interactive mode. ([#34822](https://github.com/hashicorp/terraform/pull/34822))
If an entered line contains opening paretheses/etc that are not closed, Terraform will await another line of input to complete the expression. This initial implementation is primarily intended to support pasting in multi-line expressions from elsewhere, rather than for manual multi-line editing, so the interactive editing support is currently limited.
* `cli`: Updates the Terraform CLI output to show logical separation between OPA and Sentinel policy evaluations
* `terraform init` now accepts a `-json` option. If specified, enables the machine readable JSON output. ([#34886](https://github.com/hashicorp/terraform/pull/34886))
* `terraform test:` The test framework will now maintain sensitive metadata between run blocks. ([#35021](https://github.com/hashicorp/terraform/pull/35021))
BUG FIXES:
* `remote-exec`: Each remote connection will be closed immediately after use ([#34137](https://github.com/hashicorp/terraform/issues/34137))
* `backend/s3`: Fixed the digest value displayed for DynamoDB/S3 state checksum mismatches ([#34387](https://github.com/hashicorp/terraform/issues/34387))
## 1.10.0 (Unreleased)
EXPERIMENTS:
Experiments are only enabled in alpha releases of Terraform CLI. The following features are not yet available in stable releases.
* `variable_validation_crossref`: This [language experiment](https://developer.hashicorp.com/terraform/language/settings#experimental-language-features) allows `validation` blocks inside input variable declarations to refer to other objects inside the module where the variable is declared, including to the values of other input variables in the same module.
* `ephemeral_values`: This [language experiment](https://developer.hashicorp.com/terraform/language/settings#experimental-language-features) introduces a new special kind of value which Terraform allows to change between the plan phase and the apply phase, and between plan/apply rounds. Ephemeral values are never persisted in saved plan files or state snapshots, and so can only be used in parts of the language that don't require values to persist in those artifacts. Ephemeral input values are the main initial example of this concept, allowing the use of input variables to provide dynamic credentials that must change between plan and apply.
* `terraform test` accepts a new option `-junit-xml=FILENAME`. If specified, and if the test configuration is valid enough to begin executing, then Terraform writes a JUnit XML test result report to the given filename, describing similar information as included in the normal test output. ([#34291](https://github.com/hashicorp/terraform/issues/34291))
* The new command `terraform rpcapi` exposes some Terraform Core functionality through an RPC interface compatible with [`go-plugin`](https://github.com/hashicorp/go-plugin). The exact RPC API exposed here is currently subject to change at any time, because it's here primarily as a vehicle to support the [Terraform Stacks](https://www.hashicorp.com/blog/terraform-stacks-explained) private preview and so will be broken if necessary to respond to feedback from private preview participants, or possibly for other reasons. Do not use this mechanism yet outside of Terraform Stacks private preview.
* The experimental "deferred actions" feature, enabled by passing the `-allow-deferral` option to `terraform plan`, permits `count` and `for_each` arguments in `module`, `resource`, and `data` blocks to have unknown values and allows providers to react more flexibly to unknown values. This experiment is under active development, and so it's not yet useful to participate in this experiment.
* The experimental "deferred actions" feature, enabled by passing the `-allow-deferral` option to `terraform plan`, permits `count` and `for_each` arguments in `module`, `resource`, and `data` blocks to have unknown values and allows providers to react more flexibly to unknown values. This experiment is under active development, and so it's not yet useful to participate in this experiment
## Previous Releases
For information on prior major and minor releases, see their changelogs:
For information on prior major and minor releases, refer to their changelogs:
* [v1.9](https://github.com/hashicorp/terraform/blob/v1.9/CHANGELOG.md)
* [v1.8](https://github.com/hashicorp/terraform/blob/v1.8/CHANGELOG.md)
* [v1.7](https://github.com/hashicorp/terraform/blob/v1.7/CHANGELOG.md)
* [v1.6](https://github.com/hashicorp/terraform/blob/v1.6/CHANGELOG.md)

View file

@ -280,7 +280,7 @@ a plan operation would include the following high-level steps:
this operation.
Each execution step for a vertex is an implementation of
[`terraform.Execute`](https://pkg.go.dev/github.com/hashicorp/terraform/internal/erraform#Execute).
[`terraform.Execute`](https://pkg.go.dev/github.com/hashicorp/terraform/internal/terraform#Execute).
As with graph transforms, the behavior of these implementations varies widely:
whereas graph transforms can take any action against the graph, an `Execute`
implementation can take any action against the `EvalContext`.

4
go.mod
View file

@ -9,7 +9,7 @@ require (
github.com/apparentlymart/go-cidr v1.1.0
github.com/apparentlymart/go-shquot v0.0.1
github.com/apparentlymart/go-userdirs v0.0.0-20200915174352-b0c018a67c13
github.com/apparentlymart/go-versions v1.0.1
github.com/apparentlymart/go-versions v1.0.2
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2
github.com/bgentry/speakeasy v0.1.0
github.com/bmatcuk/doublestar v1.1.5
@ -72,7 +72,7 @@ require (
go.opentelemetry.io/otel/trace v1.24.0
golang.org/x/crypto v0.21.0
golang.org/x/mod v0.16.0
golang.org/x/net v0.22.0
golang.org/x/net v0.23.0
golang.org/x/oauth2 v0.18.0
golang.org/x/sys v0.19.0
golang.org/x/term v0.18.0

8
go.sum
View file

@ -287,8 +287,8 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/apparentlymart/go-userdirs v0.0.0-20200915174352-b0c018a67c13 h1:JtuelWqyixKApmXm3qghhZ7O96P6NKpyrlSIe8Rwnhw=
github.com/apparentlymart/go-userdirs v0.0.0-20200915174352-b0c018a67c13/go.mod h1:7kfpUbyCdGJ9fDRCp3fopPQi5+cKNHgTE4ZuNrO71Cw=
github.com/apparentlymart/go-versions v1.0.1 h1:ECIpSn0adcYNsBfSRwdDdz9fWlL+S/6EUd9+irwkBgU=
github.com/apparentlymart/go-versions v1.0.1/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM=
github.com/apparentlymart/go-versions v1.0.2 h1:n5Gg9YvSLK8Zzpy743J7abh2jt7z7ammOQ0oTd/5oA4=
github.com/apparentlymart/go-versions v1.0.2/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 h1:7Ip0wMmLHLRJdrloDxZfhMm0xrLXZS8+COSu2bXmEQs=
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
@ -1208,8 +1208,8 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View file

@ -38,9 +38,11 @@ type Base struct {
// primitive-typed toplevel attribute in Schema, and PrepareConfig will
// arrange for the default values to be inserted before it returns.
//
// In particular, note that any attribute with an entry in this definition
// is guaranteed to never be null, since PrepareConfig will replace any
// nulls with an SDK-like "zero value".
// As a special case, if the value in the configuration is unset (null),
// none of the environment variables are non-empty, and the fallback
// value is empty, then the attribute value will be left as null in the
// object returned by PrepareConfig. In all other situations an attribute
// specified here is definitely not null.
SDKLikeDefaults SDKLikeDefaults
}

View file

@ -227,6 +227,18 @@ func (d SDKLikeDefaults) ApplyTo(base cty.Value) (cty.Value, error) {
return cty.NilVal, fmt.Errorf("argument %q is required", attrName)
}
// As a special case, if we still have an empty string and the original
// value was null then we'll preserve the null. This is a compromise,
// assuming that SDKLikeData knows how to treat a null value as a
// zero value anyway and if we preserve the null then the recipient
// of this result can still use the cty.Value result directly to
// distinguish between the value being set explicitly to empty in
// the config vs. being entirely unset.
if rawStr == "" && givenVal.IsNull() {
retAttrs[attrName] = givenVal
continue
}
// By the time we get here, rawStr should be empty only if the original
// value was unset and all of the fallback environment variables were
// also unset. Otherwise, rawStr contains a string representation of

View file

@ -269,7 +269,7 @@ func TestSDKLikeApplyEnvDefaults(t *testing.T) {
"string_env_empty": cty.StringVal("beep from environment"),
"string_env_unsetfirst": cty.StringVal("beep from environment"),
"string_env_unsetsecond": cty.StringVal("beep from environment"),
"string_nothing_null": cty.StringVal(""),
"string_nothing_null": cty.NullVal(cty.String),
"string_nothing_empty": cty.StringVal(""),
"passthru": cty.EmptyObjectVal,
})

View file

@ -108,11 +108,12 @@ type Operation struct {
// The options below are more self-explanatory and affect the runtime
// behavior of the operation.
PlanMode plans.Mode
AutoApprove bool
Targets []addrs.Targetable
ForceReplace []addrs.AbsResourceInstance
Variables map[string]UnparsedVariableValue
PlanMode plans.Mode
AutoApprove bool
Targets []addrs.Targetable
ForceReplace []addrs.AbsResourceInstance
Variables map[string]UnparsedVariableValue
StatePersistInterval int
// Some operations use root module variables only opportunistically or
// don't need them at all. If this flag is set, the backend must treat

View file

@ -10,9 +10,15 @@ import (
"log"
"time"
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/backend/backendrun"
"github.com/hashicorp/terraform/internal/collections"
"github.com/hashicorp/terraform/internal/command/views"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/experiments"
"github.com/hashicorp/terraform/internal/logging"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/states"
@ -82,11 +88,13 @@ func (b *Local) opApply(
// stateHook uses schemas for when it periodically persists state to the
// persistent storage backend.
stateHook.Schemas = schemas
stateHook.PersistInterval = 20 * time.Second // arbitrary interval that's hopefully a sweet spot
stateHook.PersistInterval = time.Duration(op.StatePersistInterval) * time.Second
var plan *plans.Plan
combinedPlanApply := false
// If we weren't given a plan, then we refresh/plan
if op.PlanFile == nil {
combinedPlanApply = true
// Perform the plan
log.Printf("[INFO] backend/local: apply calling Plan")
plan, moreDiags = lr.Core.Plan(lr.Config, lr.InputState, lr.PlanOpts)
@ -227,6 +235,59 @@ func (b *Local) opApply(
// Set up our hook for continuous state updates
stateHook.StateMgr = opState
var applyOpts *terraform.ApplyOpts
if lr.Config.Module.ActiveExperiments.Has(experiments.EphemeralValues) {
// We only try to handle apply-time input variables if the root module
// has opted into the ephemeral_values language experiment, because
// otherwise there can't possibly be any input variables required
// in the apply phase and this reduces the risk of the experimental
// code impacting non-experimental usage.
// If we stablize something like this experiment, we should find a
// less clunky way to introduce this extra step.
applyTimeValues, applyVarDiags := applyTimeInputValues(
plan.ApplyTimeVariables,
lr.Config.Module.Variables,
op.Variables,
combinedPlanApply,
)
diags = diags.Append(applyVarDiags)
if diags.HasErrors() {
op.ReportResult(runningOp, diags)
return
}
applyOpts = &terraform.ApplyOpts{
SetVariables: applyTimeValues,
}
} else {
// When the ephemeral values experiment isn't enabled, no variables
// may be _explicitly_ set during the apply phase at all, but it's
// valid for variable to show up from more implicit locations like
// environment variables and .auto.tfvars files.
if len(op.Variables) != 0 && !combinedPlanApply {
for _, rawV := range op.Variables {
// We're "parsing" only to get the resulting value's SourceType,
// so we'll use configs.VariableParseLiteral just because it's
// the most liberal interpretation and so least likely to
// fail with an unrelated error.
v, _ := rawV.ParseVariableValue(configs.VariableParseLiteral)
if v == nil {
// We'll ignore any that don't parse at all, because
// they'll fail elsewhere in this process anyway.
continue
}
if v.SourceType == terraform.ValueFromCLIArg || v.SourceType == terraform.ValueFromNamedFile {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Can't set variables when applying a saved plan",
"The -var and -var-file options cannot be used when applying a saved plan file, because a saved plan includes the variable values that were set when it was created.",
))
op.ReportResult(runningOp, diags)
return
}
}
}
}
// Start the apply in a goroutine so that we can be interrupted.
var applyState *states.State
var applyDiags tfdiags.Diagnostics
@ -234,8 +295,9 @@ func (b *Local) opApply(
go func() {
defer logging.PanicHandler()
defer close(doneCh)
log.Printf("[INFO] backend/local: apply calling Apply")
applyState, applyDiags = lr.Core.Apply(plan, lr.Config, nil)
applyState, applyDiags = lr.Core.Apply(plan, lr.Config, applyOpts)
}()
if b.opWait(doneCh, stopCtx, cancelCtx, lr.Core, opState, op.View) {
@ -332,6 +394,115 @@ func (b *Local) backupStateForError(stateFile *statefile.File, err error, view v
return diags
}
func applyTimeInputValues(needVars collections.Set[string], decls map[string]*configs.Variable, given map[string]backendrun.UnparsedVariableValue, ignoreExtras bool) (terraform.InputValues, tfdiags.Diagnostics) {
// TEMP: This function is here to deal with the currently-experimental
// possibility of certain input variables being required during an apply
// phase because they were set during planning but declared as being
// ephemeral.
//
// To reduce the disruption to existing code caused by this language
// experiment the following is implemented by lightly misusing some
// existing functions that were designed for interpreting variable values
// during the planning phase. If we move forward with something like this
// design for ephemeral input variables then we should consider revisiting
// this to see if we can share the relevant parts of this logic in a less
// clunky way.
// As a way to trick the functions we built for plan-time variable
// processing into dealing with apply-time variables, we'll construct
// a copy of the variable configurations map with only the needed
// variables in it.
filteredDecls := make(map[string]*configs.Variable, len(decls))
for name, config := range decls {
if needVars.Has(name) {
filteredDecls[name] = config
}
}
ret, diags := backendrun.ParseDeclaredVariableValues(given, filteredDecls)
undeclared, _ := backendrun.ParseUndeclaredVariableValues(given, filteredDecls)
// The diagnostics returned by ParseUndeclaredVariableValues are written
// to make sense for the plan phase, so we'll ignore them and produce
// our own diagnostics here.
for name, defn := range undeclared {
// Something can get in here either by being not declared at all,
// by being a non-ephemeral variable which should therefore have been
// set during the planning phase, or by being an ephemeral value that
// wasn't set during planning and must therefore stay unset during
// apply. We'll distinguish those cases below.
decl, declared := decls[name]
if !declared {
// FIXME: Ideally we should treat this situation similarly to how
// we would during planning, raising an error if defined in an
// "explicit-ish" way but a warning if set in an ambient way such
// as an environment variable. But for now we'll just ignore
// undeclared input variables in all cases for simplicity's sake.
continue
}
var rng *hcl.Range
if defn.HasSourceRange() {
rng = defn.SourceRange.ToHCL().Ptr()
}
if decl.Ephemeral {
// An ephemeral variable that appears as "undeclared" is one that
// wasn't set during planning and must therefore remain unset
// during apply.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Ephemeral variable was not set during planning",
Detail: fmt.Sprintf(
"The ephemeral input variable %q was not set during the planning phase, and so must remain unset during the apply phase.",
name,
),
Subject: rng,
})
} else {
// TODO: We should probably actually tolerate this if the new
// value is equal to the value that was saved in the plan, since
// that'd make it possible to, for example, reuse a .tfvars file
// containing a mixture of ephemeral and non-ephemeral definitions
// during the apply phase, rather than having to split ephemeral
// and non-ephemeral definitions into separate files. For initial
// experiment we'll keep things a little simpler, though, and
// just skip this check if we're doing a combined plan/apply where
// the apply phase will therefore always have exactly the same
// inputs as the plan phase.
if !ignoreExtras {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Cannot change value for non-ephemeral variable",
Detail: fmt.Sprintf(
"Input variable %q is non-ephemeral, so its value was decided during the planning phase and cannot be reset for the apply phase.",
name,
),
Subject: rng,
})
}
}
}
// We should now have a non-null value for each of the variables in needVars
for _, name := range needVars.Elems() {
val := cty.NullVal(cty.DynamicPseudoType)
if defn, ok := ret[name]; ok {
val = defn.Value
}
if val.IsNull() {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Ephemeral variable must be set for apply",
Detail: fmt.Sprintf(
"The ephemeral input variable %q was set during the planning phase, and so must be set again during the apply phase.",
name,
),
})
}
}
return ret, diags
}
const stateWriteBackedUpError = `The error shown above has prevented Terraform from writing the updated state to the configured backend. To allow for recovery, the state has been written to the file "errored.tfstate" in the current working directory.
Running "terraform apply" again at this point will create a forked state, making it harder to recover.

View file

@ -239,6 +239,7 @@ func (b *Local) localRunForPlanFile(op *backendrun.Operation, pf *planfile.Reade
return nil, snap, diags
}
loader := configload.NewLoaderFromSnapshot(snap)
loader.AllowLanguageExperiments(op.ConfigLoader.AllowsLanguageExperiments())
config, configDiags := loader.LoadConfig(snap.Modules[""].Dir)
diags = diags.Append(configDiags)
if configDiags.HasErrors() {

View file

@ -4,6 +4,7 @@
package local
import (
"context"
"fmt"
"os"
"path/filepath"
@ -261,7 +262,7 @@ func (s *stateStorageThatFailsRefresh) State() *states.State {
return nil
}
func (s *stateStorageThatFailsRefresh) GetRootOutputValues() (map[string]*states.OutputValue, error) {
func (s *stateStorageThatFailsRefresh) GetRootOutputValues(ctx context.Context) (map[string]*states.OutputValue, error) {
return nil, fmt.Errorf("unimplemented")
}

View file

@ -431,7 +431,7 @@ func (runner *TestFileRunner) run(run *moduletest.Run, file *moduletest.File, st
return state, false
}
variables, variableDiags := runner.GetVariables(config, run, references)
variables, variableDiags := runner.GetVariables(config, run, references, true)
run.Diagnostics = run.Diagnostics.Append(variableDiags)
if variableDiags.HasErrors() {
run.Status = moduletest.Error
@ -463,7 +463,7 @@ func (runner *TestFileRunner) run(run *moduletest.Run, file *moduletest.File, st
defer resetVariables()
if runner.Suite.Verbose {
schemas, diags := tfCtx.Schemas(config, plan.PlannedState)
schemas, diags := tfCtx.Schemas(config, plan.PriorState)
// If we're going to fail to render the plan, let's not fail the overall
// test. It can still have succeeded. So we'll add the diagnostics, but
@ -477,7 +477,7 @@ func (runner *TestFileRunner) run(run *moduletest.Run, file *moduletest.File, st
} else {
run.Verbose = &moduletest.Verbose{
Plan: plan,
State: plan.PlannedState,
State: nil, // We don't have a state to show in plan mode.
Config: config,
Providers: schemas.Providers,
Provisioners: schemas.Provisioners,
@ -544,14 +544,8 @@ func (runner *TestFileRunner) run(run *moduletest.Run, file *moduletest.File, st
resetVariables := runner.AddVariablesToConfig(config, variables)
defer resetVariables()
run.Diagnostics = run.Diagnostics.Append(variableDiags)
if variableDiags.HasErrors() {
run.Status = moduletest.Error
return updated, true
}
if runner.Suite.Verbose {
schemas, diags := tfCtx.Schemas(config, plan.PlannedState)
schemas, diags := tfCtx.Schemas(config, updated)
// If we're going to fail to render the plan, let's not fail the overall
// test. It can still have succeeded. So we'll add the diagnostics, but
@ -564,7 +558,7 @@ func (runner *TestFileRunner) run(run *moduletest.Run, file *moduletest.File, st
fmt.Sprintf("Terraform failed to print the verbose output for %s, other diagnostics will contain more details as to why.", filepath.Join(file.Name, run.Name))))
} else {
run.Verbose = &moduletest.Verbose{
Plan: plan,
Plan: nil, // We don't have a plan to show in apply mode.
State: updated,
Config: config,
Providers: schemas.Providers,
@ -637,7 +631,7 @@ func (runner *TestFileRunner) destroy(config *configs.Config, state *states.Stat
var diags tfdiags.Diagnostics
variables, variableDiags := runner.GetVariables(config, run, nil)
variables, variableDiags := runner.GetVariables(config, run, nil, false)
diags = diags.Append(variableDiags)
if diags.HasErrors() {
@ -1004,7 +998,7 @@ func (runner *TestFileRunner) cleanup(file *moduletest.File) {
// more variables than are required by the config. FilterVariablesToConfig
// should be called before trying to use these variables within a Terraform
// plan, apply, or destroy operation.
func (runner *TestFileRunner) GetVariables(config *configs.Config, run *moduletest.Run, references []*addrs.Reference) (terraform.InputValues, tfdiags.Diagnostics) {
func (runner *TestFileRunner) GetVariables(config *configs.Config, run *moduletest.Run, references []*addrs.Reference, includeWarnings bool) (terraform.InputValues, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
// relevantVariables contains the variables that are of interest to this
@ -1071,13 +1065,15 @@ func (runner *TestFileRunner) GetVariables(config *configs.Config, run *modulete
// wrote in the variable expression. But, we don't want to actually use
// it if it's not actually relevant.
if _, exists := relevantVariables[name]; !exists {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Value for undeclared variable",
Detail: fmt.Sprintf("The module under test does not declare a variable named %q, but it is declared in run block %q.", name, run.Name),
Subject: expr.Range().Ptr(),
})
// Do not display warnings during cleanup phase
if includeWarnings {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Value for undeclared variable",
Detail: fmt.Sprintf("The module under test does not declare a variable named %q, but it is declared in run block %q.", name, run.Name),
Subject: expr.Range().Ptr(),
})
}
continue // Don't add it to our final set of variables.
}

View file

@ -24,7 +24,7 @@ require (
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
github.com/agext/levenshtein v1.2.3 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/apparentlymart/go-versions v1.0.1 // indirect
github.com/apparentlymart/go-versions v1.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dimchansky/utfbom v1.1.1 // indirect
github.com/fatih/color v1.16.0 // indirect
@ -55,7 +55,7 @@ require (
github.com/zclconf/go-cty v1.14.4 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/oauth2 v0.18.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect

View file

@ -91,8 +91,8 @@ github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3 h1:ZSTrOEhi
github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/apparentlymart/go-versions v1.0.1 h1:ECIpSn0adcYNsBfSRwdDdz9fWlL+S/6EUd9+irwkBgU=
github.com/apparentlymart/go-versions v1.0.1/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM=
github.com/apparentlymart/go-versions v1.0.2 h1:n5Gg9YvSLK8Zzpy743J7abh2jt7z7ammOQ0oTd/5oA4=
github.com/apparentlymart/go-versions v1.0.2/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM=
github.com/aws/aws-sdk-go v1.44.122 h1:p6mw01WBaNpbdP2xrisz5tIkcNwzj/HysobNoaAHjgo=
github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
@ -409,8 +409,8 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View file

@ -12,7 +12,7 @@ require (
require (
github.com/agext/levenshtein v1.2.3 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/apparentlymart/go-versions v1.0.1 // indirect
github.com/apparentlymart/go-versions v1.0.2 // indirect
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fatih/color v1.16.0 // indirect
@ -46,7 +46,7 @@ require (
github.com/spf13/afero v1.9.3 // indirect
github.com/stretchr/testify v1.8.4 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.19.0 // indirect

View file

@ -54,8 +54,8 @@ github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3 h1:ZSTrOEhi
github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/apparentlymart/go-versions v1.0.1 h1:ECIpSn0adcYNsBfSRwdDdz9fWlL+S/6EUd9+irwkBgU=
github.com/apparentlymart/go-versions v1.0.1/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM=
github.com/apparentlymart/go-versions v1.0.2 h1:n5Gg9YvSLK8Zzpy743J7abh2jt7z7ammOQ0oTd/5oA4=
github.com/apparentlymart/go-versions v1.0.2/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da h1:8GUt8eRujhVEGZFFEjBj46YV4rDjvGrNxb0KMWYkL2I=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
@ -407,8 +407,8 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View file

@ -14,7 +14,7 @@ require (
require (
github.com/agext/levenshtein v1.2.3 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/apparentlymart/go-versions v1.0.1 // indirect
github.com/apparentlymart/go-versions v1.0.2 // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
@ -36,7 +36,7 @@ require (
github.com/spf13/afero v1.9.3 // indirect
github.com/zclconf/go-cty v1.14.4 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.19.0 // indirect

View file

@ -55,8 +55,8 @@ github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3 h1:ZSTrOEhi
github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/apparentlymart/go-versions v1.0.1 h1:ECIpSn0adcYNsBfSRwdDdz9fWlL+S/6EUd9+irwkBgU=
github.com/apparentlymart/go-versions v1.0.1/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM=
github.com/apparentlymart/go-versions v1.0.2 h1:n5Gg9YvSLK8Zzpy743J7abh2jt7z7ammOQ0oTd/5oA4=
github.com/apparentlymart/go-versions v1.0.2/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM=
github.com/aws/aws-sdk-go v1.44.122 h1:p6mw01WBaNpbdP2xrisz5tIkcNwzj/HysobNoaAHjgo=
github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
@ -345,8 +345,8 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View file

@ -148,6 +148,19 @@ func (b *Backend) Configure(configVal cty.Value) tfdiags.Diagnostics {
fmt.Errorf("can't set both encryption_key and kms_encryption_key"),
)
}
// The above catches the main case where both of the arguments are set to
// a non-empty value, but we also want to reject the situation where
// both are present in the configuration regardless of what values were
// assigned to them. (This check doesn't take the environment variables
// into account, so must allow neither to be set in the main configuration.)
if !(configVal.GetAttr("encryption_key").IsNull() || configVal.GetAttr("kms_encryption_key").IsNull()) {
// This rejects a configuration like:
// encryption_key = ""
// kms_encryption_key = ""
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("can't set both encryption_key and kms_encryption_key"),
)
}
b.bucketName = data.String("bucket")
b.prefix = strings.TrimLeft(data.String("prefix"), "/")

View file

@ -18,6 +18,7 @@ import (
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/httpclient"
"github.com/hashicorp/terraform/internal/states/remote"
"github.com/zclconf/go-cty/cty"
"google.golang.org/api/option"
kmspb "google.golang.org/genproto/googleapis/cloud/kms/v1"
)
@ -211,6 +212,56 @@ func TestBackendWithCustomerManagedKMSEncryption(t *testing.T) {
backend.TestBackendStateLocks(t, be0, be1)
}
func TestBackendEncryptionKeyEmptyConflict(t *testing.T) {
// This test is for the edge case where encryption_key and
// kms_encryption_key are both set in the configuration but set to empty
// strings. The "SDK-like" helpers treat unset as empty string, so
// we need an extra rule to catch them both being set to empty string
// directly inside the configuration, and this test covers that
// special case.
//
// The following assumes that the validation check we're testing will, if
// failing, always block attempts to reach any real GCP services, and so
// this test should be fine to run without an acceptance testing opt-in.
// This test is for situations where these environment variables are not set.
t.Setenv("GOOGLE_ENCRYPTION_KEY", "")
t.Setenv("GOOGLE_KMS_ENCRYPTION_KEY", "")
backend := New()
schema := backend.ConfigSchema()
rawVal := cty.ObjectVal(map[string]cty.Value{
"bucket": cty.StringVal("fake-placeholder"),
// These are both empty strings but should still be considered as
// set when we enforce teh rule that they can't both be set at once.
"encryption_key": cty.StringVal(""),
"kms_encryption_key": cty.StringVal(""),
})
// The following mimicks how the terraform_remote_state data source
// treats its "config" argument, which is a realistic situation where
// we take an arbitrary object and try to force it to conform to the
// backend's schema.
configVal, err := schema.CoerceValue(rawVal)
if err != nil {
t.Fatalf("unexpected coersion error: %s", err)
}
configVal, diags := backend.PrepareConfig(configVal)
if diags.HasErrors() {
t.Fatalf("unexpected PrepareConfig error: %s", diags.Err().Error())
}
configDiags := backend.Configure(configVal)
if !configDiags.HasErrors() {
t.Fatalf("unexpected success; want error")
}
gotErr := configDiags.Err().Error()
wantErr := `can't set both encryption_key and kms_encryption_key`
if !strings.Contains(gotErr, wantErr) {
t.Errorf("wrong error\ngot: %s\nwant substring: %s", gotErr, wantErr)
}
}
// setupBackend returns a new GCS backend.
func setupBackend(t *testing.T, bucket, prefix, key, kmsName string) backend.Backend {
t.Helper()

View file

@ -20,7 +20,7 @@ require (
cloud.google.com/go/iam v1.1.1 // indirect
github.com/agext/levenshtein v1.2.3 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/apparentlymart/go-versions v1.0.1 // indirect
github.com/apparentlymart/go-versions v1.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
@ -44,7 +44,7 @@ require (
go.opencensus.io v0.24.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect

View file

@ -57,8 +57,8 @@ github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3 h1:ZSTrOEhi
github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/apparentlymart/go-versions v1.0.1 h1:ECIpSn0adcYNsBfSRwdDdz9fWlL+S/6EUd9+irwkBgU=
github.com/apparentlymart/go-versions v1.0.1/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM=
github.com/apparentlymart/go-versions v1.0.2 h1:n5Gg9YvSLK8Zzpy743J7abh2jt7z7ammOQ0oTd/5oA4=
github.com/apparentlymart/go-versions v1.0.2/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM=
github.com/aws/aws-sdk-go v1.44.122 h1:p6mw01WBaNpbdP2xrisz5tIkcNwzj/HysobNoaAHjgo=
github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
@ -349,8 +349,8 @@ golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View file

@ -25,7 +25,7 @@ require (
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/agext/levenshtein v1.2.3 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/apparentlymart/go-versions v1.0.1 // indirect
github.com/apparentlymart/go-versions v1.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emicklei/go-restful/v3 v3.8.0 // indirect
github.com/go-logr/logr v1.4.1 // indirect
@ -64,7 +64,7 @@ require (
github.com/zclconf/go-cty v1.14.4 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/oauth2 v0.18.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/term v0.18.0 // indirect

View file

@ -74,8 +74,8 @@ github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3 h1:ZSTrOEhi
github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/apparentlymart/go-versions v1.0.1 h1:ECIpSn0adcYNsBfSRwdDdz9fWlL+S/6EUd9+irwkBgU=
github.com/apparentlymart/go-versions v1.0.1/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM=
github.com/apparentlymart/go-versions v1.0.2 h1:n5Gg9YvSLK8Zzpy743J7abh2jt7z7ammOQ0oTd/5oA4=
github.com/apparentlymart/go-versions v1.0.2/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM=
github.com/aws/aws-sdk-go v1.44.122 h1:p6mw01WBaNpbdP2xrisz5tIkcNwzj/HysobNoaAHjgo=
github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
@ -410,8 +410,8 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View file

@ -17,7 +17,7 @@ require (
require (
github.com/agext/levenshtein v1.2.3 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/apparentlymart/go-versions v1.0.1 // indirect
github.com/apparentlymart/go-versions v1.0.2 // indirect
github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang/protobuf v1.5.3 // indirect
@ -42,7 +42,7 @@ require (
github.com/stretchr/testify v1.8.4 // indirect
github.com/zclconf/go-cty v1.14.4 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.19.0 // indirect

View file

@ -60,8 +60,8 @@ github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3 h1:ZSTrOEhi
github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/apparentlymart/go-versions v1.0.1 h1:ECIpSn0adcYNsBfSRwdDdz9fWlL+S/6EUd9+irwkBgU=
github.com/apparentlymart/go-versions v1.0.1/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM=
github.com/apparentlymart/go-versions v1.0.2 h1:n5Gg9YvSLK8Zzpy743J7abh2jt7z7ammOQ0oTd/5oA4=
github.com/apparentlymart/go-versions v1.0.2/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM=
github.com/aws/aws-sdk-go v1.44.122 h1:p6mw01WBaNpbdP2xrisz5tIkcNwzj/HysobNoaAHjgo=
github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f h1:ZNv7On9kyUzm7fvRZumSyy/IUiSC7AzL0I1jKKtwooA=
@ -354,8 +354,8 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View file

@ -13,7 +13,7 @@ require (
require (
github.com/agext/levenshtein v1.2.3 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/apparentlymart/go-versions v1.0.1 // indirect
github.com/apparentlymart/go-versions v1.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/hashicorp/go-slug v0.15.0 // indirect
@ -30,7 +30,7 @@ require (
github.com/spf13/afero v1.9.3 // indirect
github.com/zclconf/go-cty v1.14.4 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.19.0 // indirect

View file

@ -54,8 +54,8 @@ github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3 h1:ZSTrOEhi
github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/apparentlymart/go-versions v1.0.1 h1:ECIpSn0adcYNsBfSRwdDdz9fWlL+S/6EUd9+irwkBgU=
github.com/apparentlymart/go-versions v1.0.1/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM=
github.com/apparentlymart/go-versions v1.0.2 h1:n5Gg9YvSLK8Zzpy743J7abh2jt7z7ammOQ0oTd/5oA4=
github.com/apparentlymart/go-versions v1.0.2/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM=
github.com/aws/aws-sdk-go v1.44.122 h1:p6mw01WBaNpbdP2xrisz5tIkcNwzj/HysobNoaAHjgo=
github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
@ -325,8 +325,8 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View file

@ -21,7 +21,7 @@ require (
require (
github.com/agext/levenshtein v1.2.3 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/apparentlymart/go-versions v1.0.1 // indirect
github.com/apparentlymart/go-versions v1.0.2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.1 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.7 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.7 // indirect
@ -63,7 +63,7 @@ require (
go.opentelemetry.io/otel/trace v1.24.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.19.0 // indirect

View file

@ -54,8 +54,8 @@ github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3 h1:ZSTrOEhi
github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/apparentlymart/go-versions v1.0.1 h1:ECIpSn0adcYNsBfSRwdDdz9fWlL+S/6EUd9+irwkBgU=
github.com/apparentlymart/go-versions v1.0.1/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM=
github.com/apparentlymart/go-versions v1.0.2 h1:n5Gg9YvSLK8Zzpy743J7abh2jt7z7ammOQ0oTd/5oA4=
github.com/apparentlymart/go-versions v1.0.2/go.mod h1:YF5j7IQtrOAOnsGkniupEA5bfCjzd7i14yu0shZavyM=
github.com/aws/aws-sdk-go v1.44.122 h1:p6mw01WBaNpbdP2xrisz5tIkcNwzj/HysobNoaAHjgo=
github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go-v2 v1.25.3 h1:xYiLpZTQs1mzvz5PaI6uR0Wh57ippuEthxS4iK5v0n0=
@ -397,8 +397,8 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View file

@ -23,6 +23,9 @@ func NewProvider() providers.Interface {
// GetSchema returns the complete schema for the provider.
func (p *Provider) GetProviderSchema() providers.GetProviderSchemaResponse {
return providers.GetProviderSchemaResponse{
ServerCapabilities: providers.ServerCapabilities{
MoveResourceState: true,
},
DataSources: map[string]providers.Schema{
"terraform_remote_state": dataSourceRemoteStateGetSchema(),
},
@ -169,10 +172,18 @@ func (p *Provider) ImportResourceState(req providers.ImportResourceStateRequest)
panic("unimplemented - terraform_remote_state has no resources")
}
func (p *Provider) MoveResourceState(providers.MoveResourceStateRequest) providers.MoveResourceStateResponse {
// We don't expose the move_resource_state capability, so this should never
// be called.
panic("unimplemented - terraform.io/builtin/terraform does not support cross-resource moves")
// MoveResourceState requests that the given resource be moved.
func (p *Provider) MoveResourceState(req providers.MoveResourceStateRequest) providers.MoveResourceStateResponse {
switch req.TargetTypeName {
case "terraform_data":
return moveDataStoreResourceState(req)
default:
var resp providers.MoveResourceStateResponse
resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("Error: unsupported resource %s", req.TargetTypeName))
return resp
}
}
// ValidateResourceConfig is used to to validate the resource configuration values.

View file

@ -4,10 +4,66 @@
package terraform
import (
"testing"
backendInit "github.com/hashicorp/terraform/internal/backend/init"
"github.com/hashicorp/terraform/internal/providers"
"github.com/zclconf/go-cty/cty"
ctyjson "github.com/zclconf/go-cty/cty/json"
)
func init() {
// Initialize the backends
backendInit.Init(nil)
}
func TestMoveResourceState_DataStore(t *testing.T) {
t.Parallel()
nullResourceStateValue := cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("test"),
})
nullResourceStateJSON, err := ctyjson.Marshal(nullResourceStateValue, nullResourceStateValue.Type())
if err != nil {
t.Fatalf("failed to marshal null resource state: %s", err)
}
provider := &Provider{}
req := providers.MoveResourceStateRequest{
SourceProviderAddress: "registry.terraform.io/hashicorp/null",
SourceStateJSON: nullResourceStateJSON,
SourceTypeName: "null_resource",
TargetTypeName: "terraform_data",
}
resp := provider.MoveResourceState(req)
if resp.Diagnostics.HasErrors() {
t.Errorf("unexpected diagnostics: %s", resp.Diagnostics.Err())
}
expectedTargetState := cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("test"),
"input": cty.NullVal(cty.DynamicPseudoType),
"output": cty.NullVal(cty.DynamicPseudoType),
"triggers_replace": cty.NullVal(cty.DynamicPseudoType),
})
if !resp.TargetState.RawEquals(expectedTargetState) {
t.Errorf("expected state was:\n%#v\ngot state is:\n%#v\n", expectedTargetState, resp.TargetState)
}
}
func TestMoveResourceState_NonExistentResource(t *testing.T) {
t.Parallel()
provider := &Provider{}
req := providers.MoveResourceStateRequest{
TargetTypeName: "nonexistent_resource",
}
resp := provider.MoveResourceState(req)
if !resp.Diagnostics.HasErrors() {
t.Fatal("expected diagnostics")
}
}

View file

@ -5,6 +5,7 @@ package terraform
import (
"fmt"
"strings"
"github.com/hashicorp/go-uuid"
"github.com/hashicorp/terraform/internal/configs/configschema"
@ -170,3 +171,84 @@ func importDataStore(req providers.ImportResourceStateRequest) (resp providers.I
}
return resp
}
// moveDataStoreResourceState enables moving from the official null_resource
// managed resource to the terraform_data managed resource.
func moveDataStoreResourceState(req providers.MoveResourceStateRequest) (resp providers.MoveResourceStateResponse) {
// Verify that the source provider is an official hashicorp/null provider,
// but ignore the hostname for mirrors.
if !strings.HasSuffix(req.SourceProviderAddress, "hashicorp/null") {
diag := tfdiags.Sourceless(
tfdiags.Error,
"Unsupported source provider for move operation",
"Only moving from the official hashicorp/null provider to terraform_data is supported.",
)
resp.Diagnostics = resp.Diagnostics.Append(diag)
return resp
}
// Verify that the source resource type name is null_resource.
if req.SourceTypeName != "null_resource" {
diag := tfdiags.Sourceless(
tfdiags.Error,
"Unsupported source resource type for move operation",
"Only moving from the null_resource managed resource to terraform_data is supported.",
)
resp.Diagnostics = resp.Diagnostics.Append(diag)
return resp
}
nullResourceSchemaType := nullResourceSchema().Block.ImpliedType()
nullResourceValue, err := ctyjson.Unmarshal(req.SourceStateJSON, nullResourceSchemaType)
if err != nil {
resp.Diagnostics = resp.Diagnostics.Append(err)
return resp
}
triggersReplace := nullResourceValue.GetAttr("triggers")
// PlanResourceChange uses RawEquals comparison, which will show a
// difference between cty.NullVal(cty.Map(cty.String)) and
// cty.NullVal(cty.DynamicPseudoType).
if triggersReplace.IsNull() {
triggersReplace = cty.NullVal(cty.DynamicPseudoType)
} else {
// PlanResourceChange uses RawEquals comparison, which will show a
// difference between cty.MapVal(...) and cty.ObjectVal(...). Given that
// triggers is typically configured using direct configuration syntax of
// {...}, which is a cty.ObjectVal, over a map typed variable or
// explicitly type converted map, this pragmatically chooses to convert
// the triggers value to cty.ObjectVal to prevent an immediate plan
// difference for the more typical case.
triggersReplace = cty.ObjectVal(triggersReplace.AsValueMap())
}
schema := dataStoreResourceSchema()
v := cty.ObjectVal(map[string]cty.Value{
"id": nullResourceValue.GetAttr("id"),
"triggers_replace": triggersReplace,
})
state, err := schema.Block.CoerceValue(v)
// null_resource did not use private state, so it is unnecessary to move.
resp.Diagnostics = resp.Diagnostics.Append(err)
resp.TargetState = state
return resp
}
func nullResourceSchema() providers.Schema {
return providers.Schema{
Block: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Computed: true},
"triggers": {Type: cty.Map(cty.String), Optional: true},
},
},
}
}

View file

@ -383,3 +383,108 @@ func TestManagedDataApply(t *testing.T) {
})
}
}
func TestMoveDataStoreResourceState_Id(t *testing.T) {
t.Parallel()
nullResourceStateValue := cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("test"),
"triggers": cty.NullVal(cty.Map(cty.String)),
})
nullResourceStateJSON, err := ctyjson.Marshal(nullResourceStateValue, nullResourceStateValue.Type())
if err != nil {
t.Fatalf("failed to marshal null resource state: %s", err)
}
req := providers.MoveResourceStateRequest{
SourceProviderAddress: "registry.terraform.io/hashicorp/null",
SourceStateJSON: nullResourceStateJSON,
SourceTypeName: "null_resource",
TargetTypeName: "terraform_data",
}
resp := moveDataStoreResourceState(req)
if resp.Diagnostics.HasErrors() {
t.Errorf("unexpected diagnostics: %s", resp.Diagnostics.Err())
}
expectedTargetState := cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("test"),
"input": cty.NullVal(cty.DynamicPseudoType),
"output": cty.NullVal(cty.DynamicPseudoType),
"triggers_replace": cty.NullVal(cty.DynamicPseudoType),
})
if !resp.TargetState.RawEquals(expectedTargetState) {
t.Errorf("expected state was:\n%#v\ngot state is:\n%#v\n", expectedTargetState, resp.TargetState)
}
}
func TestMoveResourceState_SourceProviderAddress(t *testing.T) {
t.Parallel()
req := providers.MoveResourceStateRequest{
SourceProviderAddress: "registry.terraform.io/examplecorp/null",
}
resp := moveDataStoreResourceState(req)
if !resp.Diagnostics.HasErrors() {
t.Fatal("expected diagnostics")
}
}
func TestMoveResourceState_SourceTypeName(t *testing.T) {
t.Parallel()
req := providers.MoveResourceStateRequest{
SourceProviderAddress: "registry.terraform.io/hashicorp/null",
SourceTypeName: "null_data_source",
}
resp := moveDataStoreResourceState(req)
if !resp.Diagnostics.HasErrors() {
t.Fatal("expected diagnostics")
}
}
func TestMoveDataStoreResourceState_Triggers(t *testing.T) {
t.Parallel()
nullResourceStateValue := cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("test"),
"triggers": cty.MapVal(map[string]cty.Value{
"testkey": cty.StringVal("testvalue"),
}),
})
nullResourceStateJSON, err := ctyjson.Marshal(nullResourceStateValue, nullResourceStateValue.Type())
if err != nil {
t.Fatalf("failed to marshal null resource state: %s", err)
}
req := providers.MoveResourceStateRequest{
SourceProviderAddress: "registry.terraform.io/hashicorp/null",
SourceStateJSON: nullResourceStateJSON,
SourceTypeName: "null_resource",
TargetTypeName: "terraform_data",
}
resp := moveDataStoreResourceState(req)
if resp.Diagnostics.HasErrors() {
t.Errorf("unexpected diagnostics: %s", resp.Diagnostics.Err())
}
expectedTargetState := cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("test"),
"input": cty.NullVal(cty.DynamicPseudoType),
"output": cty.NullVal(cty.DynamicPseudoType),
"triggers_replace": cty.ObjectVal(map[string]cty.Value{
"testkey": cty.StringVal("testvalue"),
}),
})
if !resp.TargetState.RawEquals(expectedTargetState) {
t.Errorf("expected state was:\n%#v\ngot state is:\n%#v\n", expectedTargetState, resp.TargetState)
}
}

101
internal/cloud/retry.go Normal file
View file

@ -0,0 +1,101 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cloud
import (
"context"
"log"
"sync/atomic"
"time"
)
// Fatal implements a RetryBackoff func return value that, if encountered,
// signals that the func should not be retried. In that case, the error
// returned by the interface method will be returned by RetryBackoff
type Fatal interface {
FatalError() error
}
// NonRetryableError is a simple implementation of Fatal that wraps an error
type NonRetryableError struct {
InnerError error
}
// FatalError returns the inner error, but also implements Fatal, which
// signals to RetryBackoff that a non-retryable error occurred.
func (e NonRetryableError) FatalError() error {
return e.InnerError
}
// Error returns the inner error string
func (e NonRetryableError) Error() string {
return e.InnerError.Error()
}
var (
initialBackoffDelay = time.Second
maxBackoffDelay = 3 * time.Second
)
// RetryBackoff retries function f until nil or a FatalError is returned.
// RetryBackoff only returns an error if the context is in error or if a
// FatalError was encountered.
func RetryBackoff(ctx context.Context, f func() error) error {
// doneCh signals that the routine is done and sends the last error
var doneCh = make(chan struct{})
var errVal atomic.Value
type errWrap struct {
E error
}
go func() {
// the retry delay between each attempt
var delay time.Duration = 0
defer close(doneCh)
for {
select {
case <-ctx.Done():
return
case <-time.After(delay):
}
err := f()
switch e := err.(type) {
case nil:
return
case Fatal:
errVal.Store(errWrap{e.FatalError()})
return
}
delay *= 2
if delay == 0 {
delay = initialBackoffDelay
}
delay = min(delay, maxBackoffDelay)
log.Printf("[WARN] retryable error: %q, delaying for %s", err, delay)
}
}()
// Wait until done or deadline
select {
case <-doneCh:
case <-ctx.Done():
}
err, hadErr := errVal.Load().(errWrap)
var lastErr error
if hadErr {
lastErr = err.E
}
if ctx.Err() != nil {
return ctx.Err()
}
return lastErr
}

View file

@ -0,0 +1,100 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cloud
import (
"context"
"errors"
"testing"
"time"
)
type fatalError struct{}
var fe = errors.New("this was a fatal error")
func (f fatalError) FatalError() error {
return fe
}
func (f fatalError) Error() string {
return f.FatalError().Error()
}
func Test_RetryBackoff_canceled(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := RetryBackoff(ctx, func() error {
return nil
})
if !errors.Is(err, context.Canceled) {
t.Errorf("expected canceled error, got %q", err)
}
}
func Test_RetryBackoff_deadline(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Millisecond))
defer cancel()
err := RetryBackoff(ctx, func() error {
time.Sleep(10 * time.Millisecond)
return nil
})
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("expected timeout error, got %q", err)
}
}
func Test_RetryBackoff_happy(t *testing.T) {
t.Parallel()
err := RetryBackoff(context.Background(), func() error {
return nil
})
if err != nil {
t.Errorf("expected nil err, got %q", err)
}
}
func Test_RetryBackoff_fatal(t *testing.T) {
t.Parallel()
err := RetryBackoff(context.Background(), func() error {
return fatalError{}
})
if !errors.Is(fe, err) {
t.Errorf("expected fatal error, got %q", err)
}
}
func Test_RetryBackoff_non_fatal(t *testing.T) {
t.Parallel()
var retriedCount = 0
err := RetryBackoff(context.Background(), func() error {
retriedCount += 1
if retriedCount == 2 {
return nil
}
return errors.New("retryable error")
})
if err != nil {
t.Errorf("expected no error, got %q", err)
}
if retriedCount != 2 {
t.Errorf("expected 2 retries, got %d", retriedCount)
}
}

View file

@ -515,12 +515,37 @@ func (s *State) Delete(force bool) error {
}
// GetRootOutputValues fetches output values from HCP Terraform
func (s *State) GetRootOutputValues() (map[string]*states.OutputValue, error) {
ctx := context.Background()
func (s *State) GetRootOutputValues(ctx context.Context) (map[string]*states.OutputValue, error) {
// The cloud backend initializes this value to true, but we want to implement
// some custom retry logic. This code presumes that the tfeClient doesn't need
// to be shared with other goroutines by the caller.
s.tfeClient.RetryServerErrors(false)
defer s.tfeClient.RetryServerErrors(true)
so, err := s.tfeClient.StateVersionOutputs.ReadCurrent(ctx, s.workspace.ID)
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
var so *tfe.StateVersionOutputsList
err := RetryBackoff(ctx, func() error {
var err error
so, err = s.tfeClient.StateVersionOutputs.ReadCurrent(ctx, s.workspace.ID)
if err != nil {
if strings.Contains(err.Error(), "service unavailable") {
return err
}
return NonRetryableError{err}
}
return nil
})
if err != nil {
switch err {
case context.DeadlineExceeded:
return nil, fmt.Errorf("current outputs were not ready to be read within the deadline. Please try again")
case context.Canceled:
return nil, fmt.Errorf("canceled reading current outputs")
}
return nil, fmt.Errorf("could not read state version outputs: %w", err)
}

View file

@ -40,7 +40,7 @@ func TestState_GetRootOutputValues(t *testing.T) {
state := &State{tfeClient: b.client, organization: b.Organization, workspace: &tfe.Workspace{
ID: "ws-abcd",
}}
outputs, err := state.GetRootOutputValues()
outputs, err := state.GetRootOutputValues(context.Background())
if err != nil {
t.Fatalf("error returned from GetRootOutputValues: %s", err)

View file

@ -42,8 +42,8 @@ func NewSetFunc[T any](keyFunc func(T) UniqueKey[T], elems ...T) Set[T] {
// NewSetCmp constructs a new set for any comparable type, using the built-in
// == operator as the definition of element equivalence.
func NewSetCmp[T comparable]() Set[T] {
return NewSetFunc(cmpUniqueKeyFunc[T])
func NewSetCmp[T comparable](elems ...T) Set[T] {
return NewSetFunc(cmpUniqueKeyFunc[T], elems...)
}
// Has returns true if the given value is present in the set, or false

View file

@ -71,17 +71,6 @@ func (c *ApplyCommand) Run(rawArgs []string) int {
return 1
}
// Check for invalid combination of plan file and variable overrides
if planFile != nil && !args.Vars.Empty() {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Can't set variables when applying a saved plan",
"The -var and -var-file options cannot be used when applying a saved plan file, because a saved plan includes the variable values that were set when it was created.",
))
view.Diagnostics(diags)
return 1
}
// FIXME: the -input flag value is needed to initialize the backend and the
// operation, but there is no clear path to pass this value down, so we
// continue to mutate the Meta object state for now.
@ -283,6 +272,7 @@ func (c *ApplyCommand) OperationRequest(
opReq.ForceReplace = args.ForceReplace
opReq.Type = backendrun.OperationTypeApply
opReq.View = view.Operation()
opReq.StatePersistInterval = c.Meta.StatePersistInterval()
// EXPERIMENTAL: maybe enable deferred actions
if c.AllowExperimentalFeatures {

View file

@ -900,6 +900,16 @@ func TestApply_planWithVarFile(t *testing.T) {
}
func TestApply_planVars(t *testing.T) {
// This test ensures that it isn't allowed to set input variables
// when applying from a saved plan file, since in that case the
// variable values come from the saved plan file.
//
// This situation was originally checked by the apply command itself,
// and that's what this test was originally exercising. This rule
// is now enforced by the "local" backend instead, but this test
// is still valid since the command instance delegates to the
// local backend.
planPath := applyFixturePlanFile(t)
statePath := testTempFile(t)

View file

@ -30,6 +30,10 @@ const DefaultPluginVendorDir = "terraform.d/plugins/" + pluginMachineName
// DefaultStateFilename is the default filename used for the state file.
const DefaultStateFilename = "terraform.tfstate"
// DefaultStatePersistInterval is the default interval a backend should persist
// Terraform state, if applicable. Backends can set their own custom defaults.
const DefaultStatePersistInterval = 20
// DefaultVarsFilename is the default filename used for vars
const DefaultVarsFilename = "terraform.tfvars"

View file

@ -25,6 +25,8 @@ import (
"github.com/hashicorp/terraform/internal/depsfile"
"github.com/hashicorp/terraform/internal/getproviders"
"github.com/hashicorp/terraform/internal/providercache"
"github.com/hashicorp/terraform/internal/providers"
testing_provider "github.com/hashicorp/terraform/internal/providers/testing"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/states/statefile"
"github.com/hashicorp/terraform/internal/states/statemgr"
@ -2831,6 +2833,44 @@ func TestInit_invalidSyntaxBackendAttribute(t *testing.T) {
}
}
func TestInit_testsWithExternalProviders(t *testing.T) {
td := t.TempDir()
testCopyDir(t, testFixturePath("init-with-tests-external-providers"), td)
defer testChdir(t, td)()
providerSource, close := newMockProviderSource(t, map[string][]string{
"hashicorp/testing": {"1.0.0"},
"testing/configure": {"1.0.0"},
})
defer close()
hashicorpTestingProviderAddress := addrs.NewDefaultProvider("testing")
hashicorpTestingProvider := new(testing_provider.MockProvider)
testingConfigureProviderAddress := addrs.NewProvider(addrs.DefaultProviderRegistryHost, "testing", "configure")
testingConfigureProvider := new(testing_provider.MockProvider)
ui := new(cli.MockUi)
view, done := testView(t)
c := &InitCommand{
Meta: Meta{
testingOverrides: &testingOverrides{
Providers: map[addrs.Provider]providers.Factory{
hashicorpTestingProviderAddress: providers.FactoryFixed(hashicorpTestingProvider),
testingConfigureProviderAddress: providers.FactoryFixed(testingConfigureProvider),
},
},
Ui: ui,
View: view,
ProviderSource: providerSource,
},
}
var args []string
if code := c.Run(args); code != 0 {
t.Fatalf("bad: \n%s", done(t).All())
}
}
func TestInit_tests(t *testing.T) {
// Create a temporary working directory that is empty
td := t.TempDir()

View file

@ -74,6 +74,13 @@ func ComputeDiffForBlock(change structured.Change, block *jsonprovider.Block) co
afterSensitive := childValue.IsAfterSensitive()
forcesReplacement := childValue.ReplacePaths.Matches()
if unknown, ok := checkForUnknownBlock(childValue, block); ok {
// An unknown block doesn't render any type information, so we can
// render it as a single block rather than switching on all types.
blocks.AddSingleBlock(key, unknown, forcesReplacement, beforeSensitive, afterSensitive)
continue
}
switch NestingMode(blockType.NestingMode) {
case nestingModeSet:
diffs, action := computeBlockDiffsAsSet(childValue, blockType.Block)

View file

@ -99,6 +99,41 @@ func TestValue_SimpleBlocks(t *testing.T) {
"normal_attribute": renderers.ValidatePrimitive(nil, "some value", plans.Create, false),
}, nil, nil, nil, nil, plans.Create, false),
},
"create_with_unknown_block": {
input: structured.Change{
Before: nil,
After: map[string]interface{}{
"normal_attribute": "some value",
},
Unknown: map[string]any{
"nested": true,
},
},
block: &jsonprovider.Block{
Attributes: map[string]*jsonprovider.Attribute{
"normal_attribute": {
AttributeType: unmarshalType(t, cty.String),
},
},
BlockTypes: map[string]*jsonprovider.BlockType{
"nested": {
NestingMode: "single",
Block: &jsonprovider.Block{
Attributes: map[string]*jsonprovider.Attribute{
"attr": {
AttributeType: unmarshalType(t, cty.String),
Optional: true,
},
},
},
},
},
},
validate: renderers.ValidateBlock(map[string]renderers.ValidateDiffFunction{
"normal_attribute": renderers.ValidatePrimitive(nil, "some value", plans.Create, false),
}, map[string]renderers.ValidateDiffFunction{
"nested": renderers.ValidateUnknown(nil, plans.Create, false),
}, nil, nil, nil, plans.Create, false)},
}
for name, tc := range tcs {
// Set some default values

View file

@ -86,6 +86,8 @@ func Marshal(f map[string]function.Function) ([]byte, tfdiags.Diagnostics) {
signatures.Signatures[name] = marshalCan(v)
} else if name == "try" || name == "core::try" {
signatures.Signatures[name] = marshalTry(v)
} else if name == "templatestring" || name == "core::templatestring" {
signatures.Signatures[name] = marshalTemplatestring(v)
} else {
signature, err := marshalFunction(v)
if err != nil {
@ -194,3 +196,28 @@ func marshalCan(can function.Function) *FunctionSignature {
},
}
}
// marshalTemplatestring returns a static function signature for the
// templatestring function.
// We need this exception because the function implementation uses capsule
// types that we can't marshal.
func marshalTemplatestring(templatestring function.Function) *FunctionSignature {
return &FunctionSignature{
Description: templatestring.Description(),
ReturnType: cty.String,
Parameters: []*parameter{
{
Name: templatestring.Params()[0].Name,
Description: templatestring.Params()[0].Description,
IsNullable: templatestring.Params()[0].AllowNull,
Type: cty.String,
},
{
Name: templatestring.Params()[1].Name,
Description: templatestring.Params()[1].Description,
IsNullable: templatestring.Params()[1].AllowNull,
Type: cty.DynamicPseudoType,
},
},
}
}

View file

@ -736,6 +736,28 @@ func (m *Meta) showDiagnostics(vals ...interface{}) {
}
}
const (
// StatePersistIntervalEnvVar is the environment variable that can be set
// to control the interval at which Terraform persists state. The interval
// itself defaults to 20 seconds.
StatePersistIntervalEnvVar = "TF_STATE_PERSIST_INTERVAL"
)
// StatePersistInterval returns the configured interval that Terraform should
// persist statefiles to the desired backend. Backends may choose to override
// the default value.
func (m *Meta) StatePersistInterval() int {
if val, ok := os.LookupEnv(StatePersistIntervalEnvVar); ok {
if interval, err := strconv.Atoi(val); err == nil && interval > DefaultStatePersistInterval {
// The user-specified interval must be greater than the default minimum
return interval
} else if err != nil {
log.Printf("[ERROR] Can't parse state persist interval %q of environment variable %q", val, StatePersistIntervalEnvVar)
}
}
return DefaultStatePersistInterval
}
// WorkspaceNameEnvVar is the name of the environment variable that can be used
// to set the name of the Terraform workspace, overriding the workspace chosen
// by `terraform workspace select`.

View file

@ -219,6 +219,60 @@ func TestMeta_Env(t *testing.T) {
}
}
func TestMeta_StatePersistInterval(t *testing.T) {
m := new(Meta)
t.Run("when the env var is not defined", func(t *testing.T) {
interval := m.StatePersistInterval()
if interval != DefaultStatePersistInterval {
t.Fatalf("expected state persist interval to be %d, got: %d", DefaultStatePersistInterval, interval)
}
})
t.Run("with valid interval greater than the default", func(t *testing.T) {
os.Setenv(StatePersistIntervalEnvVar, "25")
t.Cleanup(func() {
os.Unsetenv(StatePersistIntervalEnvVar)
})
interval := m.StatePersistInterval()
if interval != 25 {
t.Fatalf("expected state persist interval to be 25, got: %d", interval)
}
})
t.Run("with a valid interval less than the default", func(t *testing.T) {
os.Setenv(StatePersistIntervalEnvVar, "10")
t.Cleanup(func() {
os.Unsetenv(StatePersistIntervalEnvVar)
})
interval := m.StatePersistInterval()
if interval != DefaultStatePersistInterval {
t.Fatalf("expected state persist interval to be %d, got: %d", DefaultStatePersistInterval, interval)
}
})
t.Run("with invalid integer interval", func(t *testing.T) {
os.Setenv(StatePersistIntervalEnvVar, "foo")
t.Cleanup(func() {
os.Unsetenv(StatePersistIntervalEnvVar)
})
interval := m.StatePersistInterval()
if interval != DefaultStatePersistInterval {
t.Fatalf("expected state persist interval to be %d, got: %d", DefaultStatePersistInterval, interval)
}
})
t.Run("with negative integer interval", func(t *testing.T) {
os.Setenv(StatePersistIntervalEnvVar, "-10")
t.Cleanup(func() {
os.Unsetenv(StatePersistIntervalEnvVar)
})
interval := m.StatePersistInterval()
if interval != DefaultStatePersistInterval {
t.Fatalf("expected state persist interval to be %d, got: %d", DefaultStatePersistInterval, interval)
}
})
}
func TestMeta_Workspace_override(t *testing.T) {
defer func(value string) {
os.Setenv(WorkspaceNameEnvVar, value)

View file

@ -69,6 +69,10 @@ func (c *OutputCommand) Outputs(statePath string) (map[string]*states.OutputValu
return nil, diags
}
// Command can be aborted by interruption signals
ctx, done := c.InterruptibleContext(c.CommandContext())
defer done()
// This is a read-only command
c.ignoreRemoteVersionConflict(b)
@ -85,7 +89,7 @@ func (c *OutputCommand) Outputs(statePath string) (map[string]*states.OutputValu
return nil, diags
}
output, err := stateStore.GetRootOutputValues()
output, err := stateStore.GetRootOutputValues(ctx)
if err != nil {
return nil, diags.Append(err)
}

View file

@ -192,7 +192,6 @@ func TestProviders_tests(t *testing.T) {
}
wantOutput := []string{
"provider[registry.terraform.io/hashicorp/foo]",
"test.main",
"provider[registry.terraform.io/hashicorp/bar]",
}

View file

@ -1270,6 +1270,84 @@ Success! 2 passed, 0 failed.
}
}
// There should not be warnings in clean-up
func TestTest_InvalidWarningsInCleanup(t *testing.T) {
td := t.TempDir()
testCopyDir(t, testFixturePath(path.Join("test", "invalid-cleanup-warnings")), td)
defer testChdir(t, td)()
provider := testing_command.NewProvider(nil)
providerSource, close := newMockProviderSource(t, map[string][]string{
"test": {"1.0.0"},
})
defer close()
streams, done := terminal.StreamsForTesting(t)
view := views.NewView(streams)
ui := new(cli.MockUi)
meta := Meta{
testingOverrides: metaOverridesForProvider(provider.Provider),
Ui: ui,
View: view,
Streams: streams,
ProviderSource: providerSource,
}
init := &InitCommand{
Meta: meta,
}
output := done(t)
if code := init.Run(nil); code != 0 {
t.Fatalf("expected status code 0 but got %d: %s", code, output.All())
}
// Reset the streams for the next command.
streams, done = terminal.StreamsForTesting(t)
meta.Streams = streams
meta.View = views.NewView(streams)
c := &TestCommand{
Meta: meta,
}
code := c.Run([]string{"-no-color"})
output = done(t)
if code != 0 {
t.Errorf("expected status code 0 but got %d", code)
}
expected := `main.tftest.hcl... in progress
run "test"... pass
Warning: Value for undeclared variable
on main.tftest.hcl line 6, in run "test":
6: validation = "Hello, world!"
The module under test does not declare a variable named "validation", but it
is declared in run block "test".
main.tftest.hcl... tearing down
main.tftest.hcl... pass
Success! 1 passed, 0 failed.
`
actual := output.All()
if diff := cmp.Diff(actual, expected); len(diff) > 0 {
t.Errorf("output didn't match expected:\nexpected:\n%s\nactual:\n%s\ndiff:\n%s", expected, actual, diff)
}
if provider.ResourceCount() > 0 {
t.Errorf("should have deleted all resources on completion but left %v", provider.ResourceString())
}
}
func TestTest_BadReferences(t *testing.T) {
td := t.TempDir()
testCopyDir(t, testFixturePath(path.Join("test", "bad-references")), td)

View file

@ -0,0 +1,3 @@
resource "testing_instance" "baz" {
ami = "baz"
}

View file

@ -0,0 +1,25 @@
// configure is not a "hashicorp" provider, so it won't be able to load
// this using the default behaviour. Terraform will need to look into the setup
// module to find the provider configuration.
provider "configure" {}
// testing is a "hashicorp" provider, so it can load this using the defaults
// even though not required provider block providers a definition for it.
provider "testing" {}
run "setup" {
module {
source = "./setup"
}
providers = {
configure = configure
}
}
run "test" {
providers = {
testing = testing
}
}

View file

@ -0,0 +1,11 @@
terraform {
required_providers {
configure = {
source = "testing/configure"
}
}
}
resource "configure_instance" "baz" {
ami = "baz"
}

View file

@ -1,3 +1,5 @@
// This won't actually show up in the providers list, as nothing is actually
// using it.
provider "foo" {
}

View file

@ -0,0 +1,8 @@
# main.tf
variable "input" {}
resource "test_resource" "resource" {
value = var.input
}

View file

@ -0,0 +1,13 @@
# main.tftest.hcl
run "test" {
variables {
input = "Hello, world!"
validation = "Hello, world!"
}
assert {
condition = test_resource.resource.value == "Hello, world!"
error_message = "bad!"
}
}

View file

@ -275,6 +275,7 @@ func NewDiagnostic(diag tfdiags.Diagnostic, sources map[string][]byte) *Diagnost
values := make([]DiagnosticExpressionValue, 0, len(vars))
seen := make(map[string]struct{}, len(vars))
includeUnknown := tfdiags.DiagnosticCausedByUnknown(diag)
includeEphemeral := tfdiags.DiagnosticCausedByEphemeral(diag)
includeSensitive := tfdiags.DiagnosticCausedBySensitive(diag)
Traversals:
for _, traversal := range vars {
@ -295,6 +296,22 @@ func NewDiagnostic(diag tfdiags.Diagnostic, sources map[string][]byte) *Diagnost
value := DiagnosticExpressionValue{
Traversal: traversalStr,
}
// We'll skip any value that has a mark that we don't
// know how to handle, because in that case we can't
// know what that mark is intended to represent and so
// must be conservative.
_, valMarks := val.Unmark()
for mark := range valMarks {
switch mark {
case marks.Sensitive, marks.Ephemeral:
// These are handled below
continue
default:
// All other marks are unhandled, so we'll
// skip this traversal entirely.
continue Traversals
}
}
switch {
case val.HasMark(marks.Sensitive):
// We only mention a sensitive value if the diagnostic
@ -351,6 +368,9 @@ func NewDiagnostic(diag tfdiags.Diagnostic, sources map[string][]byte) *Diagnost
default:
value.Statement = fmt.Sprintf("is %s", compactValueStr(val))
}
if includeEphemeral && val.HasMark(marks.Ephemeral) {
value.Statement += ", and is ephemeral"
}
values = append(values, value)
seen[traversalStr] = struct{}{}
}
@ -417,12 +437,27 @@ func compactValueStr(val cty.Value) string {
// helpful but concise messages in diagnostics. It is not comprehensive
// nor intended to be used for other purposes.
if val.HasMark(marks.Sensitive) {
// We check this in here just to make sure, but note that the caller
// of compactValueStr ought to have already checked this and skipped
// calling into compactValueStr anyway, so this shouldn't actually
// be reachable.
return "(sensitive value)"
val, valMarks := val.Unmark()
for mark := range valMarks {
switch mark {
case marks.Sensitive:
// We check this in here just to make sure, but note that the caller
// of compactValueStr ought to have already checked this and skipped
// calling into compactValueStr anyway, so this shouldn't actually
// be reachable.
return "(sensitive value)"
case marks.Ephemeral:
// A non-sensitive ephemeral value is fine to show in the UI. Values
// that are both ephemeral and sensitive should have both markings
// and should therefore get caught by the marks.Sensitive case
// above.
continue
default:
// We don't know about any other marks, so we'll be conservative.
// This shouldn't actuallyr eachable since the caller should've
// checked this and skipped calling compactValueStr anyway.
return "value with unrecognized marks (this is a bug in Terraform)"
}
}
// WARNING: We've only checked that the value isn't sensitive _shallowly_

View file

@ -384,10 +384,6 @@ func (c *Config) ProviderRequirementsByModule() (*ModuleRequirements, hcl.Diagno
Runs: make(map[string]*ModuleRequirements),
}
for _, provider := range test.Providers {
diags = append(diags, c.addProviderRequirementsFromProviderBlock(testReqs.Requirements, provider)...)
}
for _, run := range test.Runs {
if run.ConfigUnderTest == nil {
continue
@ -556,20 +552,13 @@ func (c *Config) addProviderRequirements(reqs providerreqs.Requirements, recurse
// We may have provider blocks and required_providers set in some testing
// files.
if tests {
if tests && recurse {
for _, file := range c.Module.Tests {
for _, provider := range file.Providers {
moreDiags := c.addProviderRequirementsFromProviderBlock(reqs, provider)
diags = append(diags, moreDiags...)
}
if recurse {
// Then we'll also look for requirements in testing modules.
for _, run := range file.Runs {
if run.ConfigUnderTest != nil {
moreDiags := run.ConfigUnderTest.addProviderRequirements(reqs, true, false)
diags = append(diags, moreDiags...)
}
// Then we'll also look for requirements in testing modules.
for _, run := range file.Runs {
if run.ConfigUnderTest != nil {
moreDiags := run.ConfigUnderTest.addProviderRequirements(reqs, true, false)
diags = append(diags, moreDiags...)
}
}
}

View file

@ -165,12 +165,7 @@ func TestConfigProviderRequirements(t *testing.T) {
func TestConfigProviderRequirementsInclTests(t *testing.T) {
cfg, diags := testNestedModuleConfigFromDirWithTests(t, "testdata/provider-reqs-with-tests")
// TODO: Version Constraint Deprecation.
// Once we've removed the version argument from provider configuration
// blocks, this can go back to expected 0 diagnostics.
// assertNoDiagnostics(t, diags)
assertDiagnosticCount(t, diags, 1)
assertDiagnosticSummary(t, diags, "Version constraints inside provider configuration blocks are deprecated")
assertDiagnosticCount(t, diags, 0)
tlsProvider := addrs.NewProvider(
addrs.DefaultProviderRegistryHost,
@ -189,7 +184,7 @@ func TestConfigProviderRequirementsInclTests(t *testing.T) {
nullProvider: providerreqs.MustParseVersionConstraints("~> 2.0.0"),
randomProvider: providerreqs.MustParseVersionConstraints("~> 1.2.0"),
tlsProvider: providerreqs.MustParseVersionConstraints("~> 3.0"),
configuredProvider: providerreqs.MustParseVersionConstraints("~> 1.4"),
configuredProvider: nil,
impliedProvider: nil,
terraformProvider: nil,
}
@ -243,12 +238,7 @@ func TestConfigProviderRequirementsShallow(t *testing.T) {
func TestConfigProviderRequirementsShallowInclTests(t *testing.T) {
cfg, diags := testNestedModuleConfigFromDirWithTests(t, "testdata/provider-reqs-with-tests")
// TODO: Version Constraint Deprecation.
// Once we've removed the version argument from provider configuration
// blocks, this can go back to expected 0 diagnostics.
// assertNoDiagnostics(t, diags)
assertDiagnosticCount(t, diags, 1)
assertDiagnosticSummary(t, diags, "Version constraints inside provider configuration blocks are deprecated")
assertDiagnosticCount(t, diags, 0)
tlsProvider := addrs.NewProvider(
addrs.DefaultProviderRegistryHost,
@ -256,15 +246,13 @@ func TestConfigProviderRequirementsShallowInclTests(t *testing.T) {
)
impliedProvider := addrs.NewDefaultProvider("implied")
terraformProvider := addrs.NewBuiltInProvider("terraform")
configuredProvider := addrs.NewDefaultProvider("configured")
got, diags := cfg.ProviderRequirementsShallow()
assertNoDiagnostics(t, diags)
want := providerreqs.Requirements{
tlsProvider: providerreqs.MustParseVersionConstraints("~> 3.0"),
configuredProvider: providerreqs.MustParseVersionConstraints("~> 1.4"),
impliedProvider: nil,
terraformProvider: nil,
tlsProvider: providerreqs.MustParseVersionConstraints("~> 3.0"),
impliedProvider: nil,
terraformProvider: nil,
}
if diff := cmp.Diff(want, got); diff != "" {
@ -346,12 +334,7 @@ func TestConfigProviderRequirementsByModule(t *testing.T) {
func TestConfigProviderRequirementsByModuleInclTests(t *testing.T) {
cfg, diags := testNestedModuleConfigFromDirWithTests(t, "testdata/provider-reqs-with-tests")
// TODO: Version Constraint Deprecation.
// Once we've removed the version argument from provider configuration
// blocks, this can go back to expected 0 diagnostics.
// assertNoDiagnostics(t, diags)
assertDiagnosticCount(t, diags, 1)
assertDiagnosticSummary(t, diags, "Version constraints inside provider configuration blocks are deprecated")
assertDiagnosticCount(t, diags, 0)
tlsProvider := addrs.NewProvider(
addrs.DefaultProviderRegistryHost,
@ -378,17 +361,16 @@ func TestConfigProviderRequirementsByModuleInclTests(t *testing.T) {
Children: make(map[string]*ModuleRequirements),
Tests: map[string]*TestFileModuleRequirements{
"provider-reqs-root.tftest.hcl": {
Requirements: providerreqs.Requirements{
configuredProvider: providerreqs.MustParseVersionConstraints("~> 1.4"),
},
Requirements: providerreqs.Requirements{},
Runs: map[string]*ModuleRequirements{
"setup": {
Name: "setup",
SourceAddr: addrs.ModuleSourceLocal("./setup"),
SourceDir: "testdata/provider-reqs-with-tests/setup",
Requirements: providerreqs.Requirements{
nullProvider: providerreqs.MustParseVersionConstraints("~> 2.0.0"),
randomProvider: providerreqs.MustParseVersionConstraints("~> 1.2.0"),
nullProvider: providerreqs.MustParseVersionConstraints("~> 2.0.0"),
randomProvider: providerreqs.MustParseVersionConstraints("~> 1.2.0"),
configuredProvider: nil,
},
Children: make(map[string]*ModuleRequirements),
Tests: make(map[string]*TestFileModuleRequirements),

View file

@ -164,3 +164,10 @@ func (l *Loader) ImportSourcesFromSnapshot(snap *Snapshot) {
func (l *Loader) AllowLanguageExperiments(allowed bool) {
l.parser.AllowLanguageExperiments(allowed)
}
// AllowsLanguageExperiments returns the value most recently passed to
// [Loader.AllowLanguageExperiments], or false if that method has not been
// called on this object.
func (l *Loader) AllowsLanguageExperiments() bool {
return l.parser.AllowsLanguageExperiments()
}

View file

@ -3,33 +3,35 @@
package configschema
type FilterT[T any] func(string, T) bool
import "github.com/zclconf/go-cty/cty"
type FilterT[T any] func(cty.Path, T) bool
var (
FilterReadOnlyAttribute = func(name string, attribute *Attribute) bool {
FilterReadOnlyAttribute = func(path cty.Path, attribute *Attribute) bool {
return attribute.Computed && !attribute.Optional
}
FilterHelperSchemaIdAttribute = func(name string, attribute *Attribute) bool {
if name == "id" && attribute.Computed && attribute.Optional {
FilterHelperSchemaIdAttribute = func(path cty.Path, attribute *Attribute) bool {
if path.Equals(cty.GetAttrPath("id")) && attribute.Computed && attribute.Optional {
return true
}
return false
}
FilterDeprecatedAttribute = func(name string, attribute *Attribute) bool {
FilterDeprecatedAttribute = func(path cty.Path, attribute *Attribute) bool {
return attribute.Deprecated
}
FilterDeprecatedBlock = func(name string, block *NestedBlock) bool {
FilterDeprecatedBlock = func(path cty.Path, block *NestedBlock) bool {
return block.Deprecated
}
)
func FilterOr[T any](filters ...FilterT[T]) FilterT[T] {
return func(name string, value T) bool {
return func(path cty.Path, value T) bool {
for _, f := range filters {
if f(name, value) {
if f(path, value) {
return true
}
}
@ -38,6 +40,10 @@ func FilterOr[T any](filters ...FilterT[T]) FilterT[T] {
}
func (b *Block) Filter(filterAttribute FilterT[*Attribute], filterBlock FilterT[*NestedBlock]) *Block {
return b.filter(nil, filterAttribute, filterBlock)
}
func (b *Block) filter(path cty.Path, filterAttribute FilterT[*Attribute], filterBlock FilterT[*NestedBlock]) *Block {
ret := &Block{
Description: b.Description,
DescriptionKind: b.DescriptionKind,
@ -48,10 +54,11 @@ func (b *Block) Filter(filterAttribute FilterT[*Attribute], filterBlock FilterT[
ret.Attributes = make(map[string]*Attribute, len(b.Attributes))
}
for name, attrS := range b.Attributes {
if filterAttribute == nil || !filterAttribute(name, attrS) {
path := path.GetAttr(name)
if filterAttribute == nil || !filterAttribute(path, attrS) {
ret.Attributes[name] = attrS
if attrS.NestedType != nil {
ret.Attributes[name].NestedType = filterNestedType(attrS.NestedType, filterAttribute)
ret.Attributes[name].NestedType = filterNestedType(attrS.NestedType, path, filterAttribute)
}
}
}
@ -60,8 +67,9 @@ func (b *Block) Filter(filterAttribute FilterT[*Attribute], filterBlock FilterT[
ret.BlockTypes = make(map[string]*NestedBlock, len(b.BlockTypes))
}
for name, blockS := range b.BlockTypes {
if filterBlock == nil || !filterBlock(name, blockS) {
block := blockS.Filter(filterAttribute, filterBlock)
path := path.GetAttr(name)
if filterBlock == nil || !filterBlock(path, blockS) {
block := blockS.filter(path, filterAttribute, filterBlock)
ret.BlockTypes[name] = &NestedBlock{
Block: *block,
Nesting: blockS.Nesting,
@ -74,7 +82,7 @@ func (b *Block) Filter(filterAttribute FilterT[*Attribute], filterBlock FilterT[
return ret
}
func filterNestedType(obj *Object, filterAttribute FilterT[*Attribute]) *Object {
func filterNestedType(obj *Object, path cty.Path, filterAttribute FilterT[*Attribute]) *Object {
if obj == nil {
return nil
}
@ -85,10 +93,11 @@ func filterNestedType(obj *Object, filterAttribute FilterT[*Attribute]) *Object
}
for name, attrS := range obj.Attributes {
if filterAttribute == nil || !filterAttribute(name, attrS) {
path := path.GetAttr(name)
if filterAttribute == nil || !filterAttribute(path, attrS) {
ret.Attributes[name] = attrS
if attrS.NestedType != nil {
ret.Attributes[name].NestedType = filterNestedType(attrS.NestedType, filterAttribute)
ret.Attributes[name].NestedType = filterNestedType(attrS.NestedType, path, filterAttribute)
}
}
}

View file

@ -208,16 +208,25 @@ func checkModuleExperiments(m *Module) hcl.Diagnostics {
}
*/
if !m.ActiveExperiments.Has(experiments.VariableValidationCrossRef) {
// Without this experiment, validation rules are subject to the old
// rule that they can only refer to the variable whose value they
// are checking. This experiment removes that constraint, and makes
// the modules runtime responsible for validating and evaluating
// the conditions and error messages, just as we'd do for any other
// dynamic expression.
for varName, vc := range m.Variables {
for _, vv := range vc.Validations {
diags = append(diags, checkVariableValidationBlock(varName, vv)...)
if !m.ActiveExperiments.Has(experiments.EphemeralValues) {
for _, oc := range m.Outputs {
if oc.EphemeralSet {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Ephemeral values are experimental",
Detail: "This feature is currently an opt-in experiment, subject to change in future releases based on feedback.\n\nActivate the feature for this module by adding ephemeral_values to the list of active experiments.",
Subject: oc.DeclRange.Ptr(),
})
}
}
for _, vc := range m.Variables {
if vc.EphemeralSet {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Ephemeral values are experimental",
Detail: "This feature is currently an opt-in experiment, subject to change in future releases based on feedback.\n\nActivate the feature for this module by adding ephemeral_values to the list of active experiments.",
Subject: vc.DeclRange.Ptr(),
})
}
}
}

View file

@ -49,6 +49,10 @@ func (v *Variable) merge(ov *Variable) hcl.Diagnostics {
v.Sensitive = ov.Sensitive
v.SensitiveSet = ov.SensitiveSet
}
if ov.EphemeralSet {
v.Ephemeral = ov.Ephemeral
v.EphemeralSet = ov.EphemeralSet
}
if ov.Default != cty.NilVal {
v.Default = ov.Default
}
@ -148,6 +152,10 @@ func (o *Output) merge(oo *Output) hcl.Diagnostics {
o.Sensitive = oo.Sensitive
o.SensitiveSet = oo.SensitiveSet
}
if oo.EphemeralSet {
o.Ephemeral = oo.Ephemeral
o.EphemeralSet = oo.EphemeralSet
}
// We don't allow depends_on to be overridden because that is likely to
// cause confusing misbehavior.

View file

@ -35,9 +35,11 @@ type Variable struct {
ParsingMode VariableParsingMode
Validations []*CheckRule
Sensitive bool
Ephemeral bool
DescriptionSet bool
SensitiveSet bool
EphemeralSet bool
// Nullable indicates that null is a valid value for this variable. Setting
// Nullable to false means that the module can expect this variable to
@ -120,6 +122,12 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno
v.SensitiveSet = true
}
if attr, exists := content.Attributes["ephemeral"]; exists {
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &v.Ephemeral)
diags = append(diags, valDiags...)
v.EphemeralSet = true
}
if attr, exists := content.Attributes["nullable"]; exists {
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &v.Nullable)
diags = append(diags, valDiags...)
@ -178,10 +186,11 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno
switch block.Type {
case "validation":
vv, moreDiags := decodeVariableValidationBlock(block, override)
vv, moreDiags := decodeCheckRuleBlock(block, override)
diags = append(diags, moreDiags...)
v.Validations = append(v.Validations, vv)
diags = append(diags, checkVariableValidationBlock(v.Name, vv)...)
v.Validations = append(v.Validations, vv)
default:
// The above cases should be exhaustive for all block types
// defined in variableBlockSchema
@ -324,77 +333,6 @@ func (m VariableParsingMode) Parse(name, value string) (cty.Value, hcl.Diagnosti
}
}
// decodeVariableValidationBlock is a wrapper around decodeCheckRuleBlock
// that imposes the additional rule that the condition expression can refer
// only to an input variable of the given name.
func decodeVariableValidationBlock(block *hcl.Block, override bool) (*CheckRule, hcl.Diagnostics) {
return decodeCheckRuleBlock(block, override)
}
func checkVariableValidationBlock(varName string, vv *CheckRule) hcl.Diagnostics {
var diags hcl.Diagnostics
if vv.Condition != nil {
// The validation condition can only refer to the variable itself,
// to ensure that the variable declaration can't create additional
// edges in the dependency graph.
goodRefs := 0
for _, traversal := range vv.Condition.Variables() {
ref, moreDiags := addrs.ParseRef(traversal)
if !moreDiags.HasErrors() {
if addr, ok := ref.Subject.(addrs.InputVariable); ok {
if addr.Name == varName {
goodRefs++
continue // Reference is valid
}
}
}
// If we fall out here then the reference is invalid.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid reference in variable validation",
Detail: fmt.Sprintf("The condition for variable %q can only refer to the variable itself, using var.%s.", varName, varName),
Subject: traversal.SourceRange().Ptr(),
})
}
if goodRefs < 1 {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid variable validation condition",
Detail: fmt.Sprintf("The condition for variable %q must refer to var.%s in order to test incoming values.", varName, varName),
Subject: vv.Condition.Range().Ptr(),
})
}
}
if vv.ErrorMessage != nil {
// The same applies to the validation error message, except that
// references are not required. A string literal is a valid error
// message.
goodRefs := 0
for _, traversal := range vv.ErrorMessage.Variables() {
ref, moreDiags := addrs.ParseRef(traversal)
if !moreDiags.HasErrors() {
if addr, ok := ref.Subject.(addrs.InputVariable); ok {
if addr.Name == varName {
goodRefs++
continue // Reference is valid
}
}
}
// If we fall out here then the reference is invalid.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid reference in variable validation",
Detail: fmt.Sprintf("The error message for variable %q can only refer to the variable itself, using var.%s.", varName, varName),
Subject: traversal.SourceRange().Ptr(),
})
}
}
return diags
}
// Output represents an "output" block in a module or file.
type Output struct {
Name string
@ -402,11 +340,13 @@ type Output struct {
Expr hcl.Expression
DependsOn []hcl.Traversal
Sensitive bool
Ephemeral bool
Preconditions []*CheckRule
DescriptionSet bool
SensitiveSet bool
EphemeralSet bool
DeclRange hcl.Range
}
@ -452,6 +392,12 @@ func decodeOutputBlock(block *hcl.Block, override bool) (*Output, hcl.Diagnostic
o.SensitiveSet = true
}
if attr, exists := content.Attributes["ephemeral"]; exists {
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &o.Ephemeral)
diags = append(diags, valDiags...)
o.EphemeralSet = true
}
if attr, exists := content.Attributes["depends_on"]; exists {
deps, depsDiags := decodeDependsOn(attr)
diags = append(diags, depsDiags...)
@ -543,6 +489,9 @@ var variableBlockSchema = &hcl.BodySchema{
{
Name: "sensitive",
},
{
Name: "ephemeral",
},
{
Name: "nullable",
},
@ -569,9 +518,38 @@ var outputBlockSchema = &hcl.BodySchema{
{
Name: "sensitive",
},
{
Name: "ephemeral",
},
},
Blocks: []hcl.BlockHeaderSchema{
{Type: "precondition"},
{Type: "postcondition"},
},
}
func checkVariableValidationBlock(varName string, vv *CheckRule) hcl.Diagnostics {
var diags hcl.Diagnostics
if vv.Condition != nil {
// The validation condition must include a reference to the variable itself
for _, traversal := range vv.Condition.Variables() {
ref, moreDiags := addrs.ParseRef(traversal)
if !moreDiags.HasErrors() {
if addr, ok := ref.Subject.(addrs.InputVariable); ok {
if addr.Name == varName {
return nil
}
}
}
}
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid variable validation condition",
Detail: fmt.Sprintf("The condition for variable %q must refer to var.%s in order to test incoming values.", varName, varName),
Subject: vv.Condition.Range().Ptr(),
})
}
return nil
}

View file

@ -121,3 +121,10 @@ func (p *Parser) ForceFileSource(filename string, src []byte) {
func (p *Parser) AllowLanguageExperiments(allowed bool) {
p.allowExperiments = allowed
}
// AllowsLanguageExperiments returns the value most recently passed to
// [Parser.AllowLanguageExperiments], or false if that method has not been
// called on this object.
func (p *Parser) AllowsLanguageExperiments() bool {
return p.allowExperiments
}

View file

@ -147,7 +147,7 @@ func parseConfigFile(body hcl.Body, diags hcl.Diagnostics, override, allowExperi
})
case "provider":
cfg, cfgDiags := decodeProviderBlock(block)
cfg, cfgDiags := decodeProviderBlock(block, false)
diags = append(diags, cfgDiags...)
if cfg != nil {
file.ProviderConfigs = append(file.ProviderConfigs, cfg)

View file

@ -192,18 +192,27 @@ func TestParserLoadConfigFileWarning(t *testing.T) {
sc := bufio.NewScanner(bytes.NewReader(src))
wantWarnings := make(map[int]string)
lineNum := 1
allowExperiments := false
for sc.Scan() {
lineText := sc.Text()
if idx := strings.Index(lineText, marker); idx != -1 {
summaryText := lineText[idx+len(marker):]
wantWarnings[lineNum] = summaryText
}
if lineText == "# ALLOW-LANGUAGE-EXPERIMENTS" {
allowExperiments = true
}
lineNum++
}
parser := testParser(map[string]string{
name: string(src),
})
// Some inputs use a special comment to request that they be
// permitted to use language experiments. We typically use that
// to test that the experiment opt-in is working and is causing
// the expected "you are using experimental features" warning.
parser.AllowLanguageExperiments(allowExperiments)
_, diags := parser.LoadConfigFile(name)
if diags.HasErrors() {

View file

@ -48,7 +48,7 @@ type Provider struct {
MockDataExternalSource string
}
func decodeProviderBlock(block *hcl.Block) (*Provider, hcl.Diagnostics) {
func decodeProviderBlock(block *hcl.Block, testFile bool) (*Provider, hcl.Diagnostics) {
var diags hcl.Diagnostics
content, config, moreDiags := block.Body.PartialContent(providerBlockSchema)
@ -92,15 +92,24 @@ func decodeProviderBlock(block *hcl.Block) (*Provider, hcl.Diagnostics) {
}
if attr, exists := content.Attributes["version"]; exists {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Version constraints inside provider configuration blocks are deprecated",
Detail: "Terraform 0.13 and earlier allowed provider version constraints inside the provider configuration block, but that is now deprecated and will be removed in a future version of Terraform. To silence this warning, move the provider version constraint into the required_providers block.",
Subject: attr.Expr.Range().Ptr(),
})
var versionDiags hcl.Diagnostics
provider.Version, versionDiags = decodeVersionConstraint(attr)
diags = append(diags, versionDiags...)
if testFile {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Version constraints are not allowed in test files",
Detail: "Version constraints inside provider configuration blocks are not allowed in test files. To silence this error, move the provider version constraint into the required_providers block of the configuration that uses this provider.",
Subject: attr.Expr.Range().Ptr(),
})
} else {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Version constraints inside provider configuration blocks are deprecated",
Detail: "Terraform 0.13 and earlier allowed provider version constraints inside the provider configuration block, but that is now deprecated and will be removed in a future version of Terraform. To silence this warning, move the provider version constraint into the required_providers block.",
Subject: attr.Expr.Range().Ptr(),
})
var versionDiags hcl.Diagnostics
provider.Version, versionDiags = decodeVersionConstraint(attr)
diags = append(diags, versionDiags...)
}
}
// Reserved attribute names

View file

@ -4,6 +4,8 @@
package configs
import (
"fmt"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/hcl/v2"
@ -19,6 +21,12 @@ type Removed struct {
// from state. Defaults to true.
Destroy bool
// Managed captures a number of metadata fields that are applicable only
// for managed resources, and not for other resource modes.
//
// "removed" blocks support only a subset of the fields in [ManagedResource].
Managed *ManagedResource
DeclRange hcl.Range
}
@ -31,6 +39,8 @@ func decodeRemovedBlock(block *hcl.Block) (*Removed, hcl.Diagnostics) {
content, moreDiags := block.Body.Content(removedBlockSchema)
diags = append(diags, moreDiags...)
var targetKind addrs.RemoveTargetKind
var resourceMode addrs.ResourceMode // only valid if targetKind is addrs.RemoveTargetResource
if attr, exists := content.Attributes["from"]; exists {
from, traversalDiags := hcl.AbsTraversalForExpr(attr.Expr)
diags = append(diags, traversalDiags...)
@ -38,11 +48,21 @@ func decodeRemovedBlock(block *hcl.Block) (*Removed, hcl.Diagnostics) {
from, fromDiags := addrs.ParseRemoveTarget(from)
diags = append(diags, fromDiags.ToHCL()...)
removed.From = from
if removed.From != nil {
targetKind = removed.From.ObjectKind()
if targetKind == addrs.RemoveTargetResource {
resourceMode = removed.From.RelSubject.(addrs.ConfigResource).Resource.Mode
}
}
}
}
removed.Destroy = true
if resourceMode == addrs.ManagedResourceMode {
removed.Managed = &ManagedResource{}
}
var seenConnection *hcl.Block
for _, block := range content.Blocks {
switch block.Type {
case "lifecycle":
@ -53,6 +73,61 @@ func decodeRemovedBlock(block *hcl.Block) (*Removed, hcl.Diagnostics) {
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &removed.Destroy)
diags = append(diags, valDiags...)
}
case "connection":
if removed.Managed == nil {
// target is not a managed resource, then
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid connection block",
Detail: "Provisioner connection configuration is valid only when a removed block targets a managed resource.",
Subject: &block.DefRange,
})
continue
}
if seenConnection != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate connection block",
Detail: fmt.Sprintf("This \"removed\" block already has a connection block at %s.", seenConnection.DefRange),
Subject: &block.DefRange,
})
continue
}
seenConnection = block
removed.Managed.Connection = &Connection{
Config: block.Body,
DeclRange: block.DefRange,
}
case "provisioner":
if removed.Managed == nil {
// target is not a managed resource, then
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid provisioner block",
Detail: "Provisioners are valid only when a removed block targets a managed resource.",
Subject: &block.DefRange,
})
continue
}
pv, pvDiags := decodeProvisionerBlock(block)
diags = append(diags, pvDiags...)
if pv != nil {
removed.Managed.Provisioners = append(removed.Managed.Provisioners, pv)
if pv.When != ProvisionerWhenDestroy {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid provisioner block",
Detail: "Only destroy-time provisioners are valid in \"removed\" blocks. To declare a destroy-time provisioner, use:\n when = destroy",
Subject: &block.DefRange,
})
}
}
}
}
@ -67,9 +142,9 @@ var removedBlockSchema = &hcl.BodySchema{
},
},
Blocks: []hcl.BlockHeaderSchema{
{
Type: "lifecycle",
},
{Type: "lifecycle"},
{Type: "connection"},
{Type: "provisioner", LabelNames: []string{"type"}},
},
}

View file

@ -60,6 +60,7 @@ func TestRemovedBlock_decode(t *testing.T) {
&Removed{
From: mustRemoveEndpointFromExpr(foo_expr),
Destroy: true,
Managed: &ManagedResource{},
DeclRange: blockRange,
},
``,
@ -93,10 +94,155 @@ func TestRemovedBlock_decode(t *testing.T) {
&Removed{
From: mustRemoveEndpointFromExpr(foo_expr),
Destroy: false,
Managed: &ManagedResource{},
DeclRange: blockRange,
},
``,
},
"provisioner when = destroy": {
&hcl.Block{
Type: "removed",
Body: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcl.Attributes{
"from": {
Name: "from",
Expr: foo_expr,
},
},
Blocks: hcl.Blocks{
&hcl.Block{
Type: "provisioner",
Labels: []string{"remote-exec"},
LabelRanges: []hcl.Range{{}},
Body: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcl.Attributes{
"when": {
Name: "when",
Expr: hcltest.MockExprTraversalSrc("destroy"),
},
},
}),
},
},
}),
DefRange: blockRange,
},
&Removed{
From: mustRemoveEndpointFromExpr(foo_expr),
Destroy: true,
Managed: &ManagedResource{
Provisioners: []*Provisioner{
{
Type: "remote-exec",
Config: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcl.Attributes{},
Blocks: hcl.Blocks{},
}),
When: ProvisionerWhenDestroy,
OnFailure: ProvisionerOnFailureFail,
},
},
},
DeclRange: blockRange,
},
``,
},
"provisioner when = create": {
&hcl.Block{
Type: "removed",
Body: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcl.Attributes{
"from": {
Name: "from",
Expr: foo_expr,
},
},
Blocks: hcl.Blocks{
&hcl.Block{
Type: "provisioner",
Labels: []string{"local-exec"},
LabelRanges: []hcl.Range{{}},
Body: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcl.Attributes{
"when": {
Name: "when",
Expr: hcltest.MockExprTraversalSrc("create"),
},
},
}),
},
},
}),
DefRange: blockRange,
},
&Removed{
From: mustRemoveEndpointFromExpr(foo_expr),
Destroy: true,
Managed: &ManagedResource{
Provisioners: []*Provisioner{
{
Type: "local-exec",
Config: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcl.Attributes{},
Blocks: hcl.Blocks{},
}),
When: ProvisionerWhenCreate,
OnFailure: ProvisionerOnFailureFail,
},
},
},
DeclRange: blockRange,
},
`Invalid provisioner block`,
},
"provisioner no when": {
&hcl.Block{
Type: "removed",
Body: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcl.Attributes{
"from": {
Name: "from",
Expr: foo_expr,
},
},
Blocks: hcl.Blocks{
&hcl.Block{
Type: "connection",
Body: hcltest.MockBody(&hcl.BodyContent{}),
},
&hcl.Block{
Type: "provisioner",
Labels: []string{"local-exec"},
LabelRanges: []hcl.Range{{}},
Body: hcltest.MockBody(&hcl.BodyContent{}),
},
},
}),
DefRange: blockRange,
},
&Removed{
From: mustRemoveEndpointFromExpr(foo_expr),
Destroy: true,
Managed: &ManagedResource{
Connection: &Connection{
Config: hcltest.MockBody(&hcl.BodyContent{}),
},
Provisioners: []*Provisioner{
{
Type: "local-exec",
Config: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcl.Attributes{},
Blocks: hcl.Blocks{},
}),
When: ProvisionerWhenCreate,
OnFailure: ProvisionerOnFailureFail,
},
},
},
DeclRange: blockRange,
},
`Invalid provisioner block`,
},
"modules": {
&hcl.Block{
Type: "removed",
@ -130,6 +276,67 @@ func TestRemovedBlock_decode(t *testing.T) {
},
``,
},
"provisioner for module": {
&hcl.Block{
Type: "removed",
Body: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcl.Attributes{
"from": {
Name: "from",
Expr: mod_foo_expr,
},
},
Blocks: hcl.Blocks{
&hcl.Block{
Type: "provisioner",
Labels: []string{"local-exec"},
LabelRanges: []hcl.Range{{}},
Body: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcl.Attributes{
"when": {
Name: "when",
Expr: hcltest.MockExprTraversalSrc("destroy"),
},
},
}),
},
},
}),
DefRange: blockRange,
},
&Removed{
From: mustRemoveEndpointFromExpr(mod_foo_expr),
Destroy: true,
DeclRange: blockRange,
},
`Invalid provisioner block`,
},
"connection for module": {
&hcl.Block{
Type: "removed",
Body: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcl.Attributes{
"from": {
Name: "from",
Expr: mod_foo_expr,
},
},
Blocks: hcl.Blocks{
&hcl.Block{
Type: "connection",
Body: hcltest.MockBody(&hcl.BodyContent{}),
},
},
}),
DefRange: blockRange,
},
&Removed{
From: mustRemoveEndpointFromExpr(mod_foo_expr),
Destroy: true,
DeclRange: blockRange,
},
`Invalid connection block`,
},
// KEM Unspecified behaviour
"no lifecycle block": {
&hcl.Block{
@ -147,6 +354,7 @@ func TestRemovedBlock_decode(t *testing.T) {
&Removed{
From: mustRemoveEndpointFromExpr(foo_expr),
Destroy: true,
Managed: &ManagedResource{},
DeclRange: blockRange,
},
``,

View file

@ -58,7 +58,16 @@ func (p *SourceBundleParser) LoadConfigDir(source sourceaddrs.FinalSource) (*Mod
mod, modDiags := NewModule(primary, override)
diags = append(diags, modDiags...)
mod.SourceDir = source.String()
sourceDir, err := p.sources.LocalPathForSource(source)
if err != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Cannot find configuration source code",
Detail: fmt.Sprintf("Failed to load %s from the pre-installed source packages: %s. This is a bug in Terraform - please report it.", source, err),
})
return nil, diags
}
mod.SourceDir = sourceDir
return mod, diags
}

View file

@ -337,7 +337,7 @@ func loadTestFile(body hcl.Body) (*TestFile, hcl.Diagnostics) {
tf.Variables[v.Name] = v.Expr
}
case "provider":
provider, providerDiags := decodeProviderBlock(block)
provider, providerDiags := decodeProviderBlock(block, true)
diags = append(diags, providerDiags...)
if provider != nil {
key := provider.moduleUniqueKey()

View file

@ -0,0 +1,10 @@
locals {
something = "else"
}
variable "validation" {
validation {
condition = local.something == "else"
error_message = "Something else."
}
}

View file

@ -1,8 +1,6 @@
# There is no provider in required_providers called "configured", so the version
# constraint should come from this configuration block.
provider "configured" {
version = "~> 1.4"
}
# There is no provider in required_providers called "configured", so we won't
# have a version constraint for it.
provider "configured" {}
run "setup" {
module {

View file

@ -6,3 +6,5 @@ terraform {
}
}
}
resource "configured_resource" "resource" {}

View file

@ -5,7 +5,7 @@ locals {
variable "validation" {
validation {
condition = local.foo == var.validation # ERROR: Invalid reference in variable validation
condition = local.foo == var.validation
error_message = "Must be five."
}
}
@ -13,6 +13,6 @@ variable "validation" {
variable "validation_error_expression" {
validation {
condition = var.validation_error_expression != 1
error_message = "Cannot equal ${local.foo}." # ERROR: Invalid reference in variable validation
error_message = "Cannot equal ${local.foo}."
}
}

View file

@ -0,0 +1,21 @@
# ALLOW-LANGUAGE-EXPERIMENTS
# If the ephemeral_values features get stabilized, this test input will fail
# due to the experiment being concluded, in which case it might make sense to
# move this file to valid-files and remove the experiment opt-in
#
# If this experiment is removed without stabilizing it then this will fail
# and should be removed altogether.
terraform {
experiments = [ephemeral_values] # WARNING: Experimental feature "ephemeral_values" is active
}
variable "in" {
ephemeral = true
}
output "out" {
ephemeral = true
value = var.in
}

View file

@ -30,14 +30,21 @@ func (g *AcyclicGraph) DirectedGraph() Grapher {
// Returns a Set that includes every Vertex yielded by walking down from the
// provided starting Vertex v.
func (g *AcyclicGraph) Ancestors(v Vertex) (Set, error) {
func (g *AcyclicGraph) Ancestors(vs ...Vertex) (Set, error) {
s := make(Set)
memoFunc := func(v Vertex, d int) error {
s.Add(v)
return nil
}
if err := g.DepthFirstWalk(g.downEdgesNoCopy(v), memoFunc); err != nil {
start := make(Set)
for _, v := range vs {
for _, dep := range g.downEdgesNoCopy(v) {
start.Add(dep)
}
}
if err := g.DepthFirstWalk(start, memoFunc); err != nil {
return nil, err
}
@ -46,14 +53,21 @@ func (g *AcyclicGraph) Ancestors(v Vertex) (Set, error) {
// Returns a Set that includes every Vertex yielded by walking up from the
// provided starting Vertex v.
func (g *AcyclicGraph) Descendents(v Vertex) (Set, error) {
func (g *AcyclicGraph) Descendents(vs ...Vertex) (Set, error) {
s := make(Set)
memoFunc := func(v Vertex, d int) error {
s.Add(v)
return nil
}
if err := g.ReverseDepthFirstWalk(g.upEdgesNoCopy(v), memoFunc); err != nil {
start := make(Set)
for _, v := range vs {
for _, dep := range g.upEdgesNoCopy(v) {
start.Add(dep)
}
}
if err := g.ReverseDepthFirstWalk(start, memoFunc); err != nil {
return nil, err
}

View file

@ -20,8 +20,10 @@ const (
VariableValidationCrossRef = Experiment("variable_validation_crossref")
ModuleVariableOptionalAttrs = Experiment("module_variable_optional_attrs")
SuppressProviderSensitiveAttrs = Experiment("provider_sensitive_attrs")
TemplateStringFunc = Experiment("template_string_func")
ConfigDrivenMove = Experiment("config_driven_move")
PreconditionsPostconditions = Experiment("preconditions_postconditions")
EphemeralValues = Experiment("ephemeral_values")
UnknownInstances = Experiment("unknown_instances")
)
@ -30,10 +32,12 @@ func init() {
// a current or a concluded experiment.
registerConcludedExperiment(UnknownInstances, "Unknown instances are being rolled into a larger feature for deferring unready resources and modules.")
registerConcludedExperiment(VariableValidation, "Custom variable validation can now be used by default, without enabling an experiment.")
registerCurrentExperiment(VariableValidationCrossRef)
registerConcludedExperiment(VariableValidationCrossRef, "Input variable validation rules may now refer to other objects in the same module without enabling any experiment.")
registerConcludedExperiment(SuppressProviderSensitiveAttrs, "Provider-defined sensitive attributes are now redacted by default, without enabling an experiment.")
registerConcludedExperiment(TemplateStringFunc, "The templatestring function can now be used without enabling an experiment.")
registerConcludedExperiment(ConfigDrivenMove, "Declarations of moved resource instances using \"moved\" blocks can now be used by default, without enabling an experiment.")
registerConcludedExperiment(PreconditionsPostconditions, "Condition blocks can now be used by default, without enabling an experiment.")
registerCurrentExperiment(EphemeralValues)
registerConcludedExperiment(ModuleVariableOptionalAttrs, "The final feature corresponding to this experiment differs from the experimental form and is available in the Terraform language from Terraform v1.3.0 onwards.")
}

View file

@ -0,0 +1,13 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
// Package ephemeral contains helper functions for working with values that
// might have ephemeral parts.
//
// "Ephemeral" in this context means that a value is preserved only in memory
// for no longer than the duration of a single Terraform phase, and is not
// persisted as part of longer-lived artifacts such as state snapshots and
// saved plan files. Because ephemeral values cannot be persisted, they can
// be used only as part of the configuration of objects that are ephemeral
// themselves, such as provider configurations and provisioners.
package ephemeral

View file

@ -0,0 +1,18 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package ephemeral
import (
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/lang/marks"
)
// EphemeralValuePaths returns the paths within the given value that are
// marked as ephemeral, if any.
func EphemeralValuePaths(v cty.Value) []cty.Path {
_, pvms := v.UnmarkDeepWithPaths()
ret, _ := marks.PathsWithMark(pvms, marks.Ephemeral)
return ret
}

View file

@ -0,0 +1,42 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package ephemeral
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform/internal/lang/marks"
"github.com/zclconf/go-cty/cty"
)
func TestEphemeralValuePaths(t *testing.T) {
// This test is intentionally not a thorough wringing of all possible cases
// because EphemeralValuePaths is really just a thing wrapper around a
// more general function in package marks, and that function already has
// its own tests. That also in turn wraps a more-general-again function in
// upstream cty that also has its own tests.
v := cty.ObjectVal(map[string]cty.Value{
"unmarked": cty.StringVal("unmarked"),
"sensitive": cty.StringVal("sensitive").Mark(marks.Sensitive),
"ephemeral": cty.StringVal("ephemeral").Mark(marks.Ephemeral),
"both": cty.StringVal("both").Mark(marks.Ephemeral).Mark(marks.Sensitive),
"nested": cty.ListVal([]cty.Value{
cty.StringVal("unmarked"),
cty.StringVal("sensitive").Mark(marks.Sensitive),
cty.StringVal("ephemeral").Mark(marks.Ephemeral),
cty.StringVal("both").Mark(marks.Ephemeral).Mark(marks.Sensitive),
}),
})
got := cty.NewPathSet(EphemeralValuePaths(v)...)
want := cty.NewPathSet(
cty.GetAttrPath("ephemeral"),
cty.GetAttrPath("both"),
cty.GetAttrPath("nested").IndexInt(2),
cty.GetAttrPath("nested").IndexInt(3),
)
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("wrong result\n%s", diff)
}
}

View file

@ -420,6 +420,13 @@ var DescriptionList = map[string]descriptionEntry{
Description: "`templatefile` reads the file at the given path and renders its content as a template using a supplied set of template variables.",
ParamDescription: []string{"", ""},
},
"templatestring": {
Description: "`templatestring` takes a string from elsewhere in the module and renders its content as a template using a supplied set of template variables.",
ParamDescription: []string{
"A simple reference to a string value containing the template source code.",
"Object of variables to expose in the template scope.",
},
},
"textdecodebase64": {
Description: "`textdecodebase64` function decodes a string that was previously Base64-encoded, and then interprets the result as characters in a specified character encoding.",
ParamDescription: []string{"", ""},

View file

@ -6,7 +6,7 @@ package funcs
import (
"encoding/base64"
"fmt"
"io/ioutil"
"io"
"os"
"path/filepath"
"unicode/utf8"
@ -17,6 +17,8 @@ import (
homedir "github.com/mitchellh/go-homedir"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
"github.com/hashicorp/terraform/internal/collections"
)
// MakeFileFunc constructs a function that takes a file path and returns the
@ -69,95 +71,38 @@ func MakeFileFunc(baseDir string, encBase64 bool) function.Function {
// As a special exception, a referenced template file may not recursively call
// the templatefile function, since that would risk the same file being
// included into itself indefinitely.
func MakeTemplateFileFunc(baseDir string, funcsCb func() map[string]function.Function) function.Function {
params := []function.Parameter{
{
Name: "path",
Type: cty.String,
AllowMarked: true,
},
{
Name: "vars",
Type: cty.DynamicPseudoType,
},
}
loadTmpl := func(fn string, marks cty.ValueMarks) (hcl.Expression, error) {
func MakeTemplateFileFunc(baseDir string, funcsCb func() (funcs map[string]function.Function, fsFuncs collections.Set[string], templateFuncs collections.Set[string])) function.Function {
loadTmpl := func(fn string, marks cty.ValueMarks) (hcl.Expression, cty.ValueMarks, error) {
// We re-use File here to ensure the same filename interpretation
// as it does, along with its other safety checks.
tmplVal, err := File(baseDir, cty.StringVal(fn).WithMarks(marks))
if err != nil {
return nil, err
return nil, nil, err
}
tmplVal, marks = tmplVal.Unmark()
expr, diags := hclsyntax.ParseTemplate([]byte(tmplVal.AsString()), fn, hcl.Pos{Line: 1, Column: 1})
if diags.HasErrors() {
return nil, diags
return nil, nil, diags
}
return expr, nil
return expr, marks, nil
}
renderTmpl := func(expr hcl.Expression, varsVal cty.Value) (cty.Value, error) {
if varsTy := varsVal.Type(); !(varsTy.IsMapType() || varsTy.IsObjectType()) {
return cty.DynamicVal, function.NewArgErrorf(1, "invalid vars value: must be a map") // or an object, but we don't strongly distinguish these most of the time
}
ctx := &hcl.EvalContext{
Variables: varsVal.AsValueMap(),
}
// We require all of the variables to be valid HCL identifiers, because
// otherwise there would be no way to refer to them in the template
// anyway. Rejecting this here gives better feedback to the user
// than a syntax error somewhere in the template itself.
for n := range ctx.Variables {
if !hclsyntax.ValidIdentifier(n) {
// This error message intentionally doesn't describe _all_ of
// the different permutations that are technically valid as an
// HCL identifier, but rather focuses on what we might
// consider to be an "idiomatic" variable name.
return cty.DynamicVal, function.NewArgErrorf(1, "invalid template variable name %q: must start with a letter, followed by zero or more letters, digits, and underscores", n)
}
}
// We'll pre-check references in the template here so we can give a
// more specialized error message than HCL would by default, so it's
// clearer that this problem is coming from a templatefile call.
for _, traversal := range expr.Variables() {
root := traversal.RootName()
if _, ok := ctx.Variables[root]; !ok {
return cty.DynamicVal, function.NewArgErrorf(1, "vars map does not contain key %q, referenced at %s", root, traversal[0].SourceRange())
}
}
givenFuncs := funcsCb() // this callback indirection is to avoid chicken/egg problems
funcs := make(map[string]function.Function, len(givenFuncs))
for name, fn := range givenFuncs {
if name == "templatefile" || name == "core::templatefile" {
// We stub this one out to prevent recursive calls.
funcs[name] = function.New(&function.Spec{
Params: params,
Type: func(args []cty.Value) (cty.Type, error) {
return cty.NilType, fmt.Errorf("cannot recursively call templatefile from inside templatefile call")
},
})
continue
}
funcs[name] = fn
}
ctx.Functions = funcs
val, diags := expr.Value(ctx)
if diags.HasErrors() {
return cty.DynamicVal, diags
}
return val, nil
}
renderTmpl := makeRenderTemplateFunc(funcsCb, true)
return function.New(&function.Spec{
Params: params,
Params: []function.Parameter{
{
Name: "path",
Type: cty.String,
AllowMarked: true,
},
{
Name: "vars",
Type: cty.DynamicPseudoType,
},
},
Type: func(args []cty.Value) (cty.Type, error) {
if !(args[0].IsKnown() && args[1].IsKnown()) {
return cty.DynamicPseudoType, nil
@ -168,7 +113,7 @@ func MakeTemplateFileFunc(baseDir string, funcsCb func() map[string]function.Fun
// return any type.
pathArg, pathMarks := args[0].Unmark()
expr, err := loadTmpl(pathArg.AsString(), pathMarks)
expr, _, err := loadTmpl(pathArg.AsString(), pathMarks)
if err != nil {
return cty.DynamicPseudoType, err
}
@ -180,12 +125,12 @@ func MakeTemplateFileFunc(baseDir string, funcsCb func() map[string]function.Fun
},
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
pathArg, pathMarks := args[0].Unmark()
expr, err := loadTmpl(pathArg.AsString(), pathMarks)
expr, tmplMarks, err := loadTmpl(pathArg.AsString(), pathMarks)
if err != nil {
return cty.DynamicVal, err
}
result, err := renderTmpl(expr, args[1])
return result.WithMarks(pathMarks), err
return result.WithMarks(tmplMarks), err
},
})
@ -426,7 +371,7 @@ func readFileBytes(baseDir, path string, marks cty.ValueMarks) ([]byte, error) {
}
defer f.Close()
src, err := ioutil.ReadAll(f)
src, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}

View file

@ -9,11 +9,13 @@ import (
"path/filepath"
"testing"
"github.com/hashicorp/terraform/internal/lang/marks"
homedir "github.com/mitchellh/go-homedir"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
"github.com/zclconf/go-cty/cty/function/stdlib"
"github.com/hashicorp/terraform/internal/collections"
"github.com/hashicorp/terraform/internal/lang/marks"
)
func TestFile(t *testing.T) {
@ -149,13 +151,13 @@ func TestTemplateFile(t *testing.T) {
cty.StringVal("testdata/recursive.tmpl"),
cty.MapValEmpty(cty.String),
cty.NilVal,
`testdata/recursive.tmpl:1,3-16: Error in function call; Call to function "templatefile" failed: cannot recursively call templatefile from inside templatefile call.`,
`testdata/recursive.tmpl:1,3-16: Error in function call; Call to function "templatefile" failed: cannot recursively call templatefile from inside another template function.`,
},
{
cty.StringVal("testdata/recursive_namespaced.tmpl"),
cty.MapValEmpty(cty.String),
cty.NilVal,
`testdata/recursive_namespaced.tmpl:1,3-22: Error in function call; Call to function "core::templatefile" failed: cannot recursively call templatefile from inside templatefile call.`,
`testdata/recursive_namespaced.tmpl:1,3-22: Error in function call; Call to function "core::templatefile" failed: cannot recursively call templatefile from inside another template function.`,
},
{
cty.StringVal("testdata/list.tmpl"),
@ -185,16 +187,30 @@ func TestTemplateFile(t *testing.T) {
cty.True, // since this template contains only an interpolation, its true value shines through
``,
},
{
// If the template filename is sensitive then we also treat the
// rendered result as sensitive, because the rendered result
// is likely to imply which filename was used.
// (Sensitive filenames seem pretty unlikely, but if they do
// crop up then we should handle them consistently with our
// usual sensitivity rules.)
cty.StringVal("testdata/hello.txt").Mark(marks.Sensitive),
cty.EmptyObjectVal,
cty.StringVal("Hello World").Mark(marks.Sensitive),
``,
},
}
templateFileFn := MakeTemplateFileFunc(".", func() map[string]function.Function {
return map[string]function.Function{
"join": stdlib.JoinFunc,
"core::join": stdlib.JoinFunc,
"templatefile": MakeFileFunc(".", false), // just a placeholder, since templatefile itself overrides this
"core::templatefile": MakeFileFunc(".", false), // just a placeholder, since templatefile itself overrides this
}
})
funcs := map[string]function.Function{
"join": stdlib.JoinFunc,
"core::join": stdlib.JoinFunc,
}
funcsFunc := func() (funcTable map[string]function.Function, fsFuncs collections.Set[string], templateFuncs collections.Set[string]) {
return funcs, collections.NewSetCmp[string](), collections.NewSetCmp[string]("templatefile")
}
templateFileFn := MakeTemplateFileFunc(".", funcsFunc)
funcs["templatefile"] = templateFileFn
funcs["core::templatefile"] = templateFileFn
for _, test := range tests {
t.Run(fmt.Sprintf("TemplateFile(%#v, %#v)", test.Path, test.Vars), func(t *testing.T) {

View file

@ -4,11 +4,18 @@
package funcs
import (
"fmt"
"regexp"
"strings"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/ext/customdecode"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
"github.com/zclconf/go-cty/cty/function"
"github.com/hashicorp/terraform/internal/collections"
)
// StartsWithFunc constructs a function that checks if a string starts with
@ -153,6 +160,279 @@ var StrContainsFunc = function.New(&function.Spec{
},
})
// TemplateStringFunc renders a template presented either as a literal string
// or as a reference to a string from elsewhere.
func MakeTemplateStringFunc(funcsCb func() (funcs map[string]function.Function, fsFuncs collections.Set[string], templateFuncs collections.Set[string])) function.Function {
return function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "template",
Type: customdecode.ExpressionClosureType,
},
{
Name: "vars",
Type: cty.DynamicPseudoType,
},
},
Type: function.StaticReturnType(cty.String),
RefineResult: refineNotNull,
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
templateClosure := customdecode.ExpressionClosureFromVal(args[0])
varsVal := args[1]
// Our historical experience with the hashicorp/template provider's
// template_file data source tells us that situations where authors
// must write a string template that generates a string template
// cause all sorts of confusion, because the same syntax ends up
// being evaluated in two different contexts with different variables
// in scope, and new authors tend to be attracted to a function
// named "template" and so miss that the language has built-in
// support for inline template expressions.
//
// As a compromise to try to meet the (relatively unusual) use-cases
// where dynamic template fetching is needed without creating an
// attractive nuisance for those who would be better off just writing
// a plain inline template, this function imposes constraints on how
// the template argument may be provided and thus allows us
// to return slightly more helpful error messages.
//
// The only valid way to provide the template argument is as a
// simple, direct reference to some other value in scope that is
// of type string:
// templatestring(local.greeting_template, { name = "Alex" })
//
// Those with more unusual desires, such as dynamically generating
// a template at runtime by trying to concatenate template chunks
// together, can still do such things by placing the template
// construction expression in a separate local value and then passing
// that local value to the template argument. But the restriction is
// intended to intentionally add an extra "roadbump" so that
// anyone who mistakenly thinks they need templatestring to render
// an inline template (a common mistake for new authors with
// template_file) will hopefully hit this roadblock and refer to
// the function documentation to learn about the other options that
// are probably more suitable for what they need.
switch expr := templateClosure.Expression.(type) {
case *hclsyntax.TemplateWrapExpr:
// This situation occurs when someone writes an interpolation-only
// expression as was required in Terraform v0.11 and earlier.
// Because older versions of Terraform required this and this
// habit has been sticky for some authors, we'll return a
// special error message.
return cty.UnknownVal(retType), function.NewArgErrorf(
0, "invalid template expression: templatestring is only for rendering templates retrieved dynamically from elsewhere; to treat the inner expression as template syntax, write the reference expression directly without any template interpolation syntax",
)
case *hclsyntax.TemplateExpr:
// This is the more general case of someone trying to write
// an inline template as the argument. In this case we'll
// distinguish between an entirely-literal template, which
// probably suggests someone was trying to escape their template
// for the function to consume, vs. a template with other
// sequences that suggests someone was just trying to write
// an inline template and so probably doesn't need to call
// this function at all.
literal := true
if len(expr.Parts) != 1 {
literal = false
} else if _, ok := expr.Parts[0].(*hclsyntax.LiteralValueExpr); !ok {
literal = false
}
if literal {
return cty.UnknownVal(retType), function.NewArgErrorf(
0, "invalid template expression: templatestring is only for rendering templates retrieved dynamically from elsewhere, and so does not support providing a literal template; consider using a template string expression instead",
)
} else {
return cty.UnknownVal(retType), function.NewArgErrorf(
0, "invalid template expression: templatestring is only for rendering templates retrieved dynamically from elsewhere; to render an inline template, consider using a plain template string expression",
)
}
default:
if !isValidTemplateStringExpr(expr) {
// Someone who really does want to construct a template dynamically
// can factor out that construction into a local value and refer
// to it in the templatestring call, but it's not really feasible
// to explain that clearly in a short error message so we'll deal
// with that option on the function's documentation page instead,
// where we can show a full example.
return cty.UnknownVal(retType), function.NewArgErrorf(
0, "invalid template expression: must be a direct reference to a single string from elsewhere, containing valid Terraform template syntax",
)
}
}
templateVal, diags := templateClosure.Value()
if diags.HasErrors() {
// With the constraints we imposed above the possible errors
// here are pretty limited: it must be some kind of invalid
// traversal. As usual HCL diagnostics don't make for very
// good function errors but we've already filtered out many
// common reasons for error here, so we should get here pretty
// rarely.
return cty.UnknownVal(retType), function.NewArgErrorf(
0, "invalid template expression: %s",
diags.Error(),
)
}
if !templateVal.IsKnown() {
// We'll need to wait until we actually know what the template is.
return cty.UnknownVal(retType), nil
}
if templateVal.Type() != cty.String || templateVal.IsNull() {
// We're being a little stricter than usual here and requiring
// exactly a string, rather than just anything that can convert
// to one. This is because the stringification of a number or
// boolean value cannot be a useful template (it wouldn't have
// any template sequences in it) and so far more likely to be
// a mistake than actually intentional.
return cty.UnknownVal(retType), function.NewArgErrorf(
0, "invalid template value: a string is required",
)
}
templateVal, templateMarks := templateVal.Unmark()
templateStr := templateVal.AsString()
expr, diags := hclsyntax.ParseTemplate([]byte(templateStr), "<templatestring argument>", hcl.Pos{Line: 1, Column: 1})
if diags.HasErrors() {
return cty.UnknownVal(retType), function.NewArgErrorf(
0, "invalid template: %s",
diags.Error(),
)
}
render := makeRenderTemplateFunc(funcsCb, false)
retVal, err := render(expr, varsVal)
if err != nil {
return cty.UnknownVal(retType), err
}
retVal, err = convert.Convert(retVal, cty.String)
if err != nil {
return cty.UnknownVal(retType), fmt.Errorf("invalid template result: %s", err)
}
return retVal.WithMarks(templateMarks), nil
},
})
}
func makeRenderTemplateFunc(funcsCb func() (funcs map[string]function.Function, fsFuncs collections.Set[string], templateFuncs collections.Set[string]), allowFS bool) func(expr hcl.Expression, varsVal cty.Value) (cty.Value, error) {
return func(expr hcl.Expression, varsVal cty.Value) (cty.Value, error) {
if varsTy := varsVal.Type(); !(varsTy.IsMapType() || varsTy.IsObjectType()) {
return cty.DynamicVal, function.NewArgErrorf(1, "invalid vars value: must be a map") // or an object, but we don't strongly distinguish these most of the time
}
ctx := &hcl.EvalContext{
Variables: varsVal.AsValueMap(),
}
// We require all of the variables to be valid HCL identifiers, because
// otherwise there would be no way to refer to them in the template
// anyway. Rejecting this here gives better feedback to the user
// than a syntax error somewhere in the template itself.
for n := range ctx.Variables {
if !hclsyntax.ValidIdentifier(n) {
// This error message intentionally doesn't describe _all_ of
// the different permutations that are technically valid as an
// HCL identifier, but rather focuses on what we might
// consider to be an "idiomatic" variable name.
return cty.DynamicVal, function.NewArgErrorf(1, "invalid template variable name %q: must start with a letter, followed by zero or more letters, digits, and underscores", n)
}
}
// We'll pre-check references in the template here so we can give a
// more specialized error message than HCL would by default, so it's
// clearer that this problem is coming from a templatefile call.
for _, traversal := range expr.Variables() {
root := traversal.RootName()
if _, ok := ctx.Variables[root]; !ok {
return cty.DynamicVal, function.NewArgErrorf(1, "vars map does not contain key %q, referenced at %s", root, traversal[0].SourceRange())
}
}
givenFuncs, fsFuncs, templateFuncs := funcsCb() // this callback indirection is to avoid chicken/egg problems
funcs := make(map[string]function.Function, len(givenFuncs))
for name, fn := range givenFuncs {
plainName := strings.TrimPrefix(name, "core::")
switch {
case templateFuncs.Has(plainName):
funcs[name] = function.New(&function.Spec{
Params: fn.Params(),
VarParam: fn.VarParam(),
Type: func(args []cty.Value) (cty.Type, error) {
return cty.NilType, fmt.Errorf("cannot recursively call %s from inside another template function", plainName)
},
})
case !allowFS && fsFuncs.Has(plainName):
// Note: for now this assumes that allowFS is false only for
// the templatestring function, and so mentions that name
// directly in the error message.
funcs[name] = function.New(&function.Spec{
Params: fn.Params(),
VarParam: fn.VarParam(),
Type: func(args []cty.Value) (cty.Type, error) {
return cty.NilType, fmt.Errorf("cannot use filesystem access functions like %s in templatestring templates; consider passing the function result as a template variable instead", plainName)
},
})
default:
funcs[name] = fn
}
}
ctx.Functions = funcs
val, diags := expr.Value(ctx)
if diags.HasErrors() {
return cty.DynamicVal, diags
}
return val, nil
}
}
func isValidTemplateStringExpr(expr hcl.Expression) bool {
// Our goal with this heuristic is to be as permissive as possible with
// allowing things that authors might try to use as references to a
// template string defined elsewhere, while rejecting complex expressions
// that seem like they might be trying to construct templates dynamically
// or might have resulted from a misunderstanding that "templatestring" is
// the only way to render a template, because someone hasn't learned
// about template expressions yet.
//
// This is here only to give better feedback to folks who seem to be using
// templatestring for something other than what it's intended for, and not
// to block dynamic template generation altogether. Authors who have a
// genuine need for dynamic template generation can always assert that to
// Terraform by factoring out their dynamic generation into a local value
// and referring to it; this rule is just a little speedbump to prompt
// the author to consider whether there's a better way to solve their
// problem, as opposed to just using the first solution they found.
switch expr := expr.(type) {
case *hclsyntax.ScopeTraversalExpr:
// A simple static reference from the current scope is always valid.
return true
case *hclsyntax.RelativeTraversalExpr:
// Relative traversals are allowed as long as they begin from
// something that would otherwise be allowed.
return isValidTemplateStringExpr(expr.Source)
case *hclsyntax.IndexExpr:
// Index expressions are allowed as long as the collection is
// also specified using an expression that conforms to these rules.
// The key operand is intentionally unconstrained because that
// is a rule for how to select an element, and so doesn't represent
// a source from which the template string is being retrieved.
return isValidTemplateStringExpr(expr.Collection)
case *hclsyntax.SplatExpr:
// Splat expressions would be weird to use because they'd typically
// return a tuple and that wouldn't be valid as a template string,
// but we allow it here (as long as the operand would otherwise have
// been allowed) because then we'll let the type mismatch error
// show through, and that's likely a more helpful error message.
return isValidTemplateStringExpr(expr.Source)
default:
// Nothing else is allowed.
return false
}
}
// Replace searches a given string for another given substring,
// and replaces all occurences with a given replacement string.
func Replace(str, substr, replace cty.Value) (cty.Value, error) {

View file

@ -5,9 +5,16 @@ package funcs
import (
"fmt"
"strings"
"testing"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/ext/customdecode"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
"github.com/hashicorp/terraform/internal/collections"
)
func TestReplace(t *testing.T) {
@ -253,3 +260,355 @@ func TestStartsWith(t *testing.T) {
})
}
}
func TestTemplateString(t *testing.T) {
// This function has some special restrictions on what syntax is valid
// in its first argument, so we'll test this one using HCL expressions
// as the inputs, rather than direct cty values as we do for most other
// functions in this package.
tests := []struct {
templateExpr string
exprScope map[string]cty.Value
vars cty.Value
want cty.Value
wantErr string
}{
{
`template`,
map[string]cty.Value{
"template": cty.StringVal(`it's ${a}`),
},
cty.ObjectVal(map[string]cty.Value{
"a": cty.StringVal("a value"),
}),
cty.StringVal(`it's a value`),
``,
},
{
`template`,
map[string]cty.Value{
"template": cty.StringVal(`${a}`),
},
cty.ObjectVal(map[string]cty.Value{
"a": cty.True,
}),
// The special treatment of a template with only a single
// interpolation sequence does not apply to templatestring, because
// we're expecting to be evaluating templates fetched dynamically
// from somewhere else and want to avoid callers needing to deal
// with anything other than string results.
cty.StringVal(`true`),
``,
},
{
`template`,
map[string]cty.Value{
"template": cty.StringVal(`${a}`),
},
cty.ObjectVal(map[string]cty.Value{
"a": cty.EmptyTupleVal,
}),
// The special treatment of a template with only a single
// interpolation sequence does not apply to templatestring, because
// we're expecting to be evaluating templates fetched dynamically
// from somewhere else and want to avoid callers needing to deal
// with anything other than string results.
cty.NilVal,
`invalid template result: string required`,
},
{
`data.whatever.whatever["foo"].result`,
map[string]cty.Value{
"data": cty.ObjectVal(map[string]cty.Value{
"whatever": cty.ObjectVal(map[string]cty.Value{
"whatever": cty.MapVal(map[string]cty.Value{
"foo": cty.ObjectVal(map[string]cty.Value{
"result": cty.StringVal("it's ${a}"),
}),
}),
}),
}),
},
cty.ObjectVal(map[string]cty.Value{
"a": cty.StringVal("a value"),
}),
cty.StringVal(`it's a value`),
``,
},
{
`data.whatever.whatever[each.key].result`,
map[string]cty.Value{
"data": cty.ObjectVal(map[string]cty.Value{
"whatever": cty.ObjectVal(map[string]cty.Value{
"whatever": cty.MapVal(map[string]cty.Value{
"foo": cty.ObjectVal(map[string]cty.Value{
"result": cty.StringVal("it's ${a}"),
}),
}),
}),
}),
"each": cty.ObjectVal(map[string]cty.Value{
"key": cty.StringVal("foo"),
}),
},
cty.ObjectVal(map[string]cty.Value{
"a": cty.StringVal("a value"),
}),
cty.StringVal(`it's a value`),
``,
},
{
`data.whatever.whatever[*].result`,
map[string]cty.Value{
"data": cty.ObjectVal(map[string]cty.Value{
"whatever": cty.ObjectVal(map[string]cty.Value{
"whatever": cty.TupleVal([]cty.Value{
cty.ObjectVal(map[string]cty.Value{
"result": cty.StringVal("it's ${a}"),
}),
}),
}),
}),
"each": cty.ObjectVal(map[string]cty.Value{
"key": cty.StringVal("foo"),
}),
},
cty.ObjectVal(map[string]cty.Value{
"a": cty.StringVal("a value"),
}),
cty.NilVal,
// We have an intentional hole in our heuristic for whether the
// first argument is a suitable expression which permits splat
// expressions just so that we can return the type mismatch error
// from the result not being a string, instead of the more general
// error about it not being a supported expression type.
`invalid template value: a string is required`,
},
{
`"can't write $${not_allowed}"`,
map[string]cty.Value{},
cty.ObjectVal(map[string]cty.Value{
"not_allowed": cty.StringVal("a literal template"),
}),
cty.NilVal,
`invalid template expression: templatestring is only for rendering templates retrieved dynamically from elsewhere, and so does not support providing a literal template; consider using a template string expression instead`,
},
{
`"can't write ${not_allowed}"`,
map[string]cty.Value{},
cty.ObjectVal(map[string]cty.Value{
"not_allowed": cty.StringVal("a literal template"),
}),
cty.NilVal,
`invalid template expression: templatestring is only for rendering templates retrieved dynamically from elsewhere; to render an inline template, consider using a plain template string expression`,
},
{
`"can't write %%{for x in things}a literal template%%{endfor}"`,
map[string]cty.Value{},
cty.ObjectVal(map[string]cty.Value{
"things": cty.ListVal([]cty.Value{cty.True}),
}),
cty.NilVal,
`invalid template expression: templatestring is only for rendering templates retrieved dynamically from elsewhere, and so does not support providing a literal template; consider using a template string expression instead`,
},
{
`"can't write %{for x in things}a literal template%{endfor}"`,
map[string]cty.Value{},
cty.ObjectVal(map[string]cty.Value{
"things": cty.ListVal([]cty.Value{cty.True}),
}),
cty.NilVal,
`invalid template expression: templatestring is only for rendering templates retrieved dynamically from elsewhere; to render an inline template, consider using a plain template string expression`,
},
{
`"${not_allowed}"`,
map[string]cty.Value{},
cty.ObjectVal(map[string]cty.Value{
"not allowed": cty.StringVal("an interp-only template"),
}),
cty.NilVal,
`invalid template expression: templatestring is only for rendering templates retrieved dynamically from elsewhere; to treat the inner expression as template syntax, write the reference expression directly without any template interpolation syntax`,
},
{
`1 + 1`,
map[string]cty.Value{},
cty.ObjectVal(map[string]cty.Value{}),
cty.NilVal,
`invalid template expression: must be a direct reference to a single string from elsewhere, containing valid Terraform template syntax`,
},
{
`not_a_string`,
map[string]cty.Value{
"not_a_string": cty.True,
},
cty.ObjectVal(map[string]cty.Value{}),
cty.NilVal,
`invalid template value: a string is required`,
},
{
`with_lower`,
map[string]cty.Value{
"with_lower": cty.StringVal(`it's ${lower(a)}`),
},
cty.ObjectVal(map[string]cty.Value{
"a": cty.StringVal("A VALUE"),
}),
cty.StringVal("it's a value"),
``,
},
{
`with_core_lower`,
map[string]cty.Value{
"with_core_lower": cty.StringVal(`it's ${core::lower(a)}`),
},
cty.ObjectVal(map[string]cty.Value{
"a": cty.StringVal("A VALUE"),
}),
cty.StringVal("it's a value"),
``,
},
{
`with_fsfunc`,
map[string]cty.Value{
"with_fsfunc": cty.StringVal(`it's ${fsfunc()}`),
},
cty.ObjectVal(map[string]cty.Value{}),
cty.NilVal,
`<templatestring argument>:1,8-15: Error in function call; Call to function "fsfunc" failed: cannot use filesystem access functions like fsfunc in templatestring templates; consider passing the function result as a template variable instead.`,
},
{
`with_core_fsfunc`,
map[string]cty.Value{
"with_core_fsfunc": cty.StringVal(`it's ${core::fsfunc()}`),
},
cty.ObjectVal(map[string]cty.Value{}),
cty.NilVal,
`<templatestring argument>:1,8-21: Error in function call; Call to function "core::fsfunc" failed: cannot use filesystem access functions like fsfunc in templatestring templates; consider passing the function result as a template variable instead.`,
},
{
`with_templatefunc`,
map[string]cty.Value{
"with_templatefunc": cty.StringVal(`it's ${templatefunc()}`),
},
cty.ObjectVal(map[string]cty.Value{}),
cty.NilVal,
`<templatestring argument>:1,8-21: Error in function call; Call to function "templatefunc" failed: cannot recursively call templatefunc from inside another template function.`,
},
{
`with_core_templatefunc`,
map[string]cty.Value{
"with_core_templatefunc": cty.StringVal(`it's ${core::templatefunc()}`),
},
cty.ObjectVal(map[string]cty.Value{}),
cty.NilVal,
`<templatestring argument>:1,8-27: Error in function call; Call to function "core::templatefunc" failed: cannot recursively call templatefunc from inside another template function.`,
},
{
`with_fstemplatefunc`,
map[string]cty.Value{
"with_fstemplatefunc": cty.StringVal(`it's ${fstemplatefunc()}`),
},
cty.ObjectVal(map[string]cty.Value{}),
cty.NilVal,
// The template function error takes priority over the filesystem
// function error if calling a function that's in both categories.
`<templatestring argument>:1,8-23: Error in function call; Call to function "fstemplatefunc" failed: cannot recursively call fstemplatefunc from inside another template function.`,
},
{
`with_core_fstemplatefunc`,
map[string]cty.Value{
"with_core_fstemplatefunc": cty.StringVal(`it's ${core::fstemplatefunc()}`),
},
cty.ObjectVal(map[string]cty.Value{}),
cty.NilVal,
// The template function error takes priority over the filesystem
// function error if calling a function that's in both categories.
`<templatestring argument>:1,8-29: Error in function call; Call to function "core::fstemplatefunc" failed: cannot recursively call fstemplatefunc from inside another template function.`,
},
}
funcToTest := MakeTemplateStringFunc(func() (funcs map[string]function.Function, fsFuncs collections.Set[string], templateFuncs collections.Set[string]) {
// These are the functions available for use inside the nested template
// evaluation context. These are here only to test that we can call
// functions and that the template/filesystem functions get blocked
// with suitable error messages. This is not a realistic set of
// functions that would be available in a real call.
funcs = map[string]function.Function{
"lower": function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "str",
Type: cty.String,
},
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
s := args[0].AsString()
return cty.StringVal(strings.ToLower(s)), nil
},
}),
"fsfunc": function.New(&function.Spec{
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
return cty.UnknownVal(retType), fmt.Errorf("should not be able to call fsfunc")
},
}),
"templatefunc": function.New(&function.Spec{
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
return cty.UnknownVal(retType), fmt.Errorf("should not be able to call templatefunc")
},
}),
"fstemplatefunc": function.New(&function.Spec{
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
return cty.UnknownVal(retType), fmt.Errorf("should not be able to call fstemplatefunc")
},
}),
}
funcs["core::lower"] = funcs["lower"]
funcs["core::fsfunc"] = funcs["fsfunc"]
funcs["core::templatefunc"] = funcs["templatefunc"]
funcs["core::fstemplatefunc"] = funcs["fstemplatefunc"]
return funcs, collections.NewSetCmp("fsfunc", "fstemplatefunc"), collections.NewSetCmp("templatefunc", "fstemplatefunc")
})
for _, test := range tests {
t.Run(test.templateExpr, func(t *testing.T) {
// The following mimics what HCL itself would do when preparing
// the first argument to this function, since the parameter
// uses the special "expression closure type" which causes
// HCL to delay evaluation of the expression and let the
// function handle it directly itself.
expr, diags := hclsyntax.ParseExpression([]byte(test.templateExpr), "", hcl.InitialPos)
if diags.HasErrors() {
t.Fatalf("unexpected errors: %s", diags.Error())
}
exprClosure := &customdecode.ExpressionClosure{
Expression: expr,
EvalContext: &hcl.EvalContext{
Variables: test.exprScope,
},
}
exprClosureVal := customdecode.ExpressionClosureVal(exprClosure)
got, gotErr := funcToTest.Call([]cty.Value{exprClosureVal, test.vars})
if test.wantErr != "" {
if gotErr == nil {
t.Fatalf("unexpected success\ngot: %#v\nwant error: %s", got, test.wantErr)
}
if got, want := gotErr.Error(), test.wantErr; got != want {
t.Fatalf("wrong error\ngot: %s\nwant: %s", got, want)
}
return
}
if gotErr != nil {
t.Errorf("unexpected error: %s", gotErr.Error())
}
if !test.want.RawEquals(got) {
t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.want)
}
})
}
}

View file

@ -12,6 +12,7 @@ import (
"github.com/zclconf/go-cty/cty/function"
"github.com/zclconf/go-cty/cty/function/stdlib"
"github.com/hashicorp/terraform/internal/collections"
"github.com/hashicorp/terraform/internal/experiments"
"github.com/hashicorp/terraform/internal/lang/funcs"
)
@ -22,6 +23,32 @@ var impureFunctions = []string{
"uuid",
}
// filesystemFunctions are the functions that allow interacting with arbitrary
// paths in the local filesystem, and which can therefore have their results
// vary based on something other than their arguments, and might allow template
// rendering to expose details about the system where Terraform is running.
var filesystemFunctions = collections.NewSetCmp[string](
"file",
"fileexists",
"fileset",
"filebase64",
"filebase64sha256",
"filebase64sha512",
"filemd5",
"filesha1",
"filesha256",
"filesha512",
"templatefile",
)
// templateFunctions are functions that render nested templates. These are
// callable from module code but not from within the templates they are
// rendering.
var templateFunctions = collections.NewSetCmp[string](
"templatefile",
"templatestring",
)
// Functions returns the set of functions that should be used to when evaluating
// expressions in the receiving scope.
func (s *Scope) Functions() map[string]function.Function {
@ -29,6 +56,10 @@ func (s *Scope) Functions() map[string]function.Function {
if s.funcs == nil {
s.funcs = baseFunctions(s.BaseDir)
// If you're adding something here, please consider whether it meets
// the criteria for either or both of the sets [filesystemFunctions]
// and [templateFunctions] and add it there if so, to ensure that
// functions relying on those classifications will behave correctly.
coreFuncs := map[string]function.Function{
"abs": stdlib.AbsoluteFunc,
"abspath": funcs.AbsPathFunc,
@ -148,12 +179,18 @@ func (s *Scope) Functions() map[string]function.Function {
"zipmap": stdlib.ZipmapFunc,
}
coreFuncs["templatefile"] = funcs.MakeTemplateFileFunc(s.BaseDir, func() map[string]function.Function {
// The templatefile function prevents recursive calls to itself
// by copying this map and overwriting the "templatefile" and
// "core:templatefile" entries.
return s.funcs
})
// Our two template-rendering functions want to be able to call
// all of the other functions themselves, but we pass them indirectly
// via a callback to avoid chicken/egg problems while initializing
// the functions table.
funcsFunc := func() (funcs map[string]function.Function, fsFuncs collections.Set[string], templateFuncs collections.Set[string]) {
// The templatefile and templatestring functions prevent recursive
// calls to themselves and each other by copying this map and
// overwriting the relevant entries.
return s.funcs, filesystemFunctions, templateFunctions
}
coreFuncs["templatefile"] = funcs.MakeTemplateFileFunc(s.BaseDir, funcsFunc)
coreFuncs["templatestring"] = funcs.MakeTemplateStringFunc(funcsFunc)
if s.ConsoleMode {
// The type function is only available in terraform console.
@ -228,6 +265,11 @@ func baseFunctions(baseDir string) map[string]function.Function {
// in the "funcs" directory and potentially graduate to cty stdlib
// later if the functionality seems to be something domain-agnostic
// that would be useful to all applications using cty functions.
//
// If you're adding something here, please consider whether it meets
// the criteria for either or both of the sets [filesystemFunctions]
// and [templateFunctions] and add it there if so, to ensure that
// functions relying on those classifications will behave correctly.
fs := map[string]function.Function{
"abs": stdlib.AbsoluteFunc,
"abspath": funcs.AbsPathFunc,
@ -347,10 +389,10 @@ func baseFunctions(baseDir string) map[string]function.Function {
"zipmap": stdlib.ZipmapFunc,
}
fs["templatefile"] = funcs.MakeTemplateFileFunc(baseDir, func() map[string]function.Function {
fs["templatefile"] = funcs.MakeTemplateFileFunc(baseDir, func() (map[string]function.Function, collections.Set[string], collections.Set[string]) {
// The templatefile function prevents recursive calls to itself
// by copying this map and overwriting the "templatefile" entry.
return fs
return fs, filesystemFunctions, templateFunctions
})
return fs

View file

@ -13,6 +13,7 @@ import (
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/experiments"
"github.com/hashicorp/terraform/internal/lang/marks"
homedir "github.com/mitchellh/go-homedir"
@ -976,6 +977,21 @@ func TestFunctions(t *testing.T) {
},
},
"templatestring": {
{
`templatestring(local.greeting_template, {
name = "Arthur"
})`,
cty.StringVal("Hello, Arthur!"),
},
{
`core::templatestring(local.greeting_template, {
name = "Namespaced Arthur"
})`,
cty.StringVal("Hello, Namespaced Arthur!"),
},
},
"timeadd": {
{
`timeadd("2017-11-22T00:00:00Z", "1s")`,
@ -1311,9 +1327,14 @@ func TestFunctions(t *testing.T) {
for _, test := range funcTests {
t.Run(test.src, func(t *testing.T) {
data := &dataForTests{} // no variables available; we only need literals here
data := &dataForTests{
LocalValues: map[string]cty.Value{
"greeting_template": cty.StringVal("Hello, ${name}!"),
},
}
scope := &Scope{
Data: data,
ParseRef: addrs.ParseRef,
BaseDir: "./testdata/functions-test", // for the functions that read from the filesystem
PlanTimestamp: time.Date(2004, 04, 25, 15, 00, 00, 000, time.UTC),
ExternalFuncs: externalFuncs,

View file

@ -39,6 +39,14 @@ func Contains(val cty.Value, mark valueMark) bool {
// Terraform.
const Sensitive = valueMark("Sensitive")
// Ephemeral indicates that a value exists only in memory during a single
// phase, and thus cannot persist between phases or between rounds.
//
// Ephemeral values can be used only in locations that don't require Terraform
// to persist them as part of artifacts such as state snapshots or saved plan
// files.
const Ephemeral = valueMark("Ephemeral")
// TypeType is used to indicate that the value contains a representation of
// another value's type. This is part of the implementation of the console-only
// `type` function.

View file

@ -17,7 +17,7 @@ require (
require (
github.com/agext/levenshtein v1.2.3 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/apparentlymart/go-versions v1.0.1 // indirect
github.com/apparentlymart/go-versions v1.0.2 // indirect
github.com/hashicorp/go-slug v0.15.0 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/hashicorp/hcl/v2 v2.20.0 // indirect
@ -26,7 +26,7 @@ require (
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/spf13/afero v1.9.3 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.19.0 // indirect

Some files were not shown because too many files have changed in this diff Show more