diff --git a/.changes/2.37.0-alpha.1.md b/.changes/2.37.0-alpha.1.md new file mode 100644 index 00000000000..11fdb522b6a --- /dev/null +++ b/.changes/2.37.0-alpha.1.md @@ -0,0 +1,7 @@ +## 2.37.0-alpha.1 (March 20, 2025) + +NOTES: + +* all: This Go module has been updated to Go 1.23 per the [Go support policy](https://go.dev/doc/devel/release#policy). It is recommended to review the [Go 1.23 release notes](https://go.dev/doc/go1.23) before upgrading. Any consumers building on earlier Go versions may experience errors. ([#1445](https://github.com/hashicorp/terraform-plugin-sdk/issues/1445)) +* This alpha pre-release contains an initial implementation for managed resource identity, which can used with Terraform v1.12.0-alpha20250319, to store and read identity data during plan and apply workflows. A managed resource identity can be used by defining an identity schema in the `resource.Identity` field. Once the identity schema is defined, you can read and store identity data in the new IdentityData struct that is available via the new `Identity()` method on ResourceData and ResourceDiff structs. ([#1444](https://github.com/hashicorp/terraform-plugin-sdk/issues/1444)) + diff --git a/.changes/2.37.0-beta.1.md b/.changes/2.37.0-beta.1.md new file mode 100644 index 00000000000..f79f32ca76d --- /dev/null +++ b/.changes/2.37.0-beta.1.md @@ -0,0 +1,6 @@ +## 2.37.0-beta.1 (April 18, 2025) + +NOTES: + +* This beta pre-release continues the implementation of managed resource identity, which should now be used with Terraform v1.12.0-beta2. Managed resources now can support import by identity during plan and apply workflows. Managed resources that already support import via the `schema.Resource.Importer` field still need to set an ID during import when an identity is provided. The `RequiredForImport` and `OptionalForImport` fields on the identity schema can be used to control the validation that Terraform core will apply to the import config block. ([#1463](https://github.com/hashicorp/terraform-plugin-sdk/issues/1463)) + diff --git a/.changes/2.37.0.md b/.changes/2.37.0.md new file mode 100644 index 00000000000..f08366086de --- /dev/null +++ b/.changes/2.37.0.md @@ -0,0 +1,22 @@ +## 2.37.0 (May 16, 2025) + +NOTES: + +* all: This Go module has been updated to Go 1.23 per the [Go support policy](https://go.dev/doc/devel/release#policy). It is recommended to review the [Go 1.23 release notes](https://go.dev/doc/go1.23) before upgrading. Any consumers building on earlier Go versions may experience errors. ([#1445](https://github.com/hashicorp/terraform-plugin-sdk/issues/1445)) +* all: This release contains new fields and structs for implmenting managed resource identity. Resource identity is data that is defined by a separate schema and is stored alongside resource state. Identity data is used by Terrform to uniquely identify a remote object and is meant to be immutable during the remote object's lifecycle. Resources that support identity can now be imported using the `identity` attribute in Terraform configuration `import` blocks, available in Terraform v1.12+. The `resource.Identity` field on the `schema.Resource` struct can be used to support identity by defining an identity schema. Once the identity schema is defined, you can read and store identity data in the state file with the new `IdentityData` struct that is available via the `Identity()` method on `schema.ResourceData` and `schema.ResourceDiff` structs. ([#1444](https://github.com/hashicorp/terraform-plugin-sdk/issues/1444)) + +FEATURES: + +* helper/schema: Added new `TestResourceDataWithIdentityRaw` function for creating a `ResourceData` struct with identity data for unit testing. ([#1475](https://github.com/hashicorp/terraform-plugin-sdk/issues/1475)) +* helper/schema: Added new `Identity` field to `Resource` that supports defining an identity schema for managed resources only. ([#1444](https://github.com/hashicorp/terraform-plugin-sdk/issues/1444)) +* Added new `ImportStatePassthroughWithIdentity` helper that can support both identity and ID importing via a single field. ([#1474](https://github.com/hashicorp/terraform-plugin-sdk/issues/1474)) + +ENHANCEMENTS: + +* helper/schema: Added `RequiredForImport` and `OptionalForImport` fields to the `Schema` struct, which are only valid for identity schemas. ([#1444](https://github.com/hashicorp/terraform-plugin-sdk/issues/1444)) +* helper/schema: Updated `ResourceData` to support passing of identity data in CRUD and import functions for managed resources. ([#1444](https://github.com/hashicorp/terraform-plugin-sdk/issues/1444)) + +BUG FIXES: + +* helper/schema: Fixed bug that blocked write-only attributes from being used with resources without update functions. ([#1472](https://github.com/hashicorp/terraform-plugin-sdk/issues/1472)) + diff --git a/.copywrite.hcl b/.copywrite.hcl index 22700a3d3f7..680f44c94f1 100644 --- a/.copywrite.hcl +++ b/.copywrite.hcl @@ -5,8 +5,11 @@ project { copyright_year = 2019 header_ignore = [ + # internal catalog metadata (prose) + "META.d/**/*.yaml", + # changie tooling configuration and CHANGELOG entries (prose) - ".changes/unreleased/*.yaml", + ".changes/unreleased/**", ".changie.yaml", # GitHub issue template configuration diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index fbbdd59452b..8ad9019cd8b 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -339,10 +339,6 @@ This section is dedicated to the maintainers of this project. ### Releases -Before running a release: - -- **`meta/meta.go`**: The versions must be appropriately updated. - To cut a release, go to the repository in GitHub and click on the `Actions` tab. Select the `Release` workflow on the left-hand menu. @@ -352,4 +348,6 @@ Click on the `Run workflow` button. Select the branch to cut the release from (default is main). Input the `Release version number` which is the Semantic Release number including -the `v` prefix (i.e. `v1.4.0`) and click `Run workflow` to kickoff the release. +the `v` prefix (i.e. `v1.4.0` or `v1.4.0-alpha.1`) and click `Run workflow` to kickoff the release. + +The (deprecated) version information in `meta/meta.go` will be updated automatically and a commit will be pushed. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 552d6ab99c4..2b70bd48e5e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,10 +14,7 @@ updates: directory: "/tools" schedule: interval: "daily" - # Dependabot only updates hashicorp GHAs, external GHAs are managed by internal tooling (tsccr) - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" - allow: - - dependency-name: "hashicorp/*" diff --git a/.github/workflows/ci-github-actions.yml b/.github/workflows/ci-github-actions.yml index 8451dde358b..ebdf8a6b857 100644 --- a/.github/workflows/ci-github-actions.yml +++ b/.github/workflows/ci-github-actions.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0 + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version-file: 'go.mod' - run: go install github.com/rhysd/actionlint/cmd/actionlint@latest diff --git a/.github/workflows/ci-go.yml b/.github/workflows/ci-go.yml index ed10a887ddf..59f87c72049 100644 --- a/.github/workflows/ci-go.yml +++ b/.github/workflows/ci-go.yml @@ -17,11 +17,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0 + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version-file: 'go.mod' - run: go mod download - - uses: golangci/golangci-lint-action@2e788936b09dd82dc280e845628a40d2ba6b204c # v6.3.1 + - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 with: version: latest terraform-provider-corner-tfprotov5: @@ -36,7 +36,7 @@ jobs: with: path: terraform-provider-corner repository: hashicorp/terraform-provider-corner - - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0 + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version-file: 'go.mod' - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 @@ -57,16 +57,16 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go-version: [ '1.23', '1.22' ] + go-version: [ '1.24', '1.23' ] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0 + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version: ${{ matrix.go-version }} - run: go mod download - run: go test -coverprofile=coverage.out ./... - run: go tool cover -html=coverage.out -o coverage.html - - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: go-${{ matrix.go-version }}-coverage path: coverage.html diff --git a/.github/workflows/ci-goreleaser.yml b/.github/workflows/ci-goreleaser.yml index 2c62e130b60..8750d649e93 100644 --- a/.github/workflows/ci-goreleaser.yml +++ b/.github/workflows/ci-goreleaser.yml @@ -15,9 +15,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0 + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version-file: 'go.mod' - - uses: goreleaser/goreleaser-action@026299872805cb2db698e02dd7fb506a4da5122d # v6.2.0 + - uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 with: args: check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 802a636b5da..457d5367c7a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,9 +20,26 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.changelog-version.outputs.version }} + version_only: ${{ steps.changelog-version.outputs.version_only }} + prerelease: ${{ steps.changelog-version.outputs.prerelease }} steps: - id: changelog-version - run: echo "version=$(echo "${{ inputs.versionNumber }}" | cut -c 2-)" >> "$GITHUB_OUTPUT" + run: | + version="${{ inputs.versionNumber }}" + version="${version#v}" # Remove leading "v" if present + version_only="${version%%-*}" + prerelease="${version#*-}" + + # If there's no dash, set prerelease to empty + if [ "$version" = "$version_only" ]; then + prerelease="" + fi + + { + echo "version=$version" + echo "version_only=$version_only" + echo "prerelease=$prerelease" + } >> "$GITHUB_OUTPUT" changelog: needs: [ changelog-version, meta-version ] @@ -70,8 +87,10 @@ jobs: # Avoid persisting GITHUB_TOKEN credentials as they take priority over our service account PAT for `git push` operations # More details: https://github.com/actions/checkout/blob/b4626ce19ce1106186ddf9bb20e706842f11a7c3/adrs/0153-checkout-v2.md#persist-credentials persist-credentials: false - - name: Update meta package SDKVersion - run: sed -i "s/var SDKVersion =.*/var SDKVersion = \"${{ needs.changelog-version.outputs.version }}\"/" meta/meta.go + - name: Update meta package SDKVersion and SDKPrerelease + run: | + sed -i "s/var SDKVersion =.*/var SDKVersion = \"${{ needs.changelog-version.outputs.version_only }}\"/" meta/meta.go + sed -i "s/var SDKPrerelease =.*/var SDKPrerelease = \"${{ needs.changelog-version.outputs.prerelease }}\"/" meta/meta.go - name: Git push meta run: | git config --global user.name "${{ vars.TF_DEVEX_CI_COMMIT_AUTHOR }}" @@ -115,7 +134,7 @@ jobs: ref: ${{ inputs.versionNumber }} fetch-depth: 0 - - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0 + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version-file: 'go.mod' @@ -124,7 +143,7 @@ jobs: cd .changes sed -e "1{/# /d;}" -e "2{/^$/d;}" ${{ needs.changelog-version.outputs.version }}.md > /tmp/release-notes.txt - - uses: goreleaser/goreleaser-action@026299872805cb2db698e02dd7fb506a4da5122d # v6.2.0 + - uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.golangci.yml b/.golangci.yml index 3e4a04f3642..0f296f08a24 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,31 +1,58 @@ -issues: - exclude-rules: - - linters: - - staticcheck - text: 'SA1019: schema.SchemaValidateFunc is deprecated' - max-per-linter: 0 - max-same-issues: 0 - +version: "2" linters: - disable-all: true + default: none enable: + - copyloopvar - durationcheck - errcheck - - copyloopvar - - gofmt - - gosimple + - govet - ineffassign - makezero - nilerr - # - paralleltest # Reference: https://github.com/kunwardeep/paralleltest/issues/14 - predeclared - staticcheck - - usetesting - unconvert - unparam - unused - - govet - -run: - # Prevent false positive timeouts in CI - timeout: 5m \ No newline at end of file + - usetesting + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - staticcheck + text: 'SA1019: schema.SchemaValidateFunc is deprecated' + paths: + - third_party$ + - builtin$ + - examples$ + settings: + staticcheck: + checks: + - all + - '-QF1001' # "could apply De Morgan's law" -- https://staticcheck.dev/docs/checks/#QF1001 + - '-QF1002' # "could use tagged switch" -- https://staticcheck.dev/docs/checks/#QF1002 + - '-QF1004' # "could use strings.ReplaceAll instead" -- https://staticcheck.dev/docs/checks/#QF1004 + - '-QF1007' # "could merge conditional assignment into variable declaration" -- https://staticcheck.dev/docs/checks/#QF1007 + - '-QF1008' # "could remove embedded field "Block" from selector" -- https://staticcheck.dev/docs/checks/#QF1008 + - '-QF1011' # "could omit type *terraform.InstanceState from declaration" -- https://staticcheck.dev/docs/checks/#QF1011 + - '-ST1003' # example: "const autoTFVarsJson should be autoTFVarsJSON" -- https://staticcheck.dev/docs/checks/#ST1003 + - '-ST1005' # "error strings should not end with punctuation or newlines" -- https://staticcheck.dev/docs/checks/#ST1005 + - '-ST1016' # example: "methods on the same type should have the same receiver name (seen 2x "r", 2x "s")" -- https://staticcheck.dev/docs/checks/#ST1016 + - '-ST1023' # example: "should omit type *terraform.InstanceState from declaration;" -- https://staticcheck.dev/docs/checks/#ST1023 +issues: + max-issues-per-linter: 0 + max-same-issues: 0 +formatters: + enable: + - gofmt + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.goreleaser.yml b/.goreleaser.yml index cb189e494c4..e1c9a0df571 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -5,5 +5,6 @@ builds: milestones: - close: true release: + prerelease: auto ids: - 'none' diff --git a/CHANGELOG.md b/CHANGELOG.md index 73678077836..796e0a6b1b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +## 2.37.0 (May 16, 2025) + +NOTES: + +* all: This Go module has been updated to Go 1.23 per the [Go support policy](https://go.dev/doc/devel/release#policy). It is recommended to review the [Go 1.23 release notes](https://go.dev/doc/go1.23) before upgrading. Any consumers building on earlier Go versions may experience errors. ([#1445](https://github.com/hashicorp/terraform-plugin-sdk/issues/1445)) +* all: This release contains new fields and structs for implmenting managed resource identity. Resource identity is data that is defined by a separate schema and is stored alongside resource state. Identity data is used by Terrform to uniquely identify a remote object and is meant to be immutable during the remote object's lifecycle. Resources that support identity can now be imported using the `identity` attribute in Terraform configuration `import` blocks, available in Terraform v1.12+. The `resource.Identity` field on the `schema.Resource` struct can be used to support identity by defining an identity schema. Once the identity schema is defined, you can read and store identity data in the state file with the new `IdentityData` struct that is available via the `Identity()` method on `schema.ResourceData` and `schema.ResourceDiff` structs. ([#1444](https://github.com/hashicorp/terraform-plugin-sdk/issues/1444)) + +FEATURES: + +* helper/schema: Added new `TestResourceDataWithIdentityRaw` function for creating a `ResourceData` struct with identity data for unit testing. ([#1475](https://github.com/hashicorp/terraform-plugin-sdk/issues/1475)) +* helper/schema: Added new `Identity` field to `Resource` that supports defining an identity schema for managed resources only. ([#1444](https://github.com/hashicorp/terraform-plugin-sdk/issues/1444)) +* Added new `ImportStatePassthroughWithIdentity` helper that can support both identity and ID importing via a single field. ([#1474](https://github.com/hashicorp/terraform-plugin-sdk/issues/1474)) + +ENHANCEMENTS: + +* helper/schema: Added `RequiredForImport` and `OptionalForImport` fields to the `Schema` struct, which are only valid for identity schemas. ([#1444](https://github.com/hashicorp/terraform-plugin-sdk/issues/1444)) +* helper/schema: Updated `ResourceData` to support passing of identity data in CRUD and import functions for managed resources. ([#1444](https://github.com/hashicorp/terraform-plugin-sdk/issues/1444)) + +BUG FIXES: + +* helper/schema: Fixed bug that blocked write-only attributes from being used with resources without update functions. ([#1472](https://github.com/hashicorp/terraform-plugin-sdk/issues/1472)) + +## 2.37.0-beta.1 (April 18, 2025) + +NOTES: + +* This beta pre-release continues the implementation of managed resource identity, which should now be used with Terraform v1.12.0-beta2. Managed resources now can support import by identity during plan and apply workflows. Managed resources that already support import via the `schema.Resource.Importer` field still need to set an ID during import when an identity is provided. The `RequiredForImport` and `OptionalForImport` fields on the identity schema can be used to control the validation that Terraform core will apply to the import config block. ([#1463](https://github.com/hashicorp/terraform-plugin-sdk/issues/1463)) + +## 2.37.0-alpha.1 (March 20, 2025) + +NOTES: + +* all: This Go module has been updated to Go 1.23 per the [Go support policy](https://go.dev/doc/devel/release#policy). It is recommended to review the [Go 1.23 release notes](https://go.dev/doc/go1.23) before upgrading. Any consumers building on earlier Go versions may experience errors. ([#1445](https://github.com/hashicorp/terraform-plugin-sdk/issues/1445)) +* This alpha pre-release contains an initial implementation for managed resource identity, which can used with Terraform v1.12.0-alpha20250319, to store and read identity data during plan and apply workflows. A managed resource identity can be used by defining an identity schema in the `resource.Identity` field. Once the identity schema is defined, you can read and store identity data in the new IdentityData struct that is available via the new `Identity()` method on ResourceData and ResourceDiff structs. ([#1444](https://github.com/hashicorp/terraform-plugin-sdk/issues/1444)) + ## 2.36.1 (February 19, 2025) NOTES: diff --git a/META.d/_summary.yaml b/META.d/_summary.yaml new file mode 100644 index 00000000000..7af04096053 --- /dev/null +++ b/META.d/_summary.yaml @@ -0,0 +1,10 @@ +--- +schema: 1.1 + +partition: tf-ecosystem + +summary: + owner: team-tf-core-plugins + description: | + Terraform Plugin SDK enables building plugins (providers) to manage any service providers or custom in-house solutions + visibility: public diff --git a/README.md b/README.md index bc3474078dc..9ea49cef353 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ When running provider tests, Terraform 0.12.26 or later is needed for version 2. This project follows the [support policy](https://golang.org/doc/devel/release.html#policy) of Go as its support policy. The two latest major releases of Go are supported by the project. -Currently, that means Go **1.22** or later must be used when including this project as a dependency. +Currently, that means Go **1.23** or later must be used when including this project as a dependency. ## Getting Started diff --git a/go.mod b/go.mod index 4745e1e61b9..775f324f8cd 100644 --- a/go.mod +++ b/go.mod @@ -1,36 +1,36 @@ module github.com/hashicorp/terraform-plugin-sdk/v2 -go 1.22.0 +go 1.23.0 -toolchain go1.22.7 +toolchain go1.23.7 require ( - github.com/google/go-cmp v0.6.0 - github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 + github.com/google/go-cmp v0.7.0 + github.com/hashicorp/go-cty v1.5.0 github.com/hashicorp/go-hclog v1.6.3 - github.com/hashicorp/go-plugin v1.6.2 + github.com/hashicorp/go-plugin v1.6.3 github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-version v1.7.0 - github.com/hashicorp/hc-install v0.9.1 + github.com/hashicorp/hc-install v0.9.2 github.com/hashicorp/hcl/v2 v2.23.0 github.com/hashicorp/logutils v1.0.0 - github.com/hashicorp/terraform-exec v0.22.0 - github.com/hashicorp/terraform-json v0.24.0 - github.com/hashicorp/terraform-plugin-go v0.26.0 + github.com/hashicorp/terraform-exec v0.23.0 + github.com/hashicorp/terraform-json v0.25.0 + github.com/hashicorp/terraform-plugin-go v0.27.0 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/mitchellh/copystructure v1.2.0 github.com/mitchellh/go-testing-interface v1.14.1 github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/reflectwalk v1.0.2 github.com/zclconf/go-cty v1.16.2 - golang.org/x/crypto v0.33.0 + golang.org/x/crypto v0.38.0 ) require ( - github.com/ProtonMail/go-crypto v1.1.3 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/agext/levenshtein v1.2.2 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect - github.com/cloudflare/circl v1.3.7 // indirect + github.com/cloudflare/circl v1.6.0 // indirect github.com/fatih/color v1.16.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect @@ -38,7 +38,7 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect - github.com/hashicorp/terraform-registry-address v0.2.4 // indirect + github.com/hashicorp/terraform-registry-address v0.2.5 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect @@ -48,14 +48,14 @@ require ( github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - golang.org/x/mod v0.22.0 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/mod v0.24.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect - google.golang.org/grpc v1.69.4 // indirect - google.golang.org/protobuf v1.36.3 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect + google.golang.org/grpc v1.72.1 // indirect + google.golang.org/protobuf v1.36.6 // indirect ) diff --git a/go.sum b/go.sum index 4c9606b27d1..1eb6e673606 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= -github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= -github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk= -github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE= github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= @@ -11,10 +11,10 @@ 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/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= -github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= -github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= -github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo= -github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= +github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -25,18 +25,18 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8= -github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= -github.com/go-git/go-git/v5 v5.13.0 h1:vLn5wlGIh/X78El6r3Jr+30W16Blk0CTcxTYcYPWi5E= -github.com/go-git/go-git/v5 v5.13.0/go.mod h1:Wjo7/JyVKtQgUNdXYXIepzWfJQkUEIGvkvVkiXRR/zw= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60= +github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= @@ -44,8 +44,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= @@ -55,14 +55,14 @@ github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuD github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 h1:1/D3zfFHttUKaCaGKZ/dR2roBXv0vKbSCnssIldfQdI= -github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320/go.mod h1:EiZBMaudVLy8fmjf9Npq1dq9RalhveqZG5w/yz3mHWs= +github.com/hashicorp/go-cty v1.5.0 h1:EkQ/v+dDNUqnuVpmS5fPqyY71NXVgT5gf32+57xY8g0= +github.com/hashicorp/go-cty v1.5.0/go.mod h1:lFUCG5kd8exDobgSfyj4ONE/dc822kiYMguVKdHGMLM= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-plugin v1.6.2 h1:zdGAEd0V1lCaU0u+MxWQhtSDQmahpkwOun8U8EiRVog= -github.com/hashicorp/go-plugin v1.6.2/go.mod h1:CkgLQ5CZqNmdL9U9JzM532t8ZiYQ35+pj3b1FD37R0Q= +github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg= +github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -70,22 +70,22 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/hc-install v0.9.1 h1:gkqTfE3vVbafGQo6VZXcy2v5yoz2bE0+nhZXruCuODQ= -github.com/hashicorp/hc-install v0.9.1/go.mod h1:pWWvN/IrfeBK4XPeXXYkL6EjMufHkCK5DvwxeLKuBf0= +github.com/hashicorp/hc-install v0.9.2 h1:v80EtNX4fCVHqzL9Lg/2xkp62bbvQMnvPQ0G+OmtO24= +github.com/hashicorp/hc-install v0.9.2/go.mod h1:XUqBQNnuT4RsxoxiM9ZaUk0NX8hi2h+Lb6/c0OZnC/I= github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/terraform-exec v0.22.0 h1:G5+4Sz6jYZfRYUCg6eQgDsqTzkNXV+fP8l+uRmZHj64= -github.com/hashicorp/terraform-exec v0.22.0/go.mod h1:bjVbsncaeh8jVdhttWYZuBGj21FcYw6Ia/XfHcNO7lQ= -github.com/hashicorp/terraform-json v0.24.0 h1:rUiyF+x1kYawXeRth6fKFm/MdfBS6+lW4NbeATsYz8Q= -github.com/hashicorp/terraform-json v0.24.0/go.mod h1:Nfj5ubo9xbu9uiAoZVBsNOjvNKB66Oyrvtit74kC7ow= -github.com/hashicorp/terraform-plugin-go v0.26.0 h1:cuIzCv4qwigug3OS7iKhpGAbZTiypAfFQmw8aE65O2M= -github.com/hashicorp/terraform-plugin-go v0.26.0/go.mod h1:+CXjuLDiFgqR+GcrM5a2E2Kal5t5q2jb0E3D57tTdNY= +github.com/hashicorp/terraform-exec v0.23.0 h1:MUiBM1s0CNlRFsCLJuM5wXZrzA3MnPYEsiXmzATMW/I= +github.com/hashicorp/terraform-exec v0.23.0/go.mod h1:mA+qnx1R8eePycfwKkCRk3Wy65mwInvlpAeOwmA7vlY= +github.com/hashicorp/terraform-json v0.25.0 h1:rmNqc/CIfcWawGiwXmRuiXJKEiJu1ntGoxseG1hLhoQ= +github.com/hashicorp/terraform-json v0.25.0/go.mod h1:sMKS8fiRDX4rVlR6EJUMudg1WcanxCMoWwTLkgZP/vc= +github.com/hashicorp/terraform-plugin-go v0.27.0 h1:ujykws/fWIdsi6oTUT5Or4ukvEan4aN9lY+LOxVP8EE= +github.com/hashicorp/terraform-plugin-go v0.27.0/go.mod h1:FDa2Bb3uumkTGSkTFpWSOwWJDwA7bf3vdP3ltLDTH6o= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= -github.com/hashicorp/terraform-registry-address v0.2.4 h1:JXu/zHB2Ymg/TGVCRu10XqNa4Sh2bWcqCNyKWjnCPJA= -github.com/hashicorp/terraform-registry-address v0.2.4/go.mod h1:tUNYTVyCtU4OIGXXMDp7WNcJ+0W1B4nmstVDgHMjfAU= +github.com/hashicorp/terraform-registry-address v0.2.5 h1:2GTftHqmUhVOeuu9CW3kwDkRe4pcBDq0uuK5VJngU1M= +github.com/hashicorp/terraform-registry-address v0.2.5/go.mod h1:PpzXWINwB5kuVS5CA7m1+eO2f1jKb5ZDIxrOPfpnGkg= github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= @@ -122,14 +122,14 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= -github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= -github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= -github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= @@ -148,34 +148,36 @@ github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70 github.com/zclconf/go-cty v1.16.2/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= -go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= -go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= -go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= -go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= -go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= -go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= -go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= -go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= -go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= -go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= -golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -188,19 +190,18 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= -golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -211,14 +212,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= -google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= -google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= +google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= +google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= -google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/helper/logging/logging_http_transport_test.go b/helper/logging/logging_http_transport_test.go index a7b84e110dc..73c3515cb60 100644 --- a/helper/logging/logging_http_transport_test.go +++ b/helper/logging/logging_http_transport_test.go @@ -31,7 +31,7 @@ func TestNewLoggingHTTPTransport(t *testing.T) { reqBody := `An example multiline request body` - req, _ := http.NewRequest("GET", "https://www.terraform.io", bytes.NewBufferString(reqBody)) + req, _ := http.NewRequest("GET", "https://developer.hashicorp.com/terraform", bytes.NewBufferString(reqBody)) res, err := client.Do(req.WithContext(ctx)) if err != nil { t.Fatalf("request failed: %v", err) @@ -40,7 +40,7 @@ func TestNewLoggingHTTPTransport(t *testing.T) { entries, err := tflogtest.MultilineJSONDecode(loggerOutput) if err != nil { - t.Fatalf("log outtput parsing failed: %v", err) + t.Fatalf("log output parsing failed: %v", err) } if len(entries) != 2 { @@ -67,12 +67,12 @@ func TestNewLoggingHTTPTransport(t *testing.T) { "@module": "provider", "tf_http_op_type": "request", "tf_http_req_method": "GET", - "tf_http_req_uri": "/", + "tf_http_req_uri": "/terraform", "tf_http_req_version": "HTTP/1.1", "tf_http_req_body": "An example multiline request body", "tf_http_trans_id": transId, "Accept-Encoding": "gzip", - "Host": "www.terraform.io", + "Host": "developer.hashicorp.com", "User-Agent": "Go-http-client/1.1", "Content-Length": "37", }); diff != "" { @@ -122,7 +122,7 @@ func TestNewSubsystemLoggingHTTPTransport(t *testing.T) { reqBody := `An example multiline request body` - req, _ := http.NewRequest("GET", "https://www.terraform.io", bytes.NewBufferString(reqBody)) + req, _ := http.NewRequest("GET", "https://developer.hashicorp.com/terraform", bytes.NewBufferString(reqBody)) res, err := client.Do(req.WithContext(ctx)) if err != nil { t.Fatalf("request failed: %v", err) @@ -158,12 +158,12 @@ func TestNewSubsystemLoggingHTTPTransport(t *testing.T) { "@module": "provider.test-subsystem", "tf_http_op_type": "request", "tf_http_req_method": "GET", - "tf_http_req_uri": "/", + "tf_http_req_uri": "/terraform", "tf_http_req_version": "HTTP/1.1", "tf_http_req_body": "An example multiline request body", "tf_http_trans_id": transId, "Accept-Encoding": "gzip", - "Host": "www.terraform.io", + "Host": "developer.hashicorp.com", "User-Agent": "Go-http-client/1.1", "Content-Length": "37", }); diff != "" { @@ -201,7 +201,7 @@ func TestNewSubsystemLoggingHTTPTransport(t *testing.T) { func TestNewLoggingHTTPTransport_LogMasking(t *testing.T) { ctx, loggerOutput := setupRootLogger() ctx = tflog.MaskFieldValuesWithFieldKeys(ctx, "tf_http_op_type") - ctx = tflog.MaskAllFieldValuesRegexes(ctx, regexp.MustCompile(`(?s).*`)) + ctx = tflog.MaskAllFieldValuesRegexes(ctx, regexp.MustCompile(`(?s).*`)) ctx = tflog.MaskMessageStrings(ctx, "Request", "Response") transport := logging.NewLoggingHTTPTransport(http.DefaultTransport) @@ -210,7 +210,7 @@ func TestNewLoggingHTTPTransport_LogMasking(t *testing.T) { Timeout: 10 * time.Second, } - req, _ := http.NewRequest("GET", "https://www.terraform.io", nil) + req, _ := http.NewRequest("GET", "https://developer.hashicorp.com/terraform", nil) res, err := client.Do(req.WithContext(ctx)) if err != nil { t.Fatalf("request failed: %v", err) @@ -266,7 +266,7 @@ func TestNewLoggingHTTPTransport_LogOmitting(t *testing.T) { Timeout: 10 * time.Second, } - req, _ := http.NewRequest("GET", "https://www.terraform.io", nil) + req, _ := http.NewRequest("GET", "https://developer.hashicorp.com/terraform", nil) res, err := client.Do(req.WithContext(ctx)) if err != nil { t.Fatalf("request failed: %v", err) diff --git a/helper/schema/core_schema.go b/helper/schema/core_schema.go index 9247adde7e4..79f78a1dc31 100644 --- a/helper/schema/core_schema.go +++ b/helper/schema/core_schema.go @@ -168,6 +168,9 @@ func (s *Schema) coreConfigSchemaAttribute() *configschema.Attribute { DescriptionKind: descKind, Deprecated: s.Deprecated != "", WriteOnly: s.WriteOnly, + // For Identity Attributes only + OptionalForImport: s.OptionalForImport, + RequiredForImport: s.RequiredForImport, } } @@ -370,3 +373,27 @@ func (r *Resource) CoreConfigSchema() *configschema.Block { func (r *Resource) coreConfigSchema() *configschema.Block { return schemaMap(r.SchemaMap()).CoreConfigSchema() } + +func (r *Resource) CoreIdentitySchema() (*configschema.Block, error) { + block, err := r.coreIdentitySchema() + + if err != nil { + return nil, err + } + + if block.Attributes == nil { + return nil, fmt.Errorf("identity schema must have at least one attribute") + } + + return block, nil +} + +func (r *Resource) coreIdentitySchema() (*configschema.Block, error) { + if r.Identity.SchemaMap() == nil { + return nil, fmt.Errorf("resource does not have an identity schema") + } + // while there is schemaMapWithIdentity, we don't need to use it here + // as we're only interested in the existing CoreConfigSchema() method + // to convert our schema + return schemaMap(r.Identity.SchemaMap()).CoreConfigSchema(), nil +} diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index f942814806c..e6334e924ea 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -77,6 +77,115 @@ func (s *GRPCProviderServer) serverCapabilities() *tfprotov5.ServerCapabilities } } +func (s *GRPCProviderServer) GetResourceIdentitySchemas(ctx context.Context, req *tfprotov5.GetResourceIdentitySchemasRequest) (*tfprotov5.GetResourceIdentitySchemasResponse, error) { + ctx = logging.InitContext(ctx) + + logging.HelperSchemaTrace(ctx, "Getting resource identity schemas") + + resp := &tfprotov5.GetResourceIdentitySchemasResponse{ + IdentitySchemas: make(map[string]*tfprotov5.ResourceIdentitySchema), + } + + for typ, res := range s.provider.ResourcesMap { + logging.HelperSchemaTrace(ctx, "Found resource identity type", map[string]interface{}{logging.KeyResourceType: typ}) + + if res.Identity != nil { + idschema, err := res.CoreIdentitySchema() + + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, fmt.Errorf("getting identity schema failed for resource '%s': %w", typ, err)) + return resp, nil + } + + resp.IdentitySchemas[typ] = &tfprotov5.ResourceIdentitySchema{ + Version: res.Identity.Version, + IdentityAttributes: convert.ConfigIdentitySchemaToProto(ctx, idschema), + } + } + } + + return resp, nil + +} + +func (s *GRPCProviderServer) UpgradeResourceIdentity(ctx context.Context, req *tfprotov5.UpgradeResourceIdentityRequest) (*tfprotov5.UpgradeResourceIdentityResponse, error) { + ctx = logging.InitContext(ctx) + resp := &tfprotov5.UpgradeResourceIdentityResponse{} + + res, ok := s.provider.ResourcesMap[req.TypeName] + if !ok { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, fmt.Errorf("unknown resource type: %s", req.TypeName)) + return resp, nil + } + + schemaBlock, err := s.getResourceIdentitySchemaBlock(req.TypeName) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) + return resp, nil + } + + version := req.Version + + jsonMap := map[string]interface{}{} + + switch { + // if there's a JSON state, we need to decode it. + case req.RawIdentity != nil && len(req.RawIdentity.JSON) > 0: + if res.UseJSONNumber { + err = unmarshalJSON(req.RawIdentity.JSON, &jsonMap) + } else { + err = json.Unmarshal(req.RawIdentity.JSON, &jsonMap) + } + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) + return resp, nil + } + default: + logging.HelperSchemaDebug(ctx, "no resource identity provided to upgrade") + return resp, nil + } + + // complete the upgrade of the JSON states + logging.HelperSchemaTrace(ctx, "Upgrading JSON identity") + + jsonMap, err = s.upgradeJSONIdentity(ctx, version, jsonMap, res) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) + return resp, nil + } + + // The provider isn't required to clean out removed fields + s.removeAttributes(ctx, jsonMap, schemaBlock.ImpliedType()) + + // now we need to turn the state into the default json representation, so + // that it can be re-decoded using the actual schema. + val, err := JSONMapToStateValue(jsonMap, schemaBlock) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) + return resp, nil + } + + // Now we need to make sure blocks are represented correctly, which means + // that missing blocks are empty collections, rather than null. + // First we need to CoerceValue to ensure that all object types match. + val, err = schemaBlock.CoerceValue(val) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) + return resp, nil + } + + // encode the final state to the expected msgpack format + newStateMP, err := msgpack.Marshal(val, schemaBlock.ImpliedType()) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) + return resp, nil + } + + resp.UpgradedIdentity = &tfprotov5.ResourceIdentityData{IdentityData: &tfprotov5.DynamicValue{MsgPack: newStateMP}} + + return resp, nil +} + func (s *GRPCProviderServer) GetMetadata(ctx context.Context, req *tfprotov5.GetMetadataRequest) (*tfprotov5.GetMetadataResponse, error) { ctx = logging.InitContext(ctx) @@ -160,6 +269,11 @@ func (s *GRPCProviderServer) getResourceSchemaBlock(name string) *configschema.B return res.CoreConfigSchema() } +func (s *GRPCProviderServer) getResourceIdentitySchemaBlock(name string) (*configschema.Block, error) { + res := s.provider.ResourcesMap[name] + return res.CoreIdentitySchema() +} + func (s *GRPCProviderServer) getDatasourceSchemaBlock(name string) *configschema.Block { dat := s.provider.DataSourcesMap[name] return dat.CoreConfigSchema() @@ -674,11 +788,43 @@ func (s *GRPCProviderServer) ConfigureProvider(ctx context.Context, req *tfproto func (s *GRPCProviderServer) ReadResource(ctx context.Context, req *tfprotov5.ReadResourceRequest) (*tfprotov5.ReadResourceResponse, error) { ctx = logging.InitContext(ctx) + readFollowingImport := false + + reqPrivate := req.Private + + if reqPrivate != nil { + // unmarshal the private data + if len(reqPrivate) > 0 { + newReqPrivate := make(map[string]interface{}) + if err := json.Unmarshal(reqPrivate, &newReqPrivate); err != nil { + return nil, err + } + // This internal private field is set on a resource during ImportResourceState to help framework determine if + // the resource has been recently imported. We only need to read this once, so we immediately clear it after. + if _, ok := newReqPrivate[terraform.ImportBeforeReadMetaKey]; ok { + readFollowingImport = true + delete(newReqPrivate, terraform.ImportBeforeReadMetaKey) + + if len(newReqPrivate) == 0 { + // if there are no other private data, set the private data to nil + reqPrivate = nil + } else { + // set the new private data without the import key + bytes, err := json.Marshal(newReqPrivate) + if err != nil { + return nil, err + } + reqPrivate = bytes + } + } + } + } + resp := &tfprotov5.ReadResourceResponse{ // helper/schema did previously handle private data during refresh, but // core is now going to expect this to be maintained in order to // persist it in the state. - Private: req.Private, + Private: reqPrivate, } res, ok := s.provider.ResourcesMap[req.TypeName] @@ -698,6 +844,7 @@ func (s *GRPCProviderServer) ReadResource(ctx context.Context, req *tfprotov5.Re ) resp.NewState = req.CurrentState + resp.NewIdentity = req.CurrentIdentity resp.Deferred = &tfprotov5.Deferred{ Reason: tfprotov5.DeferredReason(s.provider.providerDeferred.Reason), } @@ -717,9 +864,31 @@ func (s *GRPCProviderServer) ReadResource(ctx context.Context, req *tfprotov5.Re } instanceState.RawState = stateVal + var currentIdentityVal cty.Value + if req.CurrentIdentity != nil && req.CurrentIdentity.IdentityData != nil { + + // convert req.CurrentIdentity to flat map identity structure + // Step 1: Turn JSON into cty.Value based on schema + identityBlock, err := s.getResourceIdentitySchemaBlock(req.TypeName) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, fmt.Errorf("getting identity schema failed for resource '%s': %w", req.TypeName, err)) + return resp, nil + } + + currentIdentityVal, err = msgpack.Unmarshal(req.CurrentIdentity.IdentityData.MsgPack, identityBlock.ImpliedType()) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) + return resp, nil + } + // Step 2: Turn cty.Value into flatmap representation + identityAttrs := hcl2shim.FlatmapValueFromHCL2(currentIdentityVal) + // Step 3: Well, set it in the instanceState + instanceState.Identity = identityAttrs + } + private := make(map[string]interface{}) - if len(req.Private) > 0 { - if err := json.Unmarshal(req.Private, &private); err != nil { + if len(reqPrivate) > 0 { + if err := json.Unmarshal(reqPrivate, &private); err != nil { resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) return resp, nil } @@ -779,6 +948,41 @@ func (s *GRPCProviderServer) ReadResource(ctx context.Context, req *tfprotov5.Re MsgPack: newStateMP, } + if newInstanceState.Identity != nil { + identityBlock, err := s.getResourceIdentitySchemaBlock(req.TypeName) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, fmt.Errorf("getting identity schema failed for resource '%s': %w", req.TypeName, err)) + return resp, nil + } + + newIdentityVal, err := hcl2shim.HCL2ValueFromFlatmap(newInstanceState.Identity, identityBlock.ImpliedType()) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) + return resp, nil + } + + // If we're refreshing the resource state (excluding a recently imported resource), validate that the new identity isn't changing + if !res.ResourceBehavior.MutableIdentity && !readFollowingImport && !currentIdentityVal.IsNull() && !currentIdentityVal.RawEquals(newIdentityVal) { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, fmt.Errorf("Unexpected Identity Change: %s", "During the read operation, the Terraform Provider unexpectedly returned a different identity then the previously stored one.\n\n"+ + "This is always a problem with the provider and should be reported to the provider developer.\n\n"+ + fmt.Sprintf("Current Identity: %s\n\n", currentIdentityVal.GoString())+ + fmt.Sprintf("New Identity: %s", newIdentityVal.GoString()))) + return resp, nil + } + + newIdentityMP, err := msgpack.Marshal(newIdentityVal, identityBlock.ImpliedType()) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) + return resp, nil + } + + resp.NewIdentity = &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: newIdentityMP, + }, + } + } + return resp, nil } @@ -820,6 +1024,7 @@ func (s *GRPCProviderServer) PlanResourceChange(ctx context.Context, req *tfprot resp.Deferred = &tfprotov5.Deferred{ Reason: tfprotov5.DeferredReason(s.provider.providerDeferred.Reason), } + resp.PlannedIdentity = req.PriorIdentity return resp, nil } @@ -841,6 +1046,7 @@ func (s *GRPCProviderServer) PlanResourceChange(ctx context.Context, req *tfprot if proposedNewStateVal.IsNull() { resp.PlannedState = req.ProposedNewState resp.PlannedPrivate = req.PriorPrivate + resp.PlannedIdentity = req.PriorIdentity return resp, nil } @@ -887,6 +1093,28 @@ func (s *GRPCProviderServer) PlanResourceChange(ctx context.Context, req *tfprot // turn the proposed state into a legacy configuration cfg := terraform.NewResourceConfigShimmed(proposedNewStateVal, schemaBlock) + var priorIdentityVal cty.Value + // add identity data to priorState + if req.PriorIdentity != nil && req.PriorIdentity.IdentityData != nil { + // convert req.PriorIdentity to flat map identity structure + // Step 1: Turn JSON into cty.Value based on schema + identityBlock, err := s.getResourceIdentitySchemaBlock(req.TypeName) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, fmt.Errorf("getting identity schema failed for resource '%s': %w", req.TypeName, err)) + return resp, nil + } + + priorIdentityVal, err = msgpack.Unmarshal(req.PriorIdentity.IdentityData.MsgPack, identityBlock.ImpliedType()) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) + return resp, nil + } + // Step 2: Turn cty.Value into flatmap representation + identityAttrs := hcl2shim.FlatmapValueFromHCL2(priorIdentityVal) + // Step 3: Well, set it in the priorState + priorState.Identity = identityAttrs + } + diff, err := res.SimpleDiff(ctx, priorState, cfg, s.provider.Meta()) if err != nil { resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) @@ -904,13 +1132,14 @@ func (s *GRPCProviderServer) PlanResourceChange(ctx context.Context, req *tfprot } } - if diff == nil || len(diff.Attributes) == 0 { + if diff == nil || (len(diff.Attributes) == 0 && len(diff.Identity) == 0) { // schema.Provider.Diff returns nil if it ends up making a diff with no // changes, but our new interface wants us to return an actual change // description that _shows_ there are no changes. This is always the // prior state, because we force a diff above if this is a new instance. resp.PlannedState = req.PriorState resp.PlannedPrivate = req.PriorPrivate + resp.PlannedIdentity = req.PriorIdentity return resp, nil } @@ -1061,6 +1290,45 @@ func (s *GRPCProviderServer) PlanResourceChange(ctx context.Context, req *tfprot } } + if res.Identity != nil { + identityBlock, err := s.getResourceIdentitySchemaBlock(req.TypeName) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, fmt.Errorf("getting identity schema failed for resource '%s': %w", req.TypeName, err)) + return resp, nil + } + + plannedIdentityVal, err := hcl2shim.HCL2ValueFromFlatmap(diff.Identity, identityBlock.ImpliedType()) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) + return resp, nil + } + + // If we're updating or deleting and we already have an identity stored, validate that the planned identity isn't changing + if !res.ResourceBehavior.MutableIdentity && !create && !priorIdentityVal.IsNull() && !priorIdentityVal.RawEquals(plannedIdentityVal) { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, fmt.Errorf( + "Unexpected Identity Change: During the planning operation, the Terraform Provider unexpectedly returned a different identity than the previously stored one.\n\n"+ + "This is always a problem with the provider and should be reported to the provider developer.\n\n"+ + "Prior Identity: %s\n\nPlanned Identity: %s", + priorIdentityVal.GoString(), + plannedIdentityVal.GoString(), + )) + + return resp, nil + } + + plannedIdentityMP, err := msgpack.Marshal(plannedIdentityVal, identityBlock.ImpliedType()) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) + return resp, nil + } + + resp.PlannedIdentity = &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: plannedIdentityMP, + }, + } + } + return resp, nil } @@ -1084,6 +1352,8 @@ func (s *GRPCProviderServer) ApplyResourceChange(ctx context.Context, req *tfpro return resp, nil } + create := priorStateVal.IsNull() + plannedStateVal, err := msgpack.Unmarshal(req.PlannedState.MsgPack, schemaBlock.ImpliedType()) if err != nil { resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) @@ -1110,6 +1380,28 @@ func (s *GRPCProviderServer) ApplyResourceChange(ctx context.Context, req *tfpro } } + var plannedIdentityVal cty.Value + // add identity data to priorState + if req.PlannedIdentity != nil && req.PlannedIdentity.IdentityData != nil { + // convert req.PriorIdentity to flat map identity structure + // Step 1: Turn JSON into cty.Value based on schema + identityBlock, err := s.getResourceIdentitySchemaBlock(req.TypeName) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, fmt.Errorf("getting identity schema failed for resource '%s': %w", req.TypeName, err)) + return resp, nil + } + + plannedIdentityVal, err = msgpack.Unmarshal(req.PlannedIdentity.IdentityData.MsgPack, identityBlock.ImpliedType()) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) + return resp, nil + } + // Step 2: Turn cty.Value into flatmap representation + identityAttrs := hcl2shim.FlatmapValueFromHCL2(plannedIdentityVal) + // Step 3: Well, set it in the priorState + priorState.Identity = identityAttrs + } + var diff *terraform.InstanceDiff destroy := false @@ -1123,6 +1415,7 @@ func (s *GRPCProviderServer) ApplyResourceChange(ctx context.Context, req *tfpro RawPlan: plannedStateVal, RawState: priorStateVal, RawConfig: configVal, + Identity: priorState.Identity, } } else { diff, err = DiffFromValues(ctx, priorStateVal, plannedStateVal, configVal, stripResourceModifiers(res)) @@ -1139,7 +1432,10 @@ func (s *GRPCProviderServer) ApplyResourceChange(ctx context.Context, req *tfpro RawPlan: plannedStateVal, RawState: priorStateVal, RawConfig: configVal, + Identity: priorState.Identity, } + } else { + diff.Identity = priorState.Identity } // add NewExtra Fields that may have been stored in the private data @@ -1235,6 +1531,44 @@ func (s *GRPCProviderServer) ApplyResourceChange(ctx context.Context, req *tfpro } resp.Private = meta + if res.Identity != nil { + identityBlock, err := s.getResourceIdentitySchemaBlock(req.TypeName) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, fmt.Errorf("getting identity schema failed for resource '%s': %w", req.TypeName, err)) + return resp, nil + } + + newIdentityVal, err := hcl2shim.HCL2ValueFromFlatmap(newInstanceState.Identity, identityBlock.ImpliedType()) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) + return resp, nil + } + + if !res.ResourceBehavior.MutableIdentity && !create && !plannedIdentityVal.IsNull() && !plannedIdentityVal.RawEquals(newIdentityVal) { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, fmt.Errorf( + "Unexpected Identity Change: During the update operation, the Terraform Provider unexpectedly returned a different identity than the previously stored one.\n\n"+ + "This is always a problem with the provider and should be reported to the provider developer.\n\n"+ + "Planned Identity: %s\n\nNew Identity: %s", + plannedIdentityVal.GoString(), + newIdentityVal.GoString(), + )) + + return resp, nil + } + + newIdentityMP, err := msgpack.Marshal(newIdentityVal, identityBlock.ImpliedType()) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) + return resp, nil + } + + resp.NewIdentity = &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: newIdentityMP, + }, + } + } + // This is a signal to Terraform Core that we're doing the best we can to // shim the legacy type system of the SDK onto the Terraform type system // but we need it to cut us some slack. This setting should not be taken @@ -1299,7 +1633,27 @@ func (s *GRPCProviderServer) ImportResourceState(ctx context.Context, req *tfpro return resp, nil } - newInstanceStates, err := s.provider.ImportState(ctx, info, req.ID) + var identity map[string]string + // parse identity data if available + if req.Identity != nil && req.Identity.IdentityData != nil { + // convert req.Identity to flat map identity structure + // Step 1: Turn JSON into cty.Value based on schema + identityBlock, err := s.getResourceIdentitySchemaBlock(req.TypeName) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, fmt.Errorf("getting identity schema failed for resource '%s': %w", req.TypeName, err)) + return resp, nil + } + + identityVal, err := msgpack.Unmarshal(req.Identity.IdentityData.MsgPack, identityBlock.ImpliedType()) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) + return resp, nil + } + // Step 2: Turn cty.Value into flatmap representation + identity = hcl2shim.FlatmapValueFromHCL2(identityVal) + } + + newInstanceStates, err := s.provider.ImportStateWithIdentity(ctx, info, req.ID, identity) if err != nil { resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) return resp, nil @@ -1349,18 +1703,50 @@ func (s *GRPCProviderServer) ImportResourceState(ctx context.Context, req *tfpro return resp, nil } + // Set an internal private field that will get sent alongside the imported resource. This will be cleared by + // the following ReadResource RPC and is primarily used to control validation of resource identities during refresh. + is.Meta[terraform.ImportBeforeReadMetaKey] = true + meta, err := json.Marshal(is.Meta) if err != nil { resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) return resp, nil } + var identityData *tfprotov5.ResourceIdentityData + if is.Identity != nil { + identityBlock, err := s.getResourceIdentitySchemaBlock(resourceType) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, fmt.Errorf("getting identity schema failed for resource '%s': %w", req.TypeName, err)) + return resp, nil + } + + newIdentityVal, err := hcl2shim.HCL2ValueFromFlatmap(is.Identity, identityBlock.ImpliedType()) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) + return resp, nil + } + + newIdentityMP, err := msgpack.Marshal(newIdentityVal, identityBlock.ImpliedType()) + if err != nil { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) + return resp, nil + } + + identityData = &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: newIdentityMP, + }, + } + } + importedResource := &tfprotov5.ImportedResource{ TypeName: resourceType, State: &tfprotov5.DynamicValue{ MsgPack: newStateMP, }, - Private: meta, + Private: meta, + Identity: identityData, } resp.ImportedResources = append(resp.ImportedResources, importedResource) @@ -1940,3 +2326,22 @@ func configureDeferralAllowed(in *tfprotov5.ConfigureProviderClientCapabilities) return in.DeferralAllowed } + +// Resource Identity version of upgradeJSONState +func (s *GRPCProviderServer) upgradeJSONIdentity(ctx context.Context, version int64, m map[string]interface{}, res *Resource) (map[string]interface{}, error) { + var err error + + for _, upgrader := range res.Identity.IdentityUpgraders { + if version != upgrader.Version { + continue + } + + m, err = upgrader.Upgrade(ctx, m, s.provider.Meta()) + if err != nil { + return nil, err + } + version++ + } + + return m, nil +} diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index bdb22ceba08..ab15715a951 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -3294,6 +3294,449 @@ func TestGRPCProviderServerConfigureProvider(t *testing.T) { } } +func TestGRPCProviderServerGetResourceIdentitySchemas(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + Provider *Provider + Expected *tfprotov5.GetResourceIdentitySchemasResponse + }{ + "resources": { + Provider: &Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource1": { + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "test": { + Type: TypeString, + RequiredForImport: true, + OptionalForImport: false, + Description: "test resource", + }, + } + }, + }, + }, + "test_resource2": { + Identity: &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "test2": { + Type: TypeString, + RequiredForImport: false, + OptionalForImport: true, + Description: "test resource 2", + }, + "test2-2": { + Type: TypeList, + RequiredForImport: false, + OptionalForImport: true, + Description: "test resource 2-2", + }, + "test2-3": { + Type: TypeInt, + RequiredForImport: false, + OptionalForImport: true, + Description: "test resource 2-3", + }, + } + }, + }, + }, + }, + }, + Expected: &tfprotov5.GetResourceIdentitySchemasResponse{ + IdentitySchemas: map[string]*tfprotov5.ResourceIdentitySchema{ + "test_resource1": { + Version: 1, + IdentityAttributes: []*tfprotov5.ResourceIdentitySchemaAttribute{ + { + Name: "test", + Type: tftypes.String, + RequiredForImport: true, + OptionalForImport: false, + Description: "test resource", + }, + }, + }, + "test_resource2": { + IdentityAttributes: []*tfprotov5.ResourceIdentitySchemaAttribute{ + { + Name: "test2", + Type: tftypes.String, + RequiredForImport: false, + OptionalForImport: true, + Description: "test resource 2", + }, + { + Name: "test2-2", + Type: tftypes.List{ElementType: tftypes.String}, + RequiredForImport: false, + OptionalForImport: true, + Description: "test resource 2-2", + }, + { + Name: "test2-3", + Type: tftypes.Number, + RequiredForImport: false, + OptionalForImport: true, + Description: "test resource 2-3", + }, + }, + }, + }, + }, + }, + "primitive attributes": { + Provider: &Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": { + Identity: &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "bool_attr": {Type: TypeBool, Description: "Boolean attribute"}, + "float_attr": {Type: TypeFloat, Description: "Float attribute"}, + "int_attr": {Type: TypeInt, Description: "Int attribute"}, + "list_bool_attr": {Type: TypeList, Elem: TypeBool, Description: "List Bool attribute"}, + "list_float_attr": {Type: TypeList, Elem: TypeFloat, Description: "List Float attribute"}, + "list_int_attr": {Type: TypeList, Elem: TypeInt, Description: "List Int attribute"}, + "list_str_attr": {Type: TypeList, Elem: TypeString, Description: "List String attribute"}, + "string_attr": {Type: TypeString, Description: "String attribute"}, + } + }, + }, + }, + }, + }, + Expected: &tfprotov5.GetResourceIdentitySchemasResponse{ + IdentitySchemas: map[string]*tfprotov5.ResourceIdentitySchema{ + "test_resource": { + IdentityAttributes: []*tfprotov5.ResourceIdentitySchemaAttribute{ + {Name: "bool_attr", Type: tftypes.Bool, Description: "Boolean attribute"}, + {Name: "float_attr", Type: tftypes.Number, Description: "Float attribute"}, + {Name: "int_attr", Type: tftypes.Number, Description: "Int attribute"}, + {Name: "list_bool_attr", Type: tftypes.List{ElementType: tftypes.Bool}, Description: "List Bool attribute"}, + {Name: "list_float_attr", Type: tftypes.List{ElementType: tftypes.Number}, Description: "List Float attribute"}, + {Name: "list_int_attr", Type: tftypes.List{ElementType: tftypes.Number}, Description: "List Int attribute"}, + {Name: "list_str_attr", Type: tftypes.List{ElementType: tftypes.String}, Description: "List String attribute"}, + {Name: "string_attr", Type: tftypes.String, Description: "String attribute"}, + }, + }, + }, + }, + }, + "no identity schema": { + Provider: &Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource1": { + Identity: &ResourceIdentity{ + Version: 1, + }, + }, + }, + }, + Expected: &tfprotov5.GetResourceIdentitySchemasResponse{ + IdentitySchemas: map[string]*tfprotov5.ResourceIdentitySchema{}, + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "getting identity schema failed for resource 'test_resource1': resource does not have an identity schema", + }, + }, + }, + }, + "empty identity schema": { + Provider: &Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource1": { + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{} + }, + }, + }, + }, + }, + Expected: &tfprotov5.GetResourceIdentitySchemasResponse{ + IdentitySchemas: map[string]*tfprotov5.ResourceIdentitySchema{}, + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "getting identity schema failed for resource 'test_resource1': identity schema must have at least one attribute", + }, + }, + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + server := NewGRPCProviderServer(testCase.Provider) + + testReq := &tfprotov5.GetResourceIdentitySchemasRequest{} + + resp, err := server.GetResourceIdentitySchemas(context.Background(), testReq) + + if err != nil { + t.Fatalf("unexpected gRPC error: %s", err) + } + + // Prevent false positives with random map access in testing + for _, schema := range resp.IdentitySchemas { + sort.Slice(schema.IdentityAttributes, func(i int, j int) bool { + return schema.IdentityAttributes[i].Name < schema.IdentityAttributes[j].Name + }) + } + + if diff := cmp.Diff(resp, testCase.Expected); diff != "" { + t.Errorf("unexpected response difference: %s", diff) + } + }) + } +} + +// Based on TestUpgradeState_jsonState +func TestUpgradeResourceIdentity_jsonState(t *testing.T) { + r := &Resource{ + SchemaVersion: 1, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "id": { + Type: TypeString, + RequiredForImport: true, + OptionalForImport: false, + Description: "id of thing", + }, + } + }, + IdentityUpgraders: []IdentityUpgrader{ + { + Version: 0, + Type: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "identity": tftypes.String, + }, + }, + // upgrades former identity using "identity" as the attribute name to the new and shiny one just using "id" + Upgrade: func(ctx context.Context, rawState map[string]interface{}, meta interface{}) (map[string]interface{}, error) { + id, ok := rawState["identity"].(string) + if !ok { + return nil, fmt.Errorf("identity not found in %#v", rawState) + } + rawState["id"] = id + delete(rawState, "identity") + return rawState, nil + }, + }, + }, + }, + } + + server := NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": r, + }, + }) + + req := &tfprotov5.UpgradeResourceIdentityRequest{ + TypeName: "test", + Version: 0, + RawIdentity: &tfprotov5.RawState{ + JSON: []byte(`{"identity":"Peter"}`), + }, + } + + resp, err := server.UpgradeResourceIdentity(context.Background(), req) + if err != nil { + t.Fatal(err) + } + + if len(resp.Diagnostics) > 0 { + for _, d := range resp.Diagnostics { + t.Errorf("%#v", d) + } + t.Fatal("error") + } + + idschema, err := r.CoreIdentitySchema() + + if err != nil { + t.Fatal(err) + } + + val, err := msgpack.Unmarshal(resp.UpgradedIdentity.IdentityData.MsgPack, idschema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + expected := cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("Peter"), + }) + + if !cmp.Equal(expected, val, valueComparer, equateEmpty) { + t.Fatal(cmp.Diff(expected, val, valueComparer, equateEmpty)) + } +} + +// Based on TestUpgradeState_removedAttr +func TestUpgradeResourceIdentity_removedAttr(t *testing.T) { + r := &Resource{ + SchemaVersion: 1, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "id": { + Type: TypeString, + RequiredForImport: true, + OptionalForImport: false, + Description: "id of thing", + }, + } + }, + IdentityUpgraders: []IdentityUpgrader{ + { + Version: 0, + Type: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "identity": tftypes.String, + "removed": tftypes.String, + }, + }, + Upgrade: func(ctx context.Context, rawState map[string]interface{}, meta interface{}) (map[string]interface{}, error) { + id, ok := rawState["identity"].(string) + if !ok { + return nil, fmt.Errorf("identity not found in %#v", rawState) + } + rawState["id"] = id + delete(rawState, "identity") + delete(rawState, "removed") + return rawState, nil + }, + }, + }, + }, + } + + server := NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": r, + }, + }) + + req := &tfprotov5.UpgradeResourceIdentityRequest{ + TypeName: "test", + Version: 0, + RawIdentity: &tfprotov5.RawState{ + JSON: []byte(`{"identity":"Peter", "removed":"to_be_removed"}`), + }, + } + + resp, err := server.UpgradeResourceIdentity(context.Background(), req) + if err != nil { + t.Fatal(err) + } + + if len(resp.Diagnostics) > 0 { + for _, d := range resp.Diagnostics { + t.Errorf("%#v", d) + } + t.Fatal("error") + } + + idschema, err := r.CoreIdentitySchema() + if err != nil { + t.Fatal(err) + } + + val, err := msgpack.Unmarshal(resp.UpgradedIdentity.IdentityData.MsgPack, idschema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + expected := cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("Peter"), + }) + + if !cmp.Equal(expected, val, valueComparer, equateEmpty) { + t.Fatal(cmp.Diff(expected, val, valueComparer, equateEmpty)) + } +} + +// Based on TestUpgradeState_jsonStateBigInt +// This test currently does not return the integer and does not recognize it as an attribute +func TestUpgradeResourceIdentity_jsonStateBigInt(t *testing.T) { + r := &Resource{ + UseJSONNumber: true, + SchemaVersion: 1, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "int": { + Type: TypeInt, + RequiredForImport: true, + OptionalForImport: false, + Description: "", + }, + } + }, + }, + } + + server := NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": r, + }, + }) + + req := &tfprotov5.UpgradeResourceIdentityRequest{ + TypeName: "test", + Version: 0, + RawIdentity: &tfprotov5.RawState{ + JSON: []byte(`{"int":7227701560655103598}`), + }, + } + + resp, err := server.UpgradeResourceIdentity(context.Background(), req) + if err != nil { + t.Fatal(err) + } + + if len(resp.Diagnostics) > 0 { + for _, d := range resp.Diagnostics { + t.Errorf("%#v", d) + } + t.Fatal("error") + } + + idschema, err := r.CoreIdentitySchema() + if err != nil { + t.Fatal(err) + } + + val, err := msgpack.Unmarshal(resp.UpgradedIdentity.IdentityData.MsgPack, idschema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + expected := cty.ObjectVal(map[string]cty.Value{ + "int": cty.NumberIntVal(7227701560655103598), + }) + + if !cmp.Equal(expected, val, valueComparer, equateEmpty) { + t.Fatal(cmp.Diff(expected, val, valueComparer, equateEmpty)) + } +} + func TestGRPCProviderServerGetMetadata(t *testing.T) { t.Parallel() @@ -4625,8 +5068,33 @@ func TestReadResource(t *testing.T) { Type: TypeString, Computed: true, }, - }, - ReadContext: func(ctx context.Context, d *ResourceData, meta interface{}) diag.Diagnostics { + "test_list": { + Type: TypeList, + Elem: &Schema{ + Type: TypeString, + }, + Computed: true, + }, + }, + ResourceBehavior: ResourceBehavior{ + MutableIdentity: true, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "instance_id": { + Type: TypeString, + RequiredForImport: true, + }, + "region": { + Type: TypeString, + OptionalForImport: true, + }, + } + }, + }, + ReadContext: func(ctx context.Context, d *ResourceData, meta interface{}) diag.Diagnostics { err := d.Set("test_bool", true) if err != nil { return diag.FromErr(err) @@ -4637,6 +5105,15 @@ func TestReadResource(t *testing.T) { return diag.FromErr(err) } + identity, err := d.Identity() + if err != nil { + return diag.FromErr(err) + } + err = identity.Set("region", "new-region") + if err != nil { + return diag.FromErr(err) + } + return nil }, }, @@ -4644,17 +5121,36 @@ func TestReadResource(t *testing.T) { }), req: &tfprotov5.ReadResourceRequest{ TypeName: "test", + CurrentIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "instance_id": cty.String, + "region": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "instance_id": cty.StringVal("test-id"), + "region": cty.StringVal("test-region"), + }), + ), + }, + }, CurrentState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ "id": cty.String, "test_bool": cty.Bool, "test_string": cty.String, + "test_list": cty.List(cty.String), }), cty.ObjectVal(map[string]cty.Value{ "id": cty.StringVal("test-id"), "test_bool": cty.BoolVal(false), "test_string": cty.StringVal("prior-state-val"), + "test_list": cty.ListVal([]cty.Value{ + cty.StringVal("hello"), + cty.StringVal("world"), + }), }), ), }, @@ -4666,15 +5162,127 @@ func TestReadResource(t *testing.T) { "id": cty.String, "test_bool": cty.Bool, "test_string": cty.String, + "test_list": cty.List(cty.String), }), cty.ObjectVal(map[string]cty.Value{ "id": cty.StringVal("test-id"), "test_bool": cty.BoolVal(true), "test_string": cty.StringVal("new-state-val"), + "test_list": cty.ListVal([]cty.Value{ + cty.StringVal("hello"), + cty.StringVal("world"), + }), + }), + ), + }, + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "instance_id": cty.String, + "region": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "instance_id": cty.StringVal("test-id"), + "region": cty.StringVal("new-region"), + }), + ), + }, + }, + }, + }, + "no-identity-schema": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 1, + Identity: &ResourceIdentity{ + Version: 1, + }, + }, + }, + }), + req: &tfprotov5.ReadResourceRequest{ + TypeName: "test", + CurrentIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "instance_id": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "instance_id": cty.StringVal("test-id"), + }), + ), + }, + }, + CurrentState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test-id"), }), ), }, }, + expected: &tfprotov5.ReadResourceResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "getting identity schema failed for resource 'test': resource does not have an identity schema", + }, + }, + }, + }, + "empty-identity": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 1, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{} + }, + }, + }, + }, + }), + req: &tfprotov5.ReadResourceRequest{ + TypeName: "test", + CurrentIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "instance_id": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "instance_id": cty.StringVal("test-id"), + }), + ), + }, + }, + CurrentState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test-id"), + }), + ), + }, + }, + expected: &tfprotov5.ReadResourceResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "getting identity schema failed for resource 'test': identity schema must have at least one attribute", + }, + }, + }, }, "deferred-response-unknown-val": { server: NewGRPCProviderServer(&Provider{ @@ -4827,382 +5435,615 @@ func TestReadResource(t *testing.T) { }, }, }, - } - - for name, testCase := range testCases { - t.Run(name, func(t *testing.T) { - t.Parallel() - resp, err := testCase.server.ReadResource(context.Background(), testCase.req) - - if err != nil { - t.Fatal(err) - } - - if diff := cmp.Diff(resp, testCase.expected, valueComparer); diff != "" { - ty := testCase.server.getResourceSchemaBlock("test").ImpliedType() - - if resp != nil && resp.NewState != nil { - t.Logf("resp.NewState.MsgPack: %s", mustMsgpackUnmarshal(ty, resp.NewState.MsgPack)) - } - - if testCase.expected != nil && testCase.expected.NewState != nil { - t.Logf("expected: %s", mustMsgpackUnmarshal(ty, testCase.expected.NewState.MsgPack)) - } - - t.Error(diff) - } - }) - } -} - -func TestPlanResourceChange(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - server *GRPCProviderServer - req *tfprotov5.PlanResourceChangeRequest - expected *tfprotov5.PlanResourceChangeResponse - }{ - "basic-plan": { + "update-resource-without-prior-identity-identity-may-change": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ "test": { - SchemaVersion: 4, + SchemaVersion: 1, Schema: map[string]*Schema{ - "foo": { - Type: TypeInt, - Optional: true, + "id": { + Type: TypeString, + Required: true, + }, + "test": { + Type: TypeString, + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "identity": { + Type: TypeString, + RequiredForImport: true, + }, + } }, }, + ReadContext: func(ctx context.Context, d *ResourceData, meta interface{}) diag.Diagnostics { + identity, err := d.Identity() + if err != nil { + return diag.FromErr(err) + } + err = identity.Set("identity", "changed") + if err != nil { + return diag.FromErr(err) + } + + return nil + }, }, }, }), - req: &tfprotov5.PlanResourceChangeRequest{ - TypeName: "test", - PriorState: &tfprotov5.DynamicValue{ - MsgPack: mustMsgpackMarshal( - cty.Object(map[string]cty.Type{ - "foo": cty.Number, - }), - cty.NullVal( - cty.Object(map[string]cty.Type{ - "foo": cty.Number, - }), - ), - ), - }, - ProposedNewState: &tfprotov5.DynamicValue{ - MsgPack: mustMsgpackMarshal( - cty.Object(map[string]cty.Type{ - "id": cty.String, - "foo": cty.Number, - }), - cty.ObjectVal(map[string]cty.Value{ - "id": cty.UnknownVal(cty.String), - "foo": cty.NullVal(cty.Number), - }), - ), - }, - Config: &tfprotov5.DynamicValue{ + req: &tfprotov5.ReadResourceRequest{ + TypeName: "test", + CurrentIdentity: nil, // no prior identity because previous provider version didn't support it yet + CurrentState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "foo": cty.Number, + "test": cty.String, + "id": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.NullVal(cty.String), - "foo": cty.NullVal(cty.Number), + "test": cty.StringVal("hello"), + "id": cty.StringVal("initial"), }), ), }, }, - expected: &tfprotov5.PlanResourceChangeResponse{ - PlannedState: &tfprotov5.DynamicValue{ + expected: &tfprotov5.ReadResourceResponse{ + NewState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "foo": cty.Number, + "id": cty.String, + "test": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.UnknownVal(cty.String), - "foo": cty.NullVal(cty.Number), + "id": cty.StringVal("initial"), + "test": cty.StringVal("hello"), }), ), }, - RequiresReplace: []*tftypes.AttributePath{ - tftypes.NewAttributePath().WithAttributeName("id"), + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("changed"), + }), + ), + }, }, - PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), - UnsafeToUseLegacyTypeSystem: true, }, }, - "basic-plan-EnableLegacyTypeSystemPlanErrors": { + "imported-resource-by-identity-identity-may-change": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ "test": { - // Will set UnsafeToUseLegacyTypeSystem to false - EnableLegacyTypeSystemPlanErrors: true, + SchemaVersion: 1, Schema: map[string]*Schema{ - "foo": { - Type: TypeInt, - Optional: true, + "id": { + Type: TypeString, + Required: true, + }, + "test": { + Type: TypeString, + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "identity": { + Type: TypeString, + RequiredForImport: true, + }, + } }, }, + ReadContext: func(ctx context.Context, d *ResourceData, meta interface{}) diag.Diagnostics { + err := d.Set("test", "hello") + if err != nil { + return diag.FromErr(err) + } + + identity, err := d.Identity() + if err != nil { + return diag.FromErr(err) + } + err = identity.Set("identity", "changed") + if err != nil { + return diag.FromErr(err) + } + + return nil + }, }, }, }), - req: &tfprotov5.PlanResourceChangeRequest{ + req: &tfprotov5.ReadResourceRequest{ TypeName: "test", - PriorState: &tfprotov5.DynamicValue{ - MsgPack: mustMsgpackMarshal( - cty.Object(map[string]cty.Type{ - "foo": cty.Number, - }), - cty.NullVal( + CurrentIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "foo": cty.Number, + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("initial"), }), ), + }, + }, + Private: []byte(`{".import_before_read":true}`), + CurrentState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("initial"), + "test": cty.UnknownVal(cty.String), + }), ), }, - ProposedNewState: &tfprotov5.DynamicValue{ + }, + expected: &tfprotov5.ReadResourceResponse{ + NewState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "foo": cty.Number, + "id": cty.String, + "test": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.UnknownVal(cty.String), - "foo": cty.NullVal(cty.Number), + "id": cty.StringVal("initial"), + "test": cty.StringVal("hello"), }), ), }, - Config: &tfprotov5.DynamicValue{ + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("changed"), + }), + ), + }, + }, + }, + }, + "imported-resource-by-id-identity-may-change": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 1, + Schema: map[string]*Schema{ + "id": { + Type: TypeString, + Required: true, + }, + "test": { + Type: TypeString, + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "identity": { + Type: TypeString, + RequiredForImport: true, + }, + } + }, + }, + ReadContext: func(ctx context.Context, d *ResourceData, meta interface{}) diag.Diagnostics { + err := d.Set("test", "hello") + if err != nil { + return diag.FromErr(err) + } + + identity, err := d.Identity() + if err != nil { + return diag.FromErr(err) + } + err = identity.Set("identity", "changed") + if err != nil { + return diag.FromErr(err) + } + + return nil + }, + }, + }, + }), + req: &tfprotov5.ReadResourceRequest{ + TypeName: "test", + CurrentIdentity: nil, + Private: []byte(`{".import_before_read":true}`), + CurrentState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "foo": cty.Number, + "id": cty.String, + "test": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.NullVal(cty.String), - "foo": cty.NullVal(cty.Number), + "id": cty.StringVal("initial"), + "test": cty.UnknownVal(cty.String), }), ), }, }, - expected: &tfprotov5.PlanResourceChangeResponse{ - PlannedState: &tfprotov5.DynamicValue{ + expected: &tfprotov5.ReadResourceResponse{ + NewState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "foo": cty.Number, + "id": cty.String, + "test": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.UnknownVal(cty.String), - "foo": cty.NullVal(cty.Number), + "id": cty.StringVal("initial"), + "test": cty.StringVal("hello"), }), ), }, - RequiresReplace: []*tftypes.AttributePath{ - tftypes.NewAttributePath().WithAttributeName("id"), + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("changed"), + }), + ), + }, }, - PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), - UnsafeToUseLegacyTypeSystem: false, }, }, - "deferred-with-provider-plan-modification": { + "update-resource-identity-may-not-change": { server: NewGRPCProviderServer(&Provider{ - providerDeferred: &Deferred{ - Reason: DeferredReasonProviderConfigUnknown, - }, ResourcesMap: map[string]*Resource{ "test": { - ResourceBehavior: ResourceBehavior{ - ProviderDeferred: ProviderDeferredBehavior{ - // Will ensure that CustomizeDiff is called - EnablePlanModification: true, - }, - }, - SchemaVersion: 4, - CustomizeDiff: func(ctx context.Context, d *ResourceDiff, i interface{}) error { - return d.SetNew("foo", "new-foo-value") - }, + SchemaVersion: 1, Schema: map[string]*Schema{ - "foo": { + "id": { Type: TypeString, - Optional: true, - Computed: true, + Required: true, }, + "test": { + Type: TypeString, + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "identity": { + Type: TypeString, + RequiredForImport: true, + }, + } + }, + }, + ReadContext: func(ctx context.Context, d *ResourceData, meta interface{}) diag.Diagnostics { + identity, err := d.Identity() + if err != nil { + return diag.FromErr(err) + } + err = identity.Set("identity", "changed") + if err != nil { + return diag.FromErr(err) + } + + return nil }, }, }, }), - req: &tfprotov5.PlanResourceChangeRequest{ + req: &tfprotov5.ReadResourceRequest{ TypeName: "test", - ClientCapabilities: &tfprotov5.PlanResourceChangeClientCapabilities{ - DeferralAllowed: true, - }, - PriorState: &tfprotov5.DynamicValue{ - MsgPack: mustMsgpackMarshal( - cty.Object(map[string]cty.Type{ - "foo": cty.String, - }), - cty.NullVal( + CurrentIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "foo": cty.String, + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("initial"), }), ), - ), - }, - ProposedNewState: &tfprotov5.DynamicValue{ - MsgPack: mustMsgpackMarshal( - cty.Object(map[string]cty.Type{ - "id": cty.String, - "foo": cty.String, - }), - cty.ObjectVal(map[string]cty.Value{ - "id": cty.UnknownVal(cty.String), - "foo": cty.UnknownVal(cty.String), - }), - ), + }, }, - Config: &tfprotov5.DynamicValue{ + CurrentState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "foo": cty.String, + "test": cty.String, + "id": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.NullVal(cty.String), - "foo": cty.NullVal(cty.String), + "test": cty.StringVal("hello"), + "id": cty.StringVal("initial"), }), ), }, }, - expected: &tfprotov5.PlanResourceChangeResponse{ - Deferred: &tfprotov5.Deferred{ - Reason: tfprotov5.DeferredReasonProviderConfigUnknown, - }, - PlannedState: &tfprotov5.DynamicValue{ + expected: &tfprotov5.ReadResourceResponse{ + NewState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "foo": cty.String, + "id": cty.String, + "test": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.UnknownVal(cty.String), - "foo": cty.StringVal("new-foo-value"), + "id": cty.StringVal("initial"), + "test": cty.StringVal("hello"), }), ), }, - RequiresReplace: []*tftypes.AttributePath{ - tftypes.NewAttributePath().WithAttributeName("id"), + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: (`Unexpected Identity Change: During the read operation, the Terraform Provider unexpectedly returned a different identity then the previously stored one. + +This is always a problem with the provider and should be reported to the provider developer. + +Current Identity: cty.ObjectVal(map[string]cty.Value{"identity":cty.StringVal("initial")}) + +New Identity: cty.ObjectVal(map[string]cty.Value{"identity":cty.StringVal("changed")})`), + }, }, - PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), - UnsafeToUseLegacyTypeSystem: true, }, }, - "deferred-skip-plan-modification": { + "update-resource-identity-may-change-if-mutable-identity-allowed": { server: NewGRPCProviderServer(&Provider{ - providerDeferred: &Deferred{ - Reason: DeferredReasonProviderConfigUnknown, - }, ResourcesMap: map[string]*Resource{ "test": { - SchemaVersion: 4, - CustomizeDiff: func(ctx context.Context, d *ResourceDiff, i interface{}) error { - return errors.New("Test assertion failed: CustomizeDiff shouldn't be called") + ResourceBehavior: ResourceBehavior{ + MutableIdentity: true, }, + SchemaVersion: 1, Schema: map[string]*Schema{ - "foo": { + "id": { Type: TypeString, - Optional: true, - Computed: true, + Required: true, + }, + "test": { + Type: TypeString, + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "identity": { + Type: TypeString, + RequiredForImport: true, + }, + } }, }, + ReadContext: func(ctx context.Context, d *ResourceData, meta interface{}) diag.Diagnostics { + identity, err := d.Identity() + if err != nil { + return diag.FromErr(err) + } + err = identity.Set("identity", "changed") + if err != nil { + return diag.FromErr(err) + } + + return nil + }, }, }, }), - req: &tfprotov5.PlanResourceChangeRequest{ + req: &tfprotov5.ReadResourceRequest{ TypeName: "test", - ClientCapabilities: &tfprotov5.PlanResourceChangeClientCapabilities{ - DeferralAllowed: true, - }, - PriorState: &tfprotov5.DynamicValue{ - MsgPack: mustMsgpackMarshal( - cty.Object(map[string]cty.Type{ - "foo": cty.String, - }), - cty.NullVal( + CurrentIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "foo": cty.String, + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("initial"), }), ), - ), + }, }, - ProposedNewState: &tfprotov5.DynamicValue{ + CurrentState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "foo": cty.String, + "test": cty.String, + "id": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.UnknownVal(cty.String), - "foo": cty.StringVal("from-config!"), + "test": cty.StringVal("hello"), + "id": cty.StringVal("initial"), }), ), }, - Config: &tfprotov5.DynamicValue{ + }, + expected: &tfprotov5.ReadResourceResponse{ + NewState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "foo": cty.String, + "id": cty.String, + "test": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.NullVal(cty.String), - "foo": cty.StringVal("from-config!"), + "id": cty.StringVal("initial"), + "test": cty.StringVal("hello"), }), ), }, + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("changed"), + }), + ), + }, + }, }, - expected: &tfprotov5.PlanResourceChangeResponse{ - Deferred: &tfprotov5.Deferred{ - Reason: tfprotov5.DeferredReasonProviderConfigUnknown, + }, + "does-not-remove-user-data-from-private": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 1, + Schema: map[string]*Schema{ + "id": { + Type: TypeString, + Required: true, + }, + "test": { + Type: TypeString, + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "identity": { + Type: TypeString, + RequiredForImport: true, + }, + } + }, + }, + ReadContext: func(ctx context.Context, d *ResourceData, meta interface{}) diag.Diagnostics { + err := d.Set("test", "hello") + if err != nil { + return diag.FromErr(err) + } + + identity, err := d.Identity() + if err != nil { + return diag.FromErr(err) + } + err = identity.Set("identity", "changed") + if err != nil { + return diag.FromErr(err) + } + + return nil + }, + }, }, - // Returns proposed new state with deferred response - PlannedState: &tfprotov5.DynamicValue{ + }), + req: &tfprotov5.ReadResourceRequest{ + TypeName: "test", + CurrentIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("initial"), + }), + ), + }, + }, + Private: []byte(`{".import_before_read":true,"user_defined_key":"user_defined_value"}`), + CurrentState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "foo": cty.String, + "id": cty.String, + "test": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.UnknownVal(cty.String), - "foo": cty.StringVal("from-config!"), + "id": cty.StringVal("initial"), + "test": cty.UnknownVal(cty.String), }), ), }, - UnsafeToUseLegacyTypeSystem: true, + }, + expected: &tfprotov5.ReadResourceResponse{ + NewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("initial"), + "test": cty.StringVal("hello"), + }), + ), + }, + Private: []byte(`{"user_defined_key":"user_defined_value"}`), + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("changed"), + }), + ), + }, + }, }, }, - "create: write-only value can be retrieved in CustomizeDiff": { + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + resp, err := testCase.server.ReadResource(context.Background(), testCase.req) + + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(resp, testCase.expected, valueComparer); diff != "" { + ty := testCase.server.getResourceSchemaBlock("test").ImpliedType() + + if resp != nil && resp.NewState != nil { + t.Logf("resp.NewState.MsgPack: %s", mustMsgpackUnmarshal(ty, resp.NewState.MsgPack)) + } + + if testCase.expected != nil && testCase.expected.NewState != nil { + t.Logf("expected: %s", mustMsgpackUnmarshal(ty, testCase.expected.NewState.MsgPack)) + } + + t.Error(diff) + } + }) + } +} + +func TestPlanResourceChange(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + server *GRPCProviderServer + req *tfprotov5.PlanResourceChangeRequest + expected *tfprotov5.PlanResourceChangeResponse + }{ + "basic-plan": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ "test": { SchemaVersion: 4, - CustomizeDiff: func(ctx context.Context, d *ResourceDiff, i interface{}) error { - val := d.Get("foo") - if val != "bar" { - t.Fatalf("Incorrect write-only value") - } - - return nil - }, Schema: map[string]*Schema{ "foo": { - Type: TypeString, - Optional: true, - WriteOnly: true, + Type: TypeInt, + Optional: true, }, }, }, @@ -5213,11 +6054,11 @@ func TestPlanResourceChange(t *testing.T) { PriorState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "foo": cty.String, + "foo": cty.Number, }), cty.NullVal( cty.Object(map[string]cty.Type{ - "foo": cty.String, + "foo": cty.Number, }), ), ), @@ -5226,11 +6067,11 @@ func TestPlanResourceChange(t *testing.T) { MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ "id": cty.String, - "foo": cty.String, + "foo": cty.Number, }), cty.ObjectVal(map[string]cty.Value{ "id": cty.UnknownVal(cty.String), - "foo": cty.StringVal("bar"), + "foo": cty.NullVal(cty.Number), }), ), }, @@ -5238,11 +6079,11 @@ func TestPlanResourceChange(t *testing.T) { MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ "id": cty.String, - "foo": cty.String, + "foo": cty.Number, }), cty.ObjectVal(map[string]cty.Value{ "id": cty.NullVal(cty.String), - "foo": cty.StringVal("bar"), + "foo": cty.NullVal(cty.Number), }), ), }, @@ -5252,36 +6093,41 @@ func TestPlanResourceChange(t *testing.T) { MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ "id": cty.String, - "foo": cty.String, + "foo": cty.Number, }), cty.ObjectVal(map[string]cty.Value{ "id": cty.UnknownVal(cty.String), - "foo": cty.NullVal(cty.String), + "foo": cty.NullVal(cty.Number), }), ), }, - PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), RequiresReplace: []*tftypes.AttributePath{ tftypes.NewAttributePath().WithAttributeName("id"), }, + PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), UnsafeToUseLegacyTypeSystem: true, }, }, - "create: write-only values are nullified in PlanResourceChangeResponse": { + "basic-plan-with-identity": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ "test": { SchemaVersion: 4, Schema: map[string]*Schema{ "foo": { - Type: TypeString, - Optional: true, - WriteOnly: true, + Type: TypeInt, + Optional: true, }, - "bar": { - Type: TypeString, - Optional: true, - WriteOnly: true, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "name": { + Type: TypeString, + RequiredForImport: true, + }, + } }, }, }, @@ -5292,13 +6138,11 @@ func TestPlanResourceChange(t *testing.T) { PriorState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, + "foo": cty.Number, }), cty.NullVal( cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, + "foo": cty.Number, }), ), ), @@ -5307,13 +6151,11 @@ func TestPlanResourceChange(t *testing.T) { MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ "id": cty.String, - "foo": cty.String, - "bar": cty.String, + "foo": cty.Number, }), cty.ObjectVal(map[string]cty.Value{ "id": cty.UnknownVal(cty.String), - "foo": cty.StringVal("baz"), - "bar": cty.StringVal("boop"), + "foo": cty.NullVal(cty.Number), }), ), }, @@ -5321,63 +6163,92 @@ func TestPlanResourceChange(t *testing.T) { MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ "id": cty.String, - "foo": cty.String, - "bar": cty.String, + "foo": cty.Number, }), cty.ObjectVal(map[string]cty.Value{ "id": cty.NullVal(cty.String), - "foo": cty.StringVal("baz"), - "bar": cty.StringVal("boop"), + "foo": cty.NullVal(cty.Number), }), ), }, + PriorIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "name": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("test-name"), + }), + ), + }, + }, }, expected: &tfprotov5.PlanResourceChangeResponse{ PlannedState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ "id": cty.String, - "foo": cty.String, - "bar": cty.String, + "foo": cty.Number, }), cty.ObjectVal(map[string]cty.Value{ "id": cty.UnknownVal(cty.String), - "foo": cty.NullVal(cty.String), - "bar": cty.NullVal(cty.String), + "foo": cty.NullVal(cty.Number), }), ), }, - PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), RequiresReplace: []*tftypes.AttributePath{ tftypes.NewAttributePath().WithAttributeName("id"), }, + PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), UnsafeToUseLegacyTypeSystem: true, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "name": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("test-name"), + }), + ), + }, + }, }, }, - "update: write-only value can be retrieved in CustomizeDiff": { + "new-resource-with-identity": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ "test": { SchemaVersion: 4, - CustomizeDiff: func(ctx context.Context, d *ResourceDiff, i interface{}) error { - val := d.Get("write_only") - if val != "bar" { - t.Fatalf("Incorrect write-only value") - } - - return nil - }, Schema: map[string]*Schema{ - "configured": { + "foo": { Type: TypeString, Optional: true, }, - "write_only": { - Type: TypeString, - Optional: true, - WriteOnly: true, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "name": { + Type: TypeString, + RequiredForImport: true, + }, + } }, }, + CustomizeDiff: func(ctx context.Context, d *ResourceDiff, meta interface{}) error { + identity, err := d.Identity() + if err != nil { + return err + } + err = identity.Set("name", "Peter") + if err != nil { + return err + } + return nil + }, }, }, }), @@ -5386,42 +6257,36 @@ func TestPlanResourceChange(t *testing.T) { PriorState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "configured": cty.String, - "write_only": cty.String, + "id": cty.String, + "foo": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.NullVal(cty.String), - "configured": cty.StringVal("prior_val"), - "write_only": cty.NullVal(cty.String), + "id": cty.UnknownVal(cty.String), + "foo": cty.StringVal("baz"), }), ), }, ProposedNewState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "configured": cty.String, - "write_only": cty.String, + "id": cty.String, + "foo": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.UnknownVal(cty.String), - "configured": cty.StringVal("updated_val"), - "write_only": cty.StringVal("bar"), + "id": cty.UnknownVal(cty.String), + "foo": cty.StringVal("baz"), }), ), }, Config: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "configured": cty.String, - "write_only": cty.String, + "id": cty.String, + "foo": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.NullVal(cty.String), - "configured": cty.StringVal("updated_val"), - "write_only": cty.StringVal("bar"), + "id": cty.NullVal(cty.String), + "foo": cty.StringVal("baz"), }), ), }, @@ -5430,44 +6295,47 @@ func TestPlanResourceChange(t *testing.T) { PlannedState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "configured": cty.String, - "write_only": cty.String, + "id": cty.String, + "foo": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.UnknownVal(cty.String), - "configured": cty.StringVal("updated_val"), - "write_only": cty.NullVal(cty.String), + "id": cty.UnknownVal(cty.String), + "foo": cty.StringVal("baz"), }), ), }, - PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), RequiresReplace: []*tftypes.AttributePath{ tftypes.NewAttributePath().WithAttributeName("id"), }, + PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), UnsafeToUseLegacyTypeSystem: true, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "name": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("Peter"), + }), + ), + }, + }, }, }, - "update: write-only values are nullified in PlanResourceChangeResponse": { + "no identity schema": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ "test": { SchemaVersion: 4, Schema: map[string]*Schema{ - "configured": { - Type: TypeString, + "foo": { + Type: TypeInt, Optional: true, }, - "write_onlyA": { - Type: TypeString, - Optional: true, - WriteOnly: true, - }, - "write_onlyB": { - Type: TypeString, - Optional: true, - WriteOnly: true, - }, + }, + Identity: &ResourceIdentity{ + Version: 1, }, }, }, @@ -5477,257 +6345,2334 @@ func TestPlanResourceChange(t *testing.T) { PriorState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "configured": cty.String, - "write_onlyA": cty.String, - "write_onlyB": cty.String, - }), - cty.ObjectVal(map[string]cty.Value{ - "id": cty.NullVal(cty.String), - "configured": cty.StringVal("prior_val"), - "write_onlyA": cty.NullVal(cty.String), - "write_onlyB": cty.NullVal(cty.String), + "foo": cty.Number, }), + cty.NullVal( + cty.Object(map[string]cty.Type{ + "foo": cty.Number, + }), + ), ), }, ProposedNewState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "configured": cty.String, - "write_onlyA": cty.String, - "write_onlyB": cty.String, + "id": cty.String, + "foo": cty.Number, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.UnknownVal(cty.String), - "configured": cty.StringVal("updated_val"), - "write_onlyA": cty.StringVal("foo"), - "write_onlyB": cty.StringVal("bar"), + "id": cty.UnknownVal(cty.String), + "foo": cty.NullVal(cty.Number), }), ), }, Config: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "configured": cty.String, - "write_onlyA": cty.String, - "write_onlyB": cty.String, + "id": cty.String, + "foo": cty.Number, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.NullVal(cty.String), - "configured": cty.StringVal("updated_val"), - "write_onlyA": cty.StringVal("foo"), - "write_onlyB": cty.StringVal("bar"), + "id": cty.NullVal(cty.String), + "foo": cty.NullVal(cty.Number), }), ), }, + PriorIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "name": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("test-name"), + }), + ), + }, + }, }, expected: &tfprotov5.PlanResourceChangeResponse{ - PlannedState: &tfprotov5.DynamicValue{ - MsgPack: mustMsgpackMarshal( - cty.Object(map[string]cty.Type{ - "id": cty.String, - "configured": cty.String, - "write_onlyA": cty.String, - "write_onlyB": cty.String, - }), - cty.ObjectVal(map[string]cty.Value{ - "id": cty.UnknownVal(cty.String), - "configured": cty.StringVal("updated_val"), - "write_onlyA": cty.NullVal(cty.String), - "write_onlyB": cty.NullVal(cty.String), - }), - ), - }, - PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), - RequiresReplace: []*tftypes.AttributePath{ - tftypes.NewAttributePath().WithAttributeName("id"), + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "getting identity schema failed for resource 'test': resource does not have an identity schema", + }, }, UnsafeToUseLegacyTypeSystem: true, }, }, - } - - for name, testCase := range testCases { - t.Run(name, func(t *testing.T) { - t.Parallel() - - resp, err := testCase.server.PlanResourceChange(context.Background(), testCase.req) - if err != nil { - t.Fatal(err) - } - - if diff := cmp.Diff(resp, testCase.expected, valueComparer); diff != "" { - ty := testCase.server.getResourceSchemaBlock("test").ImpliedType() - - if resp != nil && resp.PlannedState != nil { - t.Logf("resp.PlannedState.MsgPack: %s", mustMsgpackUnmarshal(ty, resp.PlannedState.MsgPack)) - } - - if testCase.expected != nil && testCase.expected.PlannedState != nil { - t.Logf("expected: %s", mustMsgpackUnmarshal(ty, testCase.expected.PlannedState.MsgPack)) - } - - t.Error(diff) - } - }) - } -} - -func TestPlanResourceChange_bigint(t *testing.T) { - r := &Resource{ - UseJSONNumber: true, - Schema: map[string]*Schema{ - "foo": { - Type: TypeInt, - Required: true, - }, - }, - } - - server := NewGRPCProviderServer(&Provider{ - ResourcesMap: map[string]*Resource{ - "test": r, - }, - }) - - schema := r.CoreConfigSchema() - priorState, err := msgpack.Marshal(cty.NullVal(schema.ImpliedType()), schema.ImpliedType()) - if err != nil { - t.Fatal(err) - } - - proposedVal := cty.ObjectVal(map[string]cty.Value{ - "id": cty.UnknownVal(cty.String), - "foo": cty.MustParseNumberVal("7227701560655103598"), - }) - proposedState, err := msgpack.Marshal(proposedVal, schema.ImpliedType()) - if err != nil { - t.Fatal(err) - } - - config, err := schema.CoerceValue(cty.ObjectVal(map[string]cty.Value{ - "id": cty.NullVal(cty.String), - "foo": cty.MustParseNumberVal("7227701560655103598"), - })) - if err != nil { - t.Fatal(err) - } - configBytes, err := msgpack.Marshal(config, schema.ImpliedType()) - if err != nil { - t.Fatal(err) - } - - testReq := &tfprotov5.PlanResourceChangeRequest{ - TypeName: "test", - PriorState: &tfprotov5.DynamicValue{ - MsgPack: priorState, - }, - ProposedNewState: &tfprotov5.DynamicValue{ - MsgPack: proposedState, - }, - Config: &tfprotov5.DynamicValue{ - MsgPack: configBytes, - }, - } - - resp, err := server.PlanResourceChange(context.Background(), testReq) - if err != nil { - t.Fatal(err) - } - - plannedStateVal, err := msgpack.Unmarshal(resp.PlannedState.MsgPack, schema.ImpliedType()) - if err != nil { - t.Fatal(err) - } - - if !cmp.Equal(proposedVal, plannedStateVal, valueComparer) { - t.Fatal(cmp.Diff(proposedVal, plannedStateVal, valueComparer)) - } - - plannedStateFoo, acc := plannedStateVal.GetAttr("foo").AsBigFloat().Int64() - if acc != big.Exact { - t.Fatalf("Expected exact accuracy, got %s", acc) - } - if plannedStateFoo != 7227701560655103598 { - t.Fatalf("Expected %d, got %d, this represents a loss of precision in planning large numbers", 7227701560655103598, plannedStateFoo) - } -} - -func TestApplyResourceChange(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - server *GRPCProviderServer - req *tfprotov5.ApplyResourceChangeRequest - expected *tfprotov5.ApplyResourceChangeResponse - }{ - "create: write-only values are nullified in ApplyResourceChangeResponse": { + "empty identity schema": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ "test": { SchemaVersion: 4, - CreateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { - rd.SetId("baz") - return nil - }, Schema: map[string]*Schema{ "foo": { - Type: TypeString, - Optional: true, - WriteOnly: true, + Type: TypeInt, + Optional: true, }, - "bar": { - Type: TypeString, - Optional: true, - WriteOnly: true, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{} }, }, }, }, }), - req: &tfprotov5.ApplyResourceChangeRequest{ + req: &tfprotov5.PlanResourceChangeRequest{ TypeName: "test", PriorState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, + "foo": cty.Number, + }), + cty.NullVal( + cty.Object(map[string]cty.Type{ + "foo": cty.Number, + }), + ), + ), + }, + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.NullVal(cty.Number), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NullVal(cty.Number), + }), + ), + }, + PriorIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "name": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("test-name"), + }), + ), + }, + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "getting identity schema failed for resource 'test': identity schema must have at least one attribute", + }, + }, + UnsafeToUseLegacyTypeSystem: true, + }, + }, + "basic-plan-EnableLegacyTypeSystemPlanErrors": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + // Will set UnsafeToUseLegacyTypeSystem to false + EnableLegacyTypeSystemPlanErrors: true, + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + }, + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "foo": cty.Number, + }), + cty.NullVal( + cty.Object(map[string]cty.Type{ + "foo": cty.Number, + }), + ), + ), + }, + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.NullVal(cty.Number), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NullVal(cty.Number), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.NullVal(cty.Number), + }), + ), + }, + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("id"), + }, + PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), + UnsafeToUseLegacyTypeSystem: false, + }, + }, + "deferred-with-provider-plan-modification": { + server: NewGRPCProviderServer(&Provider{ + providerDeferred: &Deferred{ + Reason: DeferredReasonProviderConfigUnknown, + }, + ResourcesMap: map[string]*Resource{ + "test": { + ResourceBehavior: ResourceBehavior{ + ProviderDeferred: ProviderDeferredBehavior{ + // Will ensure that CustomizeDiff is called + EnablePlanModification: true, + }, + }, + SchemaVersion: 4, + CustomizeDiff: func(ctx context.Context, d *ResourceDiff, i interface{}) error { + return d.SetNew("foo", "new-foo-value") + }, + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + Computed: true, + }, + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + ClientCapabilities: &tfprotov5.PlanResourceChangeClientCapabilities{ + DeferralAllowed: true, + }, + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + cty.NullVal( + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + ), + }, + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.UnknownVal(cty.String), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NullVal(cty.String), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + Deferred: &tfprotov5.Deferred{ + Reason: tfprotov5.DeferredReasonProviderConfigUnknown, + }, + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.StringVal("new-foo-value"), + }), + ), + }, + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("id"), + }, + PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), + UnsafeToUseLegacyTypeSystem: true, + }, + }, + "deferred-skip-plan-modification": { + server: NewGRPCProviderServer(&Provider{ + providerDeferred: &Deferred{ + Reason: DeferredReasonProviderConfigUnknown, + }, + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + CustomizeDiff: func(ctx context.Context, d *ResourceDiff, i interface{}) error { + return errors.New("Test assertion failed: CustomizeDiff shouldn't be called") + }, + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + Computed: true, + }, + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + ClientCapabilities: &tfprotov5.PlanResourceChangeClientCapabilities{ + DeferralAllowed: true, + }, + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + cty.NullVal( + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + ), + }, + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.StringVal("from-config!"), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.StringVal("from-config!"), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + Deferred: &tfprotov5.Deferred{ + Reason: tfprotov5.DeferredReasonProviderConfigUnknown, + }, + // Returns proposed new state with deferred response + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.StringVal("from-config!"), + }), + ), + }, + UnsafeToUseLegacyTypeSystem: true, + }, + }, + "create: write-only value can be retrieved in CustomizeDiff": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + CustomizeDiff: func(ctx context.Context, d *ResourceDiff, i interface{}) error { + val := d.Get("foo") + if val != "bar" { + t.Fatalf("Incorrect write-only value") + } + + return nil + }, + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + cty.NullVal( + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + ), + }, + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.StringVal("bar"), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.StringVal("bar"), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.NullVal(cty.String), + }), + ), + }, + PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("id"), + }, + UnsafeToUseLegacyTypeSystem: true, + }, + }, + "create: write-only values are nullified in PlanResourceChangeResponse": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + }), + cty.NullVal( + cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + }), + ), + ), + }, + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + "bar": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.StringVal("baz"), + "bar": cty.StringVal("boop"), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + "bar": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.StringVal("baz"), + "bar": cty.StringVal("boop"), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + "bar": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.NullVal(cty.String), + "bar": cty.NullVal(cty.String), + }), + ), + }, + PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("id"), + }, + UnsafeToUseLegacyTypeSystem: true, + }, + }, + "update: write-only value can be retrieved in CustomizeDiff": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + CustomizeDiff: func(ctx context.Context, d *ResourceDiff, i interface{}) error { + val := d.Get("write_only") + if val != "bar" { + t.Fatalf("Incorrect write-only value") + } + + return nil + }, + Schema: map[string]*Schema{ + "configured": { + Type: TypeString, + Optional: true, + }, + "write_only": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_only": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "configured": cty.StringVal("prior_val"), + "write_only": cty.NullVal(cty.String), + }), + ), + }, + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_only": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "configured": cty.StringVal("updated_val"), + "write_only": cty.StringVal("bar"), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_only": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "configured": cty.StringVal("updated_val"), + "write_only": cty.StringVal("bar"), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_only": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "configured": cty.StringVal("updated_val"), + "write_only": cty.NullVal(cty.String), + }), + ), + }, + PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("id"), + }, + UnsafeToUseLegacyTypeSystem: true, + }, + }, + "update: write-only values are nullified in PlanResourceChangeResponse": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + Schema: map[string]*Schema{ + "configured": { + Type: TypeString, + Optional: true, + }, + "write_onlyA": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + "write_onlyB": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_onlyA": cty.String, + "write_onlyB": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "configured": cty.StringVal("prior_val"), + "write_onlyA": cty.NullVal(cty.String), + "write_onlyB": cty.NullVal(cty.String), + }), + ), + }, + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_onlyA": cty.String, + "write_onlyB": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "configured": cty.StringVal("updated_val"), + "write_onlyA": cty.StringVal("foo"), + "write_onlyB": cty.StringVal("bar"), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_onlyA": cty.String, + "write_onlyB": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "configured": cty.StringVal("updated_val"), + "write_onlyA": cty.StringVal("foo"), + "write_onlyB": cty.StringVal("bar"), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_onlyA": cty.String, + "write_onlyB": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "configured": cty.StringVal("updated_val"), + "write_onlyA": cty.NullVal(cty.String), + "write_onlyB": cty.NullVal(cty.String), + }), + ), + }, + PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("id"), + }, + UnsafeToUseLegacyTypeSystem: true, + }, + }, + "create-resource-identity-may-change": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 1, + Schema: map[string]*Schema{ + "id": { + Type: TypeString, + Required: true, + }, + "test": { + Type: TypeString, + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "identity": { + Type: TypeString, + RequiredForImport: true, + }, + } + }, + }, + CustomizeDiff: func(ctx context.Context, d *ResourceDiff, meta interface{}) error { + identity, err := d.Identity() + if err != nil { + return err + } + err = identity.Set("identity", "changed") + if err != nil { + return err + } + return nil + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.NullVal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + ), + ), + }, + PriorIdentity: nil, // create! + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "test": cty.StringVal("initial"), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "test": cty.StringVal("initial"), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "test": cty.StringVal("initial"), + }), + ), + }, + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("id"), + }, + PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), + UnsafeToUseLegacyTypeSystem: true, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("changed"), + }), + ), + }, + }, + }, + }, + "update-resource-identity-may-not-change": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 1, + Schema: map[string]*Schema{ + "id": { + Type: TypeString, + Required: true, + }, + "test": { + Type: TypeString, + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "identity": { + Type: TypeString, + RequiredForImport: true, + }, + } + }, + }, + CustomizeDiff: func(ctx context.Context, d *ResourceDiff, meta interface{}) error { + identity, err := d.Identity() + if err != nil { + return err + } + err = identity.Set("identity", "changed") + if err != nil { + return err + } + return nil + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("initial"), + "test": cty.StringVal("initial"), + }), + ), + }, + PriorIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("initial"), + }), + ), + }, + }, + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "test": cty.StringVal("initial"), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "test": cty.StringVal("initial"), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "test": cty.StringVal("initial"), + }), + ), + }, + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("id"), + }, + PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), + UnsafeToUseLegacyTypeSystem: true, + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: `Unexpected Identity Change: During the planning operation, the Terraform Provider unexpectedly returned a different identity than the previously stored one. + +This is always a problem with the provider and should be reported to the provider developer. + +Prior Identity: cty.ObjectVal(map[string]cty.Value{"identity":cty.StringVal("initial")}) + +Planned Identity: cty.ObjectVal(map[string]cty.Value{"identity":cty.StringVal("changed")})`, + }, + }, + }, + }, + "update-resource-identity-may-change-if-mutable-identity-allowed": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + ResourceBehavior: ResourceBehavior{ + MutableIdentity: true, + }, + SchemaVersion: 1, + Schema: map[string]*Schema{ + "id": { + Type: TypeString, + Required: true, + }, + "test": { + Type: TypeString, + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "identity": { + Type: TypeString, + RequiredForImport: true, + }, + } + }, + }, + CustomizeDiff: func(ctx context.Context, d *ResourceDiff, meta interface{}) error { + identity, err := d.Identity() + if err != nil { + return err + } + err = identity.Set("identity", "changed") + if err != nil { + return err + } + return nil + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("initial"), + "test": cty.StringVal("initial"), + }), + ), + }, + PriorIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("initial"), + }), + ), + }, + }, + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "test": cty.StringVal("initial"), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "test": cty.StringVal("initial"), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "test": cty.StringVal("initial"), + }), + ), + }, + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("id"), + }, + PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), + UnsafeToUseLegacyTypeSystem: true, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("changed"), + }), + ), + }, + }, + }, + }, + "update-resource-without-prior-identity-identity-may-change": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 1, + Schema: map[string]*Schema{ + "id": { + Type: TypeString, + Required: true, + }, + "test": { + Type: TypeString, + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "identity": { + Type: TypeString, + RequiredForImport: true, + }, + } + }, + }, + CustomizeDiff: func(ctx context.Context, d *ResourceDiff, meta interface{}) error { + identity, err := d.Identity() + if err != nil { + return err + } + err = identity.Set("identity", "changed") + if err != nil { + return err + } + return nil + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("initial"), + "test": cty.StringVal("initial"), + }), + ), + }, + PriorIdentity: nil, // no identity yet (prior provider version didn't support it and there was an upgrade without refresh) + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("initial"), + "test": cty.StringVal("initial"), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "test": cty.StringVal("initial"), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("initial"), + "test": cty.StringVal("initial"), + }), + ), + }, + PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), + UnsafeToUseLegacyTypeSystem: true, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("changed"), + }), + ), + }, + }, + }, + }, + "destroy-resource-identity-may-not-change": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 1, + Schema: map[string]*Schema{ + "id": { + Type: TypeString, + Required: true, + }, + "test": { + Type: TypeString, + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "identity": { + Type: TypeString, + RequiredForImport: true, + }, + } + }, + }, + CustomizeDiff: func(ctx context.Context, d *ResourceDiff, meta interface{}) error { + identity, err := d.Identity() + if err != nil { + return err + } + // Note: this entire function won't be run anyways for destroys (as that'll short circuit and return the prior identity) + // it's still in this test so we'd see this as an error if something breaks over in the handler + err = identity.Set("identity", "changed_this_should_not_appear_anywhere!") + if err != nil { + return err + } + return nil + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("initial"), + "test": cty.StringVal("initial"), + }), + ), + }, + PriorIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("initial"), + }), + ), + }, + }, + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.NullVal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + ), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "test": cty.StringVal("initial"), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.NullVal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + ), + ), + }, + UnsafeToUseLegacyTypeSystem: true, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("initial"), + }), + ), + }, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp, err := testCase.server.PlanResourceChange(context.Background(), testCase.req) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(resp, testCase.expected, valueComparer); diff != "" { + ty := testCase.server.getResourceSchemaBlock("test").ImpliedType() + + if resp != nil && resp.PlannedState != nil { + t.Logf("resp.PlannedState.MsgPack: %s", mustMsgpackUnmarshal(ty, resp.PlannedState.MsgPack)) + } + + if testCase.expected != nil && testCase.expected.PlannedState != nil { + t.Logf("expected: %s", mustMsgpackUnmarshal(ty, testCase.expected.PlannedState.MsgPack)) + } + + t.Error(diff) + } + }) + } +} + +func TestPlanResourceChange_bigint(t *testing.T) { + r := &Resource{ + UseJSONNumber: true, + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Required: true, + }, + }, + } + + server := NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": r, + }, + }) + + schema := r.CoreConfigSchema() + priorState, err := msgpack.Marshal(cty.NullVal(schema.ImpliedType()), schema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + proposedVal := cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.MustParseNumberVal("7227701560655103598"), + }) + proposedState, err := msgpack.Marshal(proposedVal, schema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + config, err := schema.CoerceValue(cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.MustParseNumberVal("7227701560655103598"), + })) + if err != nil { + t.Fatal(err) + } + configBytes, err := msgpack.Marshal(config, schema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + testReq := &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: priorState, + }, + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: proposedState, + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: configBytes, + }, + } + + resp, err := server.PlanResourceChange(context.Background(), testReq) + if err != nil { + t.Fatal(err) + } + + plannedStateVal, err := msgpack.Unmarshal(resp.PlannedState.MsgPack, schema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + if !cmp.Equal(proposedVal, plannedStateVal, valueComparer) { + t.Fatal(cmp.Diff(proposedVal, plannedStateVal, valueComparer)) + } + + plannedStateFoo, acc := plannedStateVal.GetAttr("foo").AsBigFloat().Int64() + if acc != big.Exact { + t.Fatalf("Expected exact accuracy, got %s", acc) + } + if plannedStateFoo != 7227701560655103598 { + t.Fatalf("Expected %d, got %d, this represents a loss of precision in planning large numbers", 7227701560655103598, plannedStateFoo) + } +} + +func TestApplyResourceChange(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + server *GRPCProviderServer + req *tfprotov5.ApplyResourceChangeRequest + expected *tfprotov5.ApplyResourceChangeResponse + }{ + "create: write-only values are nullified in ApplyResourceChangeResponse": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + CreateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + rd.SetId("baz") + return nil + }, + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + req: &tfprotov5.ApplyResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + }), + cty.NullVal( + cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + }), + ), + ), + }, + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + "bar": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.StringVal("baz"), + "bar": cty.StringVal("boop"), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + "bar": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.StringVal("baz"), + "bar": cty.StringVal("boop"), + }), + ), + }, + }, + expected: &tfprotov5.ApplyResourceChangeResponse{ + NewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + "bar": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("baz"), + "foo": cty.NullVal(cty.String), + "bar": cty.NullVal(cty.String), + }), + ), + }, + Private: []uint8(`{"schema_version":"4"}`), + UnsafeToUseLegacyTypeSystem: true, + }, + }, + "update: write-only values are nullified in ApplyResourceChangeResponse": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + CreateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + rd.SetId("baz") + s := rd.Get("configured").(string) + err := rd.Set("configured", s) + if err != nil { + return nil + } + return nil + }, + Schema: map[string]*Schema{ + "configured": { + Type: TypeString, + Optional: true, + }, + "write_onlyA": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + "write_onlyB": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + req: &tfprotov5.ApplyResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_onlyA": cty.String, + "write_onlyB": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "configured": cty.StringVal("prior_val"), + "write_onlyA": cty.NullVal(cty.String), + "write_onlyB": cty.NullVal(cty.String), + }), + ), + }, + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_onlyA": cty.String, + "write_onlyB": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "configured": cty.StringVal("updated_val"), + "write_onlyA": cty.StringVal("foo"), + "write_onlyB": cty.StringVal("bar"), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_onlyA": cty.String, + "write_onlyB": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "configured": cty.StringVal("updated_val"), + "write_onlyA": cty.StringVal("foo"), + "write_onlyB": cty.StringVal("bar"), + }), + ), + }, + }, + expected: &tfprotov5.ApplyResourceChangeResponse{ + NewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_onlyA": cty.String, + "write_onlyB": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("baz"), + "configured": cty.StringVal("updated_val"), + "write_onlyA": cty.NullVal(cty.String), + "write_onlyB": cty.NullVal(cty.String), + }), + ), + }, + Private: []uint8(`{"schema_version":"4"}`), + UnsafeToUseLegacyTypeSystem: true, + }, + }, + "create: identity returned in ApplyResourceChangeResponse": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + CreateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + rd.SetId("baz") + identity, err := rd.Identity() + if err != nil { + t.Fatal(err) + } + err = identity.Set("ident", "bazz") + if err != nil { + t.Fatal(err) + } + return nil + }, + Schema: map[string]*Schema{}, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "ident": { + Type: TypeString, + RequiredForImport: true, + }, + } + }, + }, + }, + }, + }), + req: &tfprotov5.ApplyResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{}), + cty.NullVal( + cty.Object(map[string]cty.Type{}), + ), + ), + }, + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + }), + ), + }, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "ident": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "ident": cty.NullVal(cty.String), + }), + ), + }, + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + }), + ), + }, + }, + expected: &tfprotov5.ApplyResourceChangeResponse{ + NewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("baz"), + }), + ), + }, + Private: []uint8(`{"schema_version":"4"}`), + UnsafeToUseLegacyTypeSystem: true, + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "ident": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "ident": cty.StringVal("bazz"), + }), + ), + }, + }, + }, + }, + "create: no identity schema diag in ApplyResourceChangeResponse": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + Schema: map[string]*Schema{}, + Identity: &ResourceIdentity{ + Version: 1, + }, + }, + }, + }), + req: &tfprotov5.ApplyResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{}), + cty.NullVal( + cty.Object(map[string]cty.Type{}), + ), + ), + }, + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + }), + ), + }, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "ident": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "ident": cty.UnknownVal(cty.String), + }), + ), + }, + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + }), + ), + }, + }, + expected: &tfprotov5.ApplyResourceChangeResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "getting identity schema failed for resource 'test': resource does not have an identity schema", + }, + }, + NewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal(cty.DynamicPseudoType, cty.NullVal(cty.DynamicPseudoType)), + }, + }, + }, + "create: empty identity schema diag in ApplyResourceChangeResponse": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + Schema: map[string]*Schema{}, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{} + }, + }, + }, + }, + }), + req: &tfprotov5.ApplyResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{}), + cty.NullVal( + cty.Object(map[string]cty.Type{}), + ), + ), + }, + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + }), + ), + }, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "ident": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "ident": cty.UnknownVal(cty.String), + }), + ), + }, + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + }), + ), + }, + }, + expected: &tfprotov5.ApplyResourceChangeResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "getting identity schema failed for resource 'test': identity schema must have at least one attribute", + }, + }, + NewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal(cty.DynamicPseudoType, cty.NullVal(cty.DynamicPseudoType)), + }, + }, + }, + "create-resource-identity-may-change": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 1, + Schema: map[string]*Schema{ + "id": { + Type: TypeString, + Required: true, + }, + "test": { + Type: TypeString, + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "identity": { + Type: TypeString, + RequiredForImport: true, + }, + } + }, + }, + CreateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + identity, err := rd.Identity() + if err != nil { + return diag.FromErr(err) + } + err = identity.Set("identity", "changed") + if err != nil { + return diag.FromErr(err) + } + rd.SetId("changed") + return nil + }, + }, + }, + }), + req: &tfprotov5.ApplyResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.NullVal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + ), + ), + }, + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "test": cty.StringVal("initial"), + }), + ), + }, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("initial"), + }), + ), + }, + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "test": cty.StringVal("initial"), + }), + ), + }, + }, + expected: &tfprotov5.ApplyResourceChangeResponse{ + NewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("changed"), + "test": cty.StringVal("initial"), + }), + ), + }, + Private: []uint8(`{"schema_version":"1"}`), + UnsafeToUseLegacyTypeSystem: true, + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("changed"), + }), + ), + }, + }, + }, + }, + "update-resource-identity-may-not-change": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 1, + Schema: map[string]*Schema{ + "id": { + Type: TypeString, + Required: true, + }, + "test": { + Type: TypeString, + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "identity": { + Type: TypeString, + RequiredForImport: true, + }, + } + }, + }, + UpdateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + identity, err := rd.Identity() + if err != nil { + return diag.FromErr(err) + } + err = identity.Set("identity", "changed") + if err != nil { + return diag.FromErr(err) + } + rd.SetId("changed") + return nil + }, + }, + }, + }), + req: &tfprotov5.ApplyResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("initial"), + "test": cty.StringVal("initial"), + }), + ), + }, + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("initial"), + "test": cty.StringVal("initial"), + }), + ), + }, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("initial"), + }), + ), + }, + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "test": cty.StringVal("initial"), + }), + ), + }, + }, + expected: &tfprotov5.ApplyResourceChangeResponse{ + NewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("changed"), + "test": cty.StringVal("initial"), + }), + ), + }, + Private: []uint8(`{"schema_version":"1"}`), + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: `Unexpected Identity Change: During the update operation, the Terraform Provider unexpectedly returned a different identity than the previously stored one. + +This is always a problem with the provider and should be reported to the provider developer. + +Planned Identity: cty.ObjectVal(map[string]cty.Value{"identity":cty.StringVal("initial")}) + +New Identity: cty.ObjectVal(map[string]cty.Value{"identity":cty.StringVal("changed")})`, + }, + }, + }, + }, + "update-resource-identity-may-change-if-mutable-identity-allowed": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + ResourceBehavior: ResourceBehavior{ + MutableIdentity: true, + }, + SchemaVersion: 1, + Schema: map[string]*Schema{ + "id": { + Type: TypeString, + Required: true, + }, + "test": { + Type: TypeString, + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "identity": { + Type: TypeString, + RequiredForImport: true, + }, + } + }, + }, + UpdateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + identity, err := rd.Identity() + if err != nil { + return diag.FromErr(err) + } + err = identity.Set("identity", "changed") + if err != nil { + return diag.FromErr(err) + } + rd.SetId("changed") + return nil + }, + }, + }, + }), + req: &tfprotov5.ApplyResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("initial"), + "test": cty.StringVal("initial"), + }), + ), + }, + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("initial"), + "test": cty.StringVal("initial"), + }), + ), + }, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("initial"), + }), + ), + }, + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "test": cty.StringVal("initial"), + }), + ), + }, + }, + expected: &tfprotov5.ApplyResourceChangeResponse{ + NewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("changed"), + "test": cty.StringVal("initial"), + }), + ), + }, + Private: []uint8(`{"schema_version":"1"}`), + UnsafeToUseLegacyTypeSystem: true, + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("changed"), + }), + ), + }, + }, + }, + }, + "update-resource-without-planned-identity-identity-may-change": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 1, + Schema: map[string]*Schema{ + "id": { + Type: TypeString, + Required: true, + }, + "test": { + Type: TypeString, + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "identity": { + Type: TypeString, + RequiredForImport: true, + }, + } + }, + }, + UpdateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + identity, err := rd.Identity() + if err != nil { + return diag.FromErr(err) + } + err = identity.Set("identity", "changed") + if err != nil { + return diag.FromErr(err) + } + rd.SetId("changed") + return nil + }, + }, + }, + }), + req: &tfprotov5.ApplyResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("initial"), + "test": cty.StringVal("initial"), }), - cty.NullVal( - cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, - }), - ), ), }, PlannedState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "foo": cty.String, - "bar": cty.String, + "id": cty.String, + "test": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.UnknownVal(cty.String), - "foo": cty.StringVal("baz"), - "bar": cty.StringVal("boop"), + "id": cty.StringVal("initial"), + "test": cty.StringVal("initial"), }), ), }, + PlannedIdentity: nil, Config: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "foo": cty.String, - "bar": cty.String, + "id": cty.String, + "test": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.NullVal(cty.String), - "foo": cty.StringVal("baz"), - "bar": cty.StringVal("boop"), + "id": cty.NullVal(cty.String), + "test": cty.StringVal("initial"), }), ), }, @@ -5736,51 +8681,68 @@ func TestApplyResourceChange(t *testing.T) { NewState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "foo": cty.String, - "bar": cty.String, + "id": cty.String, + "test": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("baz"), - "foo": cty.NullVal(cty.String), - "bar": cty.NullVal(cty.String), + "id": cty.StringVal("changed"), + "test": cty.StringVal("initial"), }), ), }, - Private: []uint8(`{"schema_version":"4"}`), + Private: []uint8(`{"schema_version":"1"}`), UnsafeToUseLegacyTypeSystem: true, + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("changed"), + }), + ), + }, + }, }, }, - "update: write-only values are nullified in ApplyResourceChangeResponse": { + "destroy-resource-identity-may-change": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ "test": { - SchemaVersion: 4, - CreateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { - rd.SetId("baz") - s := rd.Get("configured").(string) - err := rd.Set("configured", s) - if err != nil { - return nil - } - return nil - }, + SchemaVersion: 1, Schema: map[string]*Schema{ - "configured": { + "id": { Type: TypeString, - Optional: true, + Required: true, }, - "write_onlyA": { - Type: TypeString, - Optional: true, - WriteOnly: true, + "test": { + Type: TypeString, }, - "write_onlyB": { - Type: TypeString, - Optional: true, - WriteOnly: true, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "identity": { + Type: TypeString, + RequiredForImport: true, + }, + } }, }, + DeleteContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + identity, err := rd.Identity() + if err != nil { + return diag.FromErr(err) + } + err = identity.Set("identity", "changed") + if err != nil { + return diag.FromErr(err) + } + rd.SetId("changed") + return nil + }, }, }, }), @@ -5789,48 +8751,48 @@ func TestApplyResourceChange(t *testing.T) { PriorState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "configured": cty.String, - "write_onlyA": cty.String, - "write_onlyB": cty.String, + "id": cty.String, + "test": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.NullVal(cty.String), - "configured": cty.StringVal("prior_val"), - "write_onlyA": cty.NullVal(cty.String), - "write_onlyB": cty.NullVal(cty.String), + "id": cty.StringVal("initial"), + "test": cty.StringVal("initial"), }), ), }, PlannedState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "configured": cty.String, - "write_onlyA": cty.String, - "write_onlyB": cty.String, - }), - cty.ObjectVal(map[string]cty.Value{ - "id": cty.UnknownVal(cty.String), - "configured": cty.StringVal("updated_val"), - "write_onlyA": cty.StringVal("foo"), - "write_onlyB": cty.StringVal("bar"), + "id": cty.String, + "test": cty.String, }), + cty.NullVal(cty.Object(map[string]cty.Type{ // NullVal => destroy + "id": cty.String, + "test": cty.String, + })), ), }, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "identity": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "identity": cty.StringVal("initial"), + }), + ), + }, + }, Config: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "configured": cty.String, - "write_onlyA": cty.String, - "write_onlyB": cty.String, + "id": cty.String, + "test": cty.String, }), cty.ObjectVal(map[string]cty.Value{ - "id": cty.NullVal(cty.String), - "configured": cty.StringVal("updated_val"), - "write_onlyA": cty.StringVal("foo"), - "write_onlyB": cty.StringVal("bar"), + "id": cty.NullVal(cty.String), + "test": cty.StringVal("initial"), }), ), }, @@ -5839,21 +8801,15 @@ func TestApplyResourceChange(t *testing.T) { NewState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ - "id": cty.String, - "configured": cty.String, - "write_onlyA": cty.String, - "write_onlyB": cty.String, - }), - cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("baz"), - "configured": cty.StringVal("updated_val"), - "write_onlyA": cty.NullVal(cty.String), - "write_onlyB": cty.NullVal(cty.String), + "id": cty.String, + "test": cty.String, }), + cty.NullVal(cty.Object(map[string]cty.Type{ + "id": cty.String, + "test": cty.String, + })), ), }, - Private: []uint8(`{"schema_version":"4"}`), - UnsafeToUseLegacyTypeSystem: true, }, }, } @@ -6492,7 +9448,170 @@ func TestImportResourceState(t *testing.T) { }), ), }, - Private: []byte(`{"schema_version":"1"}`), + Private: []byte(`{".import_before_read":true,"schema_version":"1"}`), + }, + }, + }, + }, + "basic-import-from-identity": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 1, + Schema: map[string]*Schema{ + "id": { + Type: TypeString, + Required: true, + }, + "test_string": { + Type: TypeString, + Computed: true, + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "id": { + Type: TypeString, + RequiredForImport: true, + }, + } + }, + }, + Importer: &ResourceImporter{ + StateContext: func(ctx context.Context, d *ResourceData, meta interface{}) ([]*ResourceData, error) { + identity, err := d.Identity() + if err != nil { + t.Fatalf("failed to get identity: %v", err) + } + result, exists := identity.GetOk("id") + if !exists { + t.Fatalf("expected id to exist in identity") + } + + err = d.Set("test_string", "new-imported-val") + if err != nil { + return nil, err + } + + d.SetId(result.(string)) + + return []*ResourceData{d}, nil + }, + }, + }, + }, + }), + req: &tfprotov5.ImportResourceStateRequest{ + TypeName: "test", + Identity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("imported-id"), + }), + ), + }, + }, + }, + expected: &tfprotov5.ImportResourceStateResponse{ + ImportedResources: []*tfprotov5.ImportedResource{ + { + TypeName: "test", + State: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test_string": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("imported-id"), + "test_string": cty.StringVal("new-imported-val"), + }), + ), + }, + Private: []byte(`{".import_before_read":true,"schema_version":"1"}`), + Identity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("imported-id"), + }), + ), + }, + }, + }, + }, + }, + }, + "basic-import-from-identity-no-id": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 1, + Schema: map[string]*Schema{ + "id": { + Type: TypeString, + Required: true, + }, + "test_string": { + Type: TypeString, + Computed: true, + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "id": { + Type: TypeString, + RequiredForImport: true, + }, + } + }, + }, + Importer: &ResourceImporter{ + // Note: this does not set the Id on the ResourceData which results in an error that this test expects + StateContext: func(ctx context.Context, d *ResourceData, meta interface{}) ([]*ResourceData, error) { + err := d.Set("test_string", "new-imported-val") + if err != nil { + return nil, err + } + + return []*ResourceData{d}, nil + }, + }, + }, + }, + }), + req: &tfprotov5.ImportResourceStateRequest{ + TypeName: "test", + Identity: &tfprotov5.ResourceIdentityData{ + IdentityData: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("imported-id"), + }), + ), + }, + }, + }, + expected: &tfprotov5.ImportResourceStateResponse{ + ImportedResources: nil, + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "The provider returned a resource missing an identifier during ImportResourceState. This is generally a bug in the resource implementation for import. Resource import code should not call d.SetId(\"\") or create an empty ResourceData. If the resource is missing, instead return an error. Please report this to the provider developers.", }, }, }, @@ -6683,7 +9802,7 @@ func TestImportResourceState(t *testing.T) { }), ), }, - Private: []byte(`{"schema_version":"1"}`), + Private: []byte(`{".import_before_read":true,"schema_version":"1"}`), }, }, }, diff --git a/helper/schema/identity_data.go b/helper/schema/identity_data.go new file mode 100644 index 00000000000..7107de1cf30 --- /dev/null +++ b/helper/schema/identity_data.go @@ -0,0 +1,142 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +import ( + "log" + "reflect" + "strings" + "sync" +) + +type IdentityData struct { + // raw identity data will be stored internally + raw map[string]string + schema map[string]*Schema + + // Don't set + once sync.Once + multiReader *MultiLevelFieldReader + setWriter *MapFieldWriter + + panicOnError bool +} + +// Reading/writing data will be similar to the *schema.ResourceData flatmap +func (d *IdentityData) Get(key string) interface{} { + v, _ := d.GetOk(key) + return v +} + +func (d *IdentityData) GetOk(key string) (interface{}, bool) { + r := d.getRaw(key) + exists := r.Exists + if exists { + // If it exists, we also want to verify it is not the zero-value. + value := r.Value + zero := r.Schema.Type.Zero() + + if eq, ok := value.(Equal); ok { + exists = !eq.Equal(zero) + } else { + exists = !reflect.DeepEqual(value, zero) + } + } + + return r.Value, exists +} + +func (d *IdentityData) Set(key string, value interface{}) error { + d.once.Do(d.init) + + // If the value is a pointer to a non-struct, get its value and + // use that. This allows Set to take a pointer to primitives to + // simplify the interface. + reflectVal := reflect.ValueOf(value) + if reflectVal.Kind() == reflect.Ptr { + if reflectVal.IsNil() { + // If the pointer is nil, then the value is just nil + value = nil + } else { + // Otherwise, we dereference the pointer as long as its not + // a pointer to a struct, since struct pointers are allowed. + reflectVal = reflect.Indirect(reflectVal) + if reflectVal.Kind() != reflect.Struct { + value = reflectVal.Interface() + } + } + } + + err := d.setWriter.WriteField(strings.Split(key, "."), value) + if err != nil { + if d.panicOnError { + panic(err) + } else { + log.Printf("[ERROR] setting identity state: %s", err) + } + } + return err +} + +func (d *IdentityData) init() { + // Initialize the map for storing data set by the user + d.setWriter = &MapFieldWriter{Schema: d.schema} + + // Initialize the reader for getting data from the + // underlying sources (config, diff, etc.) + readers := make(map[string]FieldReader) + if d.raw != nil { + readers["raw"] = &MapFieldReader{ + Schema: d.schema, + Map: BasicMapReader(d.raw), + } + } + readers["set"] = &MapFieldReader{ + Schema: d.schema, + Map: BasicMapReader(d.setWriter.Map()), + } + d.multiReader = &MultiLevelFieldReader{ + Levels: []string{"raw", "set"}, + Readers: readers, + } +} + +func (d *IdentityData) getRaw(key string) getResult { + var parts []string + if key != "" { + parts = strings.Split(key, ".") + } + + return d.get(parts) +} + +func (d *IdentityData) get(addr []string) getResult { + d.once.Do(d.init) + + result, err := d.multiReader.ReadFieldMerge(addr, "set") + + if err != nil { + panic(err) + } + + // If the result doesn't exist, then we set the value to the zero value + var schema *Schema + if schemaL := addrToSchema(addr, d.schema); len(schemaL) > 0 { + schema = schemaL[len(schemaL)-1] + } + + if result.Value == nil && schema != nil { + result.Value = result.ValueOrZero(schema) + } + + // Transform the FieldReadResult into a getResult. It might be worth + // merging these two structures one day. + return getResult{ + Value: result.Value, + ValueProcessed: result.ValueProcessed, + Computed: result.Computed, + Exists: result.Exists, + Schema: schema, + } +} diff --git a/helper/schema/identity_data_test.go b/helper/schema/identity_data_test.go new file mode 100644 index 00000000000..e85ef249f0d --- /dev/null +++ b/helper/schema/identity_data_test.go @@ -0,0 +1,669 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +import ( + "reflect" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestIdentityDataGet(t *testing.T) { + cases := map[string]struct { + IdentitySchema map[string]*Schema + State *terraform.InstanceState + Diff *terraform.InstanceDiff + Key string + Value interface{} + }{ + "no state, empty diff": { + IdentitySchema: map[string]*Schema{ + "region": { + Type: TypeString, + RequiredForImport: true, + }, + }, + + State: nil, + + Diff: &terraform.InstanceDiff{}, + + Key: "region", + Value: "", + }, + + "no state, diff with identity": { + IdentitySchema: map[string]*Schema{ + "region": { + Type: TypeString, + RequiredForImport: true, + }, + }, + + State: nil, + + Diff: &terraform.InstanceDiff{ + Identity: map[string]string{ + "region": "foo", + }, + }, + + Key: "region", + Value: "foo", + }, + + "state with identity, no diff": { + IdentitySchema: map[string]*Schema{ + "region": { + Type: TypeString, + RequiredForImport: true, + }, + }, + + State: &terraform.InstanceState{ + Identity: map[string]string{ + "region": "bar", + }, + }, + + Diff: nil, + + Key: "region", + + Value: "bar", + }, + + "state with identity, empty diff": { + IdentitySchema: map[string]*Schema{ + "region": { + Type: TypeString, + RequiredForImport: true, + }, + }, + + State: &terraform.InstanceState{ + Identity: map[string]string{ + "region": "foo", + }, + }, + + Diff: &terraform.InstanceDiff{}, + + Key: "region", + Value: "foo", // This is different than for resource data – which would be empty + }, + + "int type: state with identity, no diff": { + IdentitySchema: map[string]*Schema{ + "port": { + Type: TypeInt, + RequiredForImport: true, + }, + }, + + State: &terraform.InstanceState{ + Identity: map[string]string{ + "port": "80", + }, + }, + + Diff: nil, + + Key: "port", + + Value: 80, + }, + + "int list type: state with identity, empty diff": { + IdentitySchema: map[string]*Schema{ + "ports": { + Type: TypeList, + Elem: &Schema{Type: TypeInt}, + RequiredForImport: true, + }, + }, + + State: &terraform.InstanceState{ + Identity: map[string]string{ + "ports.#": "3", + "ports.0": "1", + "ports.1": "2", + "ports.2": "5", + }, + }, + + Key: "ports.1", + + Value: 2, + }, + + "int list type length: state with identity, empty diff": { + IdentitySchema: map[string]*Schema{ + "ports": { + Type: TypeList, + Elem: &Schema{Type: TypeInt}, + }, + }, + + State: &terraform.InstanceState{ + Identity: map[string]string{ + "ports.#": "3", + "ports.0": "1", + "ports.1": "2", + "ports.2": "5", + }, + }, + + Key: "ports.#", + + Value: 3, + }, + + "int list type length: empty state, empty diff": { + IdentitySchema: map[string]*Schema{ + "ports": { + Type: TypeList, + Elem: &Schema{Type: TypeInt}, + RequiredForImport: true, + }, + }, + + State: nil, + + Key: "ports.#", + + Value: 0, + }, + + "int list type all: state with identity, empty diff": { + IdentitySchema: map[string]*Schema{ + "ports": { + Type: TypeList, + Elem: &Schema{Type: TypeInt}, + RequiredForImport: true, + }, + }, + + State: &terraform.InstanceState{ + Identity: map[string]string{ + "ports.#": "3", + "ports.0": "1", + "ports.1": "2", + "ports.2": "5", + }, + }, + + Key: "ports", + + Value: []interface{}{1, 2, 5}, + }, + + "full object: empty state, diff with identity": { + IdentitySchema: map[string]*Schema{ + "region": { + Type: TypeString, + RequiredForImport: true, + }, + }, + + State: nil, + + Diff: &terraform.InstanceDiff{ + Identity: map[string]string{ + "region": "foo", + }, + }, + + Key: "", + + Value: map[string]interface{}{ + "region": "foo", + }, + }, + + "float zero: empty state, empty diff": { + IdentitySchema: map[string]*Schema{ + "ratio": { + Type: TypeFloat, + RequiredForImport: true, + }, + }, + + State: nil, + + Diff: nil, + + Key: "ratio", + + Value: 0.0, + }, + + "float: state with identity, empty diff": { + IdentitySchema: map[string]*Schema{ + "ratio": { + Type: TypeFloat, + RequiredForImport: true, + }, + }, + + State: &terraform.InstanceState{ + Identity: map[string]string{ + "ratio": "0.5", + }, + }, + + Diff: nil, + + Key: "ratio", + + Value: 0.5, + }, + + "float: state with identity, diff with identity": { + IdentitySchema: map[string]*Schema{ + "ratio": { + Type: TypeFloat, + RequiredForImport: true, + }, + }, + + State: &terraform.InstanceState{ + Identity: map[string]string{ + "ratio": "-0.5", + }, + }, + + Diff: &terraform.InstanceDiff{ + Identity: map[string]string{ + "ratio": "33.0", + }, + }, + + Key: "ratio", + + Value: 33.0, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + schema := map[string]*Schema{} + d, err := schemaMapWithIdentity{schema, tc.IdentitySchema}.Data(tc.State, tc.Diff) + if err != nil { + t.Fatalf("err: %s", err) + } + + identity, err := d.Identity() + if err != nil { + t.Fatalf("err: %s", err) + } + + v := identity.Get(tc.Key) + + if !reflect.DeepEqual(v, tc.Value) { + t.Fatalf("Bad: %s\n\n%#v\n\nExpected: %#v", name, v, tc.Value) + } + }) + } +} + +func TestIdentityDataGetOk(t *testing.T) { + cases := []struct { + IdentitySchema map[string]*Schema + State *terraform.InstanceState + Diff *terraform.InstanceDiff + Key string + Value interface{} + Ok bool + }{ + /* + * Primitives + */ + { + IdentitySchema: map[string]*Schema{ + "region": { + Type: TypeString, + RequiredForImport: true, + }, + }, + + State: nil, + + Diff: &terraform.InstanceDiff{ + Identity: map[string]string{ + "region": "", + }, + }, + + Key: "region", + Value: "", + Ok: false, + }, + + { + IdentitySchema: map[string]*Schema{ + "region": { + Type: TypeString, + RequiredForImport: true, + }, + }, + + State: nil, + + Diff: nil, + + Key: "region", + Value: "", + Ok: false, + }, + + /* + * Lists + */ + + { + IdentitySchema: map[string]*Schema{ + "ports": { + Type: TypeList, + Elem: &Schema{Type: TypeInt}, + RequiredForImport: true, + }, + }, + + State: nil, + + Diff: nil, + + Key: "ports", + Value: []interface{}{}, + Ok: false, + }, + } + + for i, tc := range cases { + schema := map[string]*Schema{} + d, err := schemaMapWithIdentity{schema, tc.IdentitySchema}.Data(tc.State, tc.Diff) + if err != nil { + t.Fatalf("err: %s", err) + } + + identity, err := d.Identity() + if err != nil { + t.Fatalf("err: %s", err) + } + + v, ok := identity.GetOk(tc.Key) + if s, ok := v.(*Set); ok { + v = s.List() + } + + if !reflect.DeepEqual(v, tc.Value) { + t.Fatalf("Bad: %d\n\n%#v", i, v) + } + if ok != tc.Ok { + t.Fatalf("%d: expected ok: %t, got: %t", i, tc.Ok, ok) + } + } +} + +func TestIdentityDataSet(t *testing.T) { + var testNilPtr *string + + cases := map[string]struct { + IdentitySchema map[string]*Schema + State *terraform.InstanceState + Diff *terraform.InstanceDiff + Key string + Value interface{} + Err bool + GetKey string + GetValue interface{} + }{ + "basic string": { + IdentitySchema: map[string]*Schema{ + "region": { + Type: TypeString, + RequiredForImport: true, + }, + }, + + State: nil, + + Diff: nil, + + Key: "region", + Value: "foo", + + GetKey: "region", + GetValue: "foo", + }, + + "basic int": { + IdentitySchema: map[string]*Schema{ + "port": { + Type: TypeInt, + }, + }, + + State: nil, + + Diff: nil, + + Key: "port", + Value: 80, + + GetKey: "port", + GetValue: 80, + }, + + "basic bool": { + IdentitySchema: map[string]*Schema{ + "vpc": { + Type: TypeBool, + }, + }, + + State: nil, + + Diff: nil, + + Key: "vpc", + Value: true, + + GetKey: "vpc", + GetValue: true, + }, + + "basic bool false": { + IdentitySchema: map[string]*Schema{ + "vpc": { + Type: TypeBool, + }, + }, + + State: nil, + + Diff: nil, + + Key: "vpc", + Value: false, + + GetKey: "vpc", + GetValue: false, + }, + + "invalid type": { + IdentitySchema: map[string]*Schema{ + "region": { + Type: TypeString, + RequiredForImport: true, + }, + }, + + State: nil, + + Diff: nil, + + Key: "region", + Value: 80, + Err: true, + + GetKey: "region", + GetValue: "", + }, + + "list of primitives - set list": { + IdentitySchema: map[string]*Schema{ + "ports": { + Type: TypeList, + + Elem: &Schema{Type: TypeInt}, + }, + }, + + State: nil, + + Diff: nil, + + Key: "ports", + Value: []int{1, 2, 5}, + + GetKey: "ports", + GetValue: []interface{}{1, 2, 5}, + }, + + "list of primitives - set list with error": { + IdentitySchema: map[string]*Schema{ + "ports": { + Type: TypeList, + + Elem: &Schema{Type: TypeInt}, + }, + }, + + State: nil, + + Diff: nil, + + Key: "ports", + Value: []interface{}{1, "NOPE", 5}, + Err: true, + + GetKey: "ports", + GetValue: []interface{}{}, + }, + + "list of floats - set list": { + IdentitySchema: map[string]*Schema{ + "ratios": { + Type: TypeList, + + Elem: &Schema{Type: TypeFloat}, + }, + }, + + State: nil, + + Diff: nil, + + Key: "ratios", + Value: []float64{1.0, 2.2, 5.5}, + + GetKey: "ratios", + GetValue: []interface{}{1.0, 2.2, 5.5}, + }, + + "basic pointer": { + IdentitySchema: map[string]*Schema{ + "region": { + Type: TypeString, + RequiredForImport: true, + }, + }, + + State: nil, + + Diff: nil, + + Key: "region", + Value: testPtrTo("foo"), + + GetKey: "region", + GetValue: "foo", + }, + + "basic nil value": { + IdentitySchema: map[string]*Schema{ + "region": { + Type: TypeString, + RequiredForImport: true, + }, + }, + + State: nil, + + Diff: nil, + + Key: "region", + Value: testPtrTo(nil), + + GetKey: "region", + GetValue: "", + }, + + "basic nil pointer": { + IdentitySchema: map[string]*Schema{ + "region": { + Type: TypeString, + RequiredForImport: true, + }, + }, + + State: nil, + + Diff: nil, + + Key: "region", + Value: testNilPtr, + + GetKey: "region", + GetValue: "", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + + schema := map[string]*Schema{} + + d, err := schemaMapWithIdentity{schema, tc.IdentitySchema}.Data(tc.State, tc.Diff) + if err != nil { + t.Fatalf("err: %s", err) + } + + identity, err := d.Identity() + if err != nil { + t.Fatalf("err: %s", err) + } + + err = identity.Set(tc.Key, tc.Value) + if err != nil != tc.Err { + t.Fatalf("%s err: %s", name, err) + } + + // we retrieve a new identity to ensure memoization is working + identity, err = d.Identity() + if err != nil { + t.Fatalf("err: %s", err) + } + + v := identity.Get(tc.GetKey) + + if !reflect.DeepEqual(v, tc.GetValue) { + t.Fatalf("Get Bad: %s\n\n%#v", name, v) + } + }) + } +} diff --git a/helper/schema/provider.go b/helper/schema/provider.go index 45f1e0d466b..28c39bb0492 100644 --- a/helper/schema/provider.go +++ b/helper/schema/provider.go @@ -218,6 +218,11 @@ func (p *Provider) InternalValidate() error { } for k, r := range p.ResourcesMap { + if r.Identity != nil { + if err := r.Identity.InternalIdentityValidate(); err != nil { + validationErrors = append(validationErrors, fmt.Errorf("resource %s identity: %s", k, err)) + } + } if err := r.InternalValidate(nil, true); err != nil { validationErrors = append(validationErrors, fmt.Errorf("resource %s: %s", k, err)) } @@ -471,6 +476,14 @@ func (p *Provider) ImportState( ctx context.Context, info *terraform.InstanceInfo, id string) ([]*terraform.InstanceState, error) { + return p.ImportStateWithIdentity(ctx, info, id, nil) +} + +func (p *Provider) ImportStateWithIdentity( + ctx context.Context, + info *terraform.InstanceInfo, + id string, + identity map[string]string) ([]*terraform.InstanceState, error) { // Find the resource r, ok := p.ResourcesMap[info.Type] if !ok { @@ -487,6 +500,16 @@ func (p *Provider) ImportState( data.SetId(id) data.SetType(info.Type) + if data.identitySchema != nil { + identityData, err := data.Identity() + if err != nil { + return nil, err // this should not happen, as we checked above + } + identityData.raw = identity + } else if identity != nil { + return nil, fmt.Errorf("resource %s doesn't support identity import", info.Type) + } + // Call the import function results := []*ResourceData{data} if r.Importer.State != nil || r.Importer.StateContext != nil { diff --git a/helper/schema/provider_test.go b/helper/schema/provider_test.go index 0087c61d4c0..2426a6c189a 100644 --- a/helper/schema/provider_test.go +++ b/helper/schema/provider_test.go @@ -2271,6 +2271,164 @@ func TestProviderImportState(t *testing.T) { } } +func TestProviderImportStateWithIdentity(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + provider *Provider + info *terraform.InstanceInfo + id string + identity map[string]string + expectedStates []*terraform.InstanceState + expectedErr error + }{ + "error-missing-identity-schema": { + provider: &Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": { + Importer: &ResourceImporter{}, + // no identity schema defined + }, + }, + }, + identity: map[string]string{ + "id": "test-id", + }, + info: &terraform.InstanceInfo{ + Type: "test_resource", + }, + expectedErr: fmt.Errorf("resource test_resource doesn't support identity import"), + }, + "error-missing-ResourceData-Id": { + provider: &Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": { + Importer: &ResourceImporter{ + StateContext: func(_ context.Context, d *ResourceData, _ interface{}) ([]*ResourceData, error) { + // Example for handling import based on identity but not + // setting the id even though it's still required to be set + d.SetId("") + return []*ResourceData{d}, nil + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "id": { + Type: TypeString, + RequiredForImport: true, + }, + } + }, + }, + }, + }, + }, + info: &terraform.InstanceInfo{ + Type: "test_resource", + }, + identity: map[string]string{ + "id": "test-id", + }, + expectedErr: fmt.Errorf("The provider returned a resource missing an identifier during ImportResourceState."), + }, + "Importer-StateContext-from-identity": { + provider: &Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": { + Importer: &ResourceImporter{ + StateContext: func(_ context.Context, d *ResourceData, meta interface{}) ([]*ResourceData, error) { + if d.Id() != "" { + return nil, fmt.Errorf("expected d.Id() to be empty, got: %s", d.Id()) + } + + identity, err := d.Identity() + if err != nil { + return nil, fmt.Errorf("error getting identity: %s", err) + } + id, exists := identity.GetOk("id") + if !exists { + return nil, fmt.Errorf("expected identity to contain key id") + } + if id != "test-id" { + return nil, fmt.Errorf("expected identity id %q, got: %s", "test-id", id) + } + + // set region as we act as if it's derived from provider defaults + err = identity.Set("region", "eu-central-1") + if err != nil { + return nil, fmt.Errorf("error setting identity region: %s", err) + } + // set the id as well + d.SetId(id.(string)) + + return []*ResourceData{d}, nil + }, + }, + Identity: &ResourceIdentity{ + Version: 1, + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "id": { + Type: TypeString, + RequiredForImport: true, + }, + "region": { + Type: TypeString, + OptionalForImport: true, + }, + } + }, + }, + }, + }, + }, + info: &terraform.InstanceInfo{ + Type: "test_resource", + }, + identity: map[string]string{ + "id": "test-id", + }, + expectedStates: []*terraform.InstanceState{ + { + Attributes: map[string]string{"id": "test-id"}, + Ephemeral: terraform.EphemeralState{Type: "test_resource"}, + ID: "test-id", + Identity: map[string]string{"id": "test-id", "region": "eu-central-1"}, + Meta: map[string]interface{}{"schema_version": "0"}, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + states, err := testCase.provider.ImportStateWithIdentity(context.Background(), testCase.info, testCase.id, testCase.identity) + + if err != nil { + if testCase.expectedErr == nil { + t.Fatalf("unexpected error: %s", err) + } + + if !strings.Contains(err.Error(), testCase.expectedErr.Error()) { + t.Fatalf("expected error %q, got: %s", testCase.expectedErr, err) + } + } + + if err == nil && testCase.expectedErr != nil { + t.Fatalf("expected error %q, got none", testCase.expectedErr) + } + + if diff := cmp.Diff(states, testCase.expectedStates); diff != "" { + t.Fatalf("unexpected states difference: %s", diff) + } + }) + } +} + func TestProviderMeta(t *testing.T) { p := new(Provider) if v := p.Meta(); v != nil { diff --git a/helper/schema/resource.go b/helper/schema/resource.go index 32a21d2edfa..83140436800 100644 --- a/helper/schema/resource.go +++ b/helper/schema/resource.go @@ -89,6 +89,12 @@ type Resource struct { // their Versioning at any integer >= 1 SchemaVersion int + // Identity is a nested structure containing information about the structure + // and type of this resource's identity. This field is only valid when the + // Resource is a managed resource. + // This field, is optional. + Identity *ResourceIdentity + // MigrateState is responsible for updating an InstanceState with an old // version to the format expected by the current version of the Schema. // This field is only valid when the Resource is a managed resource. @@ -668,6 +674,11 @@ type ResourceBehavior struct { // NOTE: This functionality is related to deferred action support, which is currently experimental and is subject // to change or break without warning. It is not protected by version compatibility guarantees. ProviderDeferred ProviderDeferredBehavior + + // MutableIdentity indicates that the managed resource supports an identity that can change during the + // resource's lifecycle. Setting this flag to true will disable the SDK validation that ensures identity + // data doesn't change during RPC calls. + MutableIdentity bool } // ProviderDeferredBehavior enables provider-defined logic to be executed @@ -722,7 +733,7 @@ func (r *Resource) ShimInstanceStateFromValue(state cty.Value) (*terraform.Insta // We now rebuild the state through the ResourceData, so that the set indexes // match what helper/schema expects. - data, err := schemaMap(r.SchemaMap()).Data(s, nil) + data, err := schemaMapWithIdentity{r.SchemaMap(), r.Identity.SchemaMap()}.Data(s, nil) if err != nil { return nil, err } @@ -895,7 +906,7 @@ func (r *Resource) Apply( s *terraform.InstanceState, d *terraform.InstanceDiff, meta interface{}) (*terraform.InstanceState, diag.Diagnostics) { - schema := schemaMap(r.SchemaMap()) + schema := schemaMapWithIdentity{r.SchemaMap(), r.Identity.SchemaMap()} data, err := schema.Data(s, d) if err != nil { return s, diag.FromErr(err) @@ -1019,13 +1030,15 @@ func (r *Resource) SimpleDiff( c *terraform.ResourceConfig, meta interface{}) (*terraform.InstanceDiff, error) { - instanceDiff, err := schemaMap(r.SchemaMap()).Diff(ctx, s, c, r.CustomizeDiff, meta, false) + // TODO: figure out if it makes sense to be able to set identity in CustomizeDiff at all + instanceDiff, err := schemaMapWithIdentity{r.SchemaMap(), r.Identity.SchemaMap()}.Diff(ctx, s, c, r.CustomizeDiff, meta, false) if err != nil { return instanceDiff, err } if instanceDiff == nil { instanceDiff = terraform.NewInstanceDiff() + instanceDiff.Identity = s.Identity // if we create a new diff, we need to copy the identity } // Make sure the old value is set in each of the instance diffs. @@ -1107,7 +1120,7 @@ func (r *Resource) RefreshWithoutUpgrade( } } - schema := schemaMap(r.SchemaMap()) + schema := schemaMapWithIdentity{r.SchemaMap(), r.Identity.SchemaMap()} if r.Exists != nil { // Make a copy of data so that if it is modified it doesn't @@ -1208,7 +1221,7 @@ func (r *Resource) InternalValidate(topSchemaMap schemaMap, writable bool) error if !r.updateFuncSet() { nonForceNewAttrs := make([]string, 0) for k, v := range schema { - if !v.ForceNew && !v.Computed { + if !v.ForceNew && !v.Computed && !v.WriteOnly { nonForceNewAttrs = append(nonForceNewAttrs, k) } } @@ -1390,7 +1403,7 @@ func isReservedResourceFieldName(name string) bool { // // This function is useful for unit tests and ResourceImporter functions. func (r *Resource) Data(s *terraform.InstanceState) *ResourceData { - result, err := schemaMap(r.SchemaMap()).Data(s, nil) + result, err := schemaMapWithIdentity{r.SchemaMap(), r.Identity.SchemaMap()}.Data(s, nil) if err != nil { // At the time of writing, this isn't possible (Data never returns // non-nil errors). We panic to find this in the future if we have to. @@ -1417,7 +1430,8 @@ func (r *Resource) Data(s *terraform.InstanceState) *ResourceData { // TODO: May be able to be removed with the above ResourceData function. func (r *Resource) TestResourceData() *ResourceData { return &ResourceData{ - schema: r.SchemaMap(), + schema: r.SchemaMap(), + identitySchema: r.Identity.SchemaMap(), } } @@ -1457,3 +1471,135 @@ func RemoveFromState(d *ResourceData, _ interface{}) error { d.SetId("") return nil } + +// Internal validation of provider implementation +func (r *ResourceIdentity) InternalIdentityValidate() error { + if r == nil { + return fmt.Errorf(`The resource identity is empty`) + } + + if len(r.SchemaMap()) == 0 { + return fmt.Errorf(`The resource identity schema is empty`) + } + + for k, v := range r.SchemaMap() { + if !v.OptionalForImport && !v.RequiredForImport { + return fmt.Errorf(`OptionalForImport or RequiredForImport must be set for resource identity`) + } + if v.OptionalForImport && v.RequiredForImport { + return fmt.Errorf(`OptionalForImport or RequiredForImport must be set for resource identity, not both`) + } + + if v.Type == TypeMap { + return fmt.Errorf(`TypeMap is not valid for resource identity`) + } + if v.Type == TypeSet { + return fmt.Errorf(`TypeSet is not valid for resource identity`) + } + if v.Type == typeObject { + return fmt.Errorf(`TypeObject is not valid for resource identity`) + } + if v.Type == TypeInvalid { + return fmt.Errorf(`TypeInvalid is not valid for resource identity`) + } + + if v.Type == TypeList { + if v.Elem != nil { + if v.Elem == TypeMap { + return fmt.Errorf(`TypeMap is not valid for resource identity element type`) + } + if v.Elem == TypeSet { + return fmt.Errorf(`TypeSet is not valid for resource identity element type`) + } + if v.Elem == typeObject { + return fmt.Errorf(`TypeObject is not valid for resource identity element type`) + } + if v.Elem == TypeInvalid { + return fmt.Errorf(`TypeInvalid is not valid for resource identity element type`) + } + } + } + + if v.ForceNew { + return fmt.Errorf(`ForceNew is not used in resource identity`) + } + if v.Required { + return fmt.Errorf(`Required is not used in resource identity`) + } + if v.Optional { + return fmt.Errorf(`Optional is not used in resource identity`) + } + if v.WriteOnly { + return fmt.Errorf(`WriteOnly is not used in resource identity`) + } + if v.Computed { + return fmt.Errorf(`Computed is not used in resource identity`) + } + if v.Sensitive { + return fmt.Errorf(`Sensitive is not used in resource identity`) + } + if v.DiffSuppressOnRefresh { + return fmt.Errorf(`DiffSuppressOnRefresh is not used in resource identity`) + } + if v.Deprecated != "" { + return fmt.Errorf(`Deprecated is not used in resource identity`) + } + if len(v.RequiredWith) > 0 { + return fmt.Errorf(`RequiredWith is not used in resource identity`) + } + if len(v.ComputedWhen) > 0 { + return fmt.Errorf(`ComputedWhen is not used in resource identity`) + } + if len(v.AtLeastOneOf) > 0 { + return fmt.Errorf("%s: AtLeastOneOf is for configurable attributes,"+ + "there's nothing to configure for resource identity", k) + } + if len(v.ConflictsWith) > 0 { + return fmt.Errorf("%s: ConflictsWith is for configurable attributes,"+ + "there's nothing to configure for resource identity", k) + } + if v.Default != nil { + return fmt.Errorf("%s: Default is for configurable attributes,"+ + "there's nothing to configure for resource identity", k) + } + if v.DefaultFunc != nil { + return fmt.Errorf("%s: DefaultFunc is for configurable attributes,"+ + "there's nothing to configure for resource identity", k) + } + if v.DiffSuppressFunc != nil { + return fmt.Errorf("%s: DiffSuppressFunc is for suppressing differences"+ + " between config and state representation. "+ + "There is no config for resource identity, nothing to compare.", k) + } + if len(v.ExactlyOneOf) > 0 { + return fmt.Errorf("%s: ExactlyOneOf is for configurable attributes,"+ + "there's nothing to configure for resource identity", k) + } + if v.InputDefault != "" { + return fmt.Errorf("%s: InputDefault is for configurable attributes,"+ + "there's nothing to configure for resource identity", k) + } + if v.MaxItems > 0 { + return fmt.Errorf("%s: MaxItems is for configurable attributes,"+ + "there's nothing to configure for resource identity", k) + } + if v.MinItems > 0 { + return fmt.Errorf("%s: MinItems is for configurable attributes,"+ + "there's nothing to configure for resource identity", k) + } + if v.StateFunc != nil { + return fmt.Errorf("%s: StateFunc is extraneous, "+ + "value should just be changed before setting for resource identity", k) + } + if v.ValidateFunc != nil { + return fmt.Errorf("%s: ValidateFunc is for validating user input, "+ + "there's nothing to validate for resource identity", k) + } + if v.ValidateDiagFunc != nil { + return fmt.Errorf("%s: ValidateDiagFunc is for validating user input, "+ + "there's nothing to validate for resource identity", k) + } + } + + return nil +} diff --git a/helper/schema/resource_data.go b/helper/schema/resource_data.go index 5129c925c4c..096f449d1fb 100644 --- a/helper/schema/resource_data.go +++ b/helper/schema/resource_data.go @@ -28,18 +28,20 @@ import ( // The most relevant methods to take a look at are Get and Set. type ResourceData struct { // Settable (internally) - schema map[string]*Schema - config *terraform.ResourceConfig - state *terraform.InstanceState - diff *terraform.InstanceDiff - meta map[string]interface{} - timeouts *ResourceTimeout - providerMeta cty.Value + schema map[string]*Schema + identitySchema map[string]*Schema + config *terraform.ResourceConfig + state *terraform.InstanceState + diff *terraform.InstanceDiff + meta map[string]interface{} + timeouts *ResourceTimeout + providerMeta cty.Value // Don't set multiReader *MultiLevelFieldReader setWriter *MapFieldWriter newState *terraform.InstanceState + newIdentity *IdentityData partial bool once sync.Once isNew bool @@ -409,6 +411,36 @@ func (d *ResourceData) State() *terraform.InstanceState { result.Tainted = d.state.Tainted } + // If the ResourceData has an identitySchema: + // copy over identity data (by getting it so we also include changes) + // In order to build the final state attributes, we read the full + // attribute set as a map[string]interface{}, write it to a MapFieldWriter, + // and then use that map. + if d.identitySchema != nil { + rawMapIdentity := make(map[string]interface{}) + identityData, err := d.Identity() + // This error shouldn't happen, as we check for the identity schema first + if err == nil { + for k := range d.identitySchema { + raw := identityData.get([]string{k}) + if raw.Exists { + rawMapIdentity[k] = raw.Value + if raw.ValueProcessed != nil { + rawMapIdentity[k] = raw.ValueProcessed + } + } + } + + mapWIdentity := &MapFieldWriter{Schema: d.identitySchema} + if err := mapWIdentity.WriteField(nil, rawMapIdentity); err != nil { + log.Printf("[ERR] Error writing identity fields: %s", err) + return nil + } + + result.Identity = mapWIdentity.Map() + } + } + return &result } @@ -701,3 +733,32 @@ func (d *ResourceData) GetRawPlan() cty.Value { } return cty.NullVal(schemaMap(d.schema).CoreConfigSchema().ImpliedType()) } + +// IdentityData is only available for managed resources, data sources +// will return an error. // TODO: return error in case of data sources +func (d *ResourceData) Identity() (*IdentityData, error) { + // return memoized value if available + if d.newIdentity != nil { + return d.newIdentity, nil + } + + if d.identitySchema == nil { + return nil, fmt.Errorf("Resource does not have Identity schema. Please set one in order to use Identity(). This is always a problem in the provider code.") + } + + var identityData map[string]string + if d.state != nil && d.state.Identity != nil { + identityData = d.state.Identity + } + if d.diff != nil && d.diff.Identity != nil { + identityData = d.diff.Identity + } + + d.newIdentity = &IdentityData{ + schema: d.identitySchema, + raw: identityData, + panicOnError: d.panicOnError, + } + + return d.newIdentity, nil +} diff --git a/helper/schema/resource_data_test.go b/helper/schema/resource_data_test.go index 5e1d53e8a01..e81ca198c8e 100644 --- a/helper/schema/resource_data_test.go +++ b/helper/schema/resource_data_test.go @@ -4178,6 +4178,142 @@ func TestResourceDataSetType(t *testing.T) { } } +func TestResourceDataIdentity(t *testing.T) { + d := &ResourceData{ + identitySchema: map[string]*Schema{ + "foo": { + Type: TypeString, + RequiredForImport: true, + }, + }, + } + d.SetId("baz") // just required to be able to call .State() + identity, err := d.Identity() + if err != nil { + t.Fatalf("err: %s", err) + } + + // test setting + err = identity.Set("foo", "bar") + if err != nil { + t.Fatalf("err: %s", err) + } + + // test memoization + identity2, err := d.Identity() + if err != nil { + t.Fatalf("err: %s", err) + } + if identity2.Get("foo").(string) != "bar" { + t.Fatalf("expected identity to contain value for foo: %#v", identity2) + } + + // test identity added to state + state := d.State() + if state.Identity == nil { + t.Fatalf("expected identity to be added to state: %#v", state) + } + if state.Identity["foo"] != "bar" { + t.Fatalf("expected identity to contain value for foo: %#v", state) + } +} + +func TestResourceDataIdentity_initial_data_from_state(t *testing.T) { + d := &ResourceData{ + identitySchema: map[string]*Schema{ + "foo": { + Type: TypeString, + RequiredForImport: true, + }, + }, + state: &terraform.InstanceState{ + Identity: map[string]string{ + "foo": "bar", + }, + }, + } + identity, err := d.Identity() + if err != nil { + t.Fatalf("err: %s", err) + } + if identity.Get("foo").(string) != "bar" { + t.Fatalf("expected identity to contain value for foo: %#v", identity) + } +} + +func TestResourceDataIdentity_initial_data_from_diff(t *testing.T) { + d := &ResourceData{ + identitySchema: map[string]*Schema{ + "foo": { + Type: TypeString, + RequiredForImport: true, + }, + }, + // we also keep this to ensure diff takes precedence over state + state: &terraform.InstanceState{ + Identity: map[string]string{ + "foo": "bar", + }, + }, + diff: &terraform.InstanceDiff{ + Identity: map[string]string{ + "foo": "baz", + }, + }, + } + identity, err := d.Identity() + if err != nil { + t.Fatalf("err: %s", err) + } + if identity.Get("foo").(string) != "baz" { + t.Fatalf("expected identity to contain baz value for foo: %#v", identity) + } +} + +func TestResourceDataIdentity_changing_initial_data(t *testing.T) { + d := &ResourceData{ + identitySchema: map[string]*Schema{ + "foo": { + Type: TypeString, + RequiredForImport: true, + }, + }, + diff: &terraform.InstanceDiff{ + Identity: map[string]string{ + "foo": "baz", + }, + }, + } + d.SetId("bar") // just required to be able to call .State() + identity, err := d.Identity() + if err != nil { + t.Fatalf("err: %s", err) + } + err = identity.Set("foo", "qux") + if err != nil { + t.Fatalf("err: %s", err) + } + + state := d.State() + if state.Identity == nil { + t.Fatalf("expected identity to be added to state: %#v", state) + } + if state.Identity["foo"] != "qux" { + t.Fatalf("expected identity to contain qux value for foo: %#v", state) + } +} + +func TestResourceDataIdentity_no_schema(t *testing.T) { + d := &ResourceData{} + _, err := d.Identity() + if err == nil { + t.Fatalf("expected error since there's no identity schema, got: nil") + } + if diff := cmp.Diff("Resource does not have Identity schema. Please set one in order to use Identity(). This is always a problem in the provider code.", err.Error()); diff != "" { + t.Fatalf("unexpected error message (-want +got):\n%s", diff) + } +} + func testPtrTo(raw interface{}) interface{} { return &raw } diff --git a/helper/schema/resource_diff.go b/helper/schema/resource_diff.go index 9f7dab683b4..300c16de4b9 100644 --- a/helper/schema/resource_diff.go +++ b/helper/schema/resource_diff.go @@ -118,6 +118,9 @@ type ResourceDiff struct { // The schema for the resource being worked on. schema map[string]*Schema + // The identity schema for the resource being worked on. + identitySchema map[string]*Schema + // The current config for this resource. config *terraform.ResourceConfig @@ -145,15 +148,18 @@ type ResourceDiff struct { // Tracks which keys were flagged as forceNew. These keys are not saved in // newWriter, but we need to track them so that they can be re-diffed later. forcedNewKeys map[string]bool + + newIdentity *IdentityData } // newResourceDiff creates a new ResourceDiff instance. -func newResourceDiff(schema map[string]*Schema, config *terraform.ResourceConfig, state *terraform.InstanceState, diff *terraform.InstanceDiff) *ResourceDiff { +func newResourceDiff(schema schemaMapWithIdentity, config *terraform.ResourceConfig, state *terraform.InstanceState, diff *terraform.InstanceDiff) *ResourceDiff { d := &ResourceDiff{ - config: config, - state: state, - diff: diff, - schema: schema, + config: config, + state: state, + diff: diff, + schema: schema.schemaMap, + identitySchema: schema.identitySchema, } d.newWriter = &newValueWriter{ @@ -682,3 +688,22 @@ func (d *ResourceDiff) checkKey(key, caller string, nested bool) error { } return nil } + +func (d *ResourceDiff) Identity() (*IdentityData, error) { + // return memoized value if available + if d.newIdentity != nil { + return d.newIdentity, nil + } + + identity := map[string]string{} + if d.state != nil && d.state.Identity != nil { + identity = d.state.Identity + } + + d.newIdentity = &IdentityData{ + schema: d.identitySchema, + raw: identity, + } + + return d.newIdentity, nil +} diff --git a/helper/schema/resource_diff_test.go b/helper/schema/resource_diff_test.go index ef5198214bf..bd0cfd4ca0d 100644 --- a/helper/schema/resource_diff_test.go +++ b/helper/schema/resource_diff_test.go @@ -31,17 +31,18 @@ func testSetFunc(v interface{}) int { // resourceDiffTestCase provides a test case struct for SetNew and SetDiff. type resourceDiffTestCase struct { - Name string - Schema map[string]*Schema - State *terraform.InstanceState - Config *terraform.ResourceConfig - Diff *terraform.InstanceDiff - Key string - OldValue interface{} - NewValue interface{} - Expected *terraform.InstanceDiff - ExpectedKeys []string - ExpectedError bool + Name string + Schema map[string]*Schema + IdentitySchema map[string]*Schema + State *terraform.InstanceState + Config *terraform.ResourceConfig + Diff *terraform.InstanceDiff + Key string + OldValue interface{} + NewValue interface{} + Expected *terraform.InstanceDiff + ExpectedKeys []string + ExpectedError bool } // testDiffCases produces a list of test cases for use with SetNew and SetDiff. @@ -645,7 +646,7 @@ func TestSetNew(t *testing.T) { for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { m := schemaMap(tc.Schema) - d := newResourceDiff(tc.Schema, tc.Config, tc.State, tc.Diff) + d := newResourceDiff(schemaMapWithIdentity{tc.Schema, tc.IdentitySchema}, tc.Config, tc.State, tc.Diff) err := d.SetNew(tc.Key, tc.NewValue) switch { case err != nil && !tc.ExpectedError: @@ -672,7 +673,7 @@ func TestSetNewComputed(t *testing.T) { for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { m := schemaMap(tc.Schema) - d := newResourceDiff(tc.Schema, tc.Config, tc.State, tc.Diff) + d := newResourceDiff(schemaMapWithIdentity{tc.Schema, tc.IdentitySchema}, tc.Config, tc.State, tc.Diff) err := d.SetNewComputed(tc.Key) switch { case err != nil && !tc.ExpectedError: @@ -940,7 +941,7 @@ func TestForceNew(t *testing.T) { } for _, tc := range cases { t.Run(tc.Name, func(t *testing.T) { - m := schemaMap(tc.Schema) + m := schemaMapWithIdentity{tc.Schema, tc.IdentitySchema} d := newResourceDiff(m, tc.Config, tc.State, tc.Diff) err := d.ForceNew(tc.Key) switch { @@ -952,7 +953,7 @@ func TestForceNew(t *testing.T) { return } for _, k := range d.UpdatedKeys() { - if err := m.diff(context.Background(), k, m[k], tc.Diff, d, false); err != nil { + if err := m.diff(context.Background(), k, m.schemaMap[k], tc.Diff, d, false); err != nil { t.Fatalf("bad: %s", err) } } @@ -1189,7 +1190,7 @@ func TestClear(t *testing.T) { } for _, tc := range cases { t.Run(tc.Name, func(t *testing.T) { - m := schemaMap(tc.Schema) + m := schemaMapWithIdentity{tc.Schema, tc.IdentitySchema} d := newResourceDiff(m, tc.Config, tc.State, tc.Diff) err := d.Clear(tc.Key) switch { @@ -1201,7 +1202,7 @@ func TestClear(t *testing.T) { return } for _, k := range d.UpdatedKeys() { - if err := m.diff(context.Background(), k, m[k], tc.Diff, d, false); err != nil { + if err := m.diff(context.Background(), k, m.schemaMap[k], tc.Diff, d, false); err != nil { t.Fatalf("bad: %s", err) } } @@ -1438,12 +1439,12 @@ func TestGetChangedKeysPrefix(t *testing.T) { } for _, tc := range cases { t.Run(tc.Name, func(t *testing.T) { - m := schemaMap(tc.Schema) + m := schemaMapWithIdentity{tc.Schema, tc.IdentitySchema} d := newResourceDiff(m, tc.Config, tc.State, tc.Diff) keys := d.GetChangedKeysPrefix(tc.Key) for _, k := range d.UpdatedKeys() { - if err := m.diff(context.Background(), k, m[k], tc.Diff, d, false); err != nil { + if err := m.diff(context.Background(), k, m.schemaMap[k], tc.Diff, d, false); err != nil { t.Fatalf("bad: %s", err) } } @@ -1459,14 +1460,15 @@ func TestGetChangedKeysPrefix(t *testing.T) { func TestResourceDiffGetOkExists(t *testing.T) { cases := []struct { - Name string - Schema map[string]*Schema - State *terraform.InstanceState - Config *terraform.ResourceConfig - Diff *terraform.InstanceDiff - Key string - Value interface{} - Ok bool + Name string + Schema map[string]*Schema + IdentitySchema map[string]*Schema + State *terraform.InstanceState + Config *terraform.ResourceConfig + Diff *terraform.InstanceDiff + Key string + Value interface{} + Ok bool }{ /* * Primitives @@ -1814,7 +1816,7 @@ func TestResourceDiffGetOkExists(t *testing.T) { for i, tc := range cases { t.Run(fmt.Sprintf("%d-%s", i, tc.Name), func(t *testing.T) { - d := newResourceDiff(tc.Schema, tc.Config, tc.State, tc.Diff) + d := newResourceDiff(schemaMapWithIdentity{tc.Schema, tc.IdentitySchema}, tc.Config, tc.State, tc.Diff) v, ok := d.GetOkExists(tc.Key) if s, ok := v.(*Set); ok { @@ -1833,12 +1835,13 @@ func TestResourceDiffGetOkExists(t *testing.T) { func TestResourceDiffGetOkExistsSetNew(t *testing.T) { tc := struct { - Schema map[string]*Schema - State *terraform.InstanceState - Diff *terraform.InstanceDiff - Key string - Value interface{} - Ok bool + Schema map[string]*Schema + IdentitySchema map[string]*Schema + State *terraform.InstanceState + Diff *terraform.InstanceDiff + Key string + Value interface{} + Ok bool }{ Schema: map[string]*Schema{ "availability_zone": { @@ -1859,7 +1862,7 @@ func TestResourceDiffGetOkExistsSetNew(t *testing.T) { Ok: true, } - d := newResourceDiff(tc.Schema, testConfig(t, map[string]interface{}{}), tc.State, tc.Diff) + d := newResourceDiff(schemaMapWithIdentity{tc.Schema, tc.IdentitySchema}, testConfig(t, map[string]interface{}{}), tc.State, tc.Diff) if err := d.SetNew(tc.Key, tc.Value); err != nil { t.Fatalf("unexpected SetNew error: %s", err) @@ -1880,12 +1883,13 @@ func TestResourceDiffGetOkExistsSetNew(t *testing.T) { func TestResourceDiffGetOkExistsSetNewComputed(t *testing.T) { tc := struct { - Schema map[string]*Schema - State *terraform.InstanceState - Diff *terraform.InstanceDiff - Key string - Value interface{} - Ok bool + Schema map[string]*Schema + IdentitySchema map[string]*Schema + State *terraform.InstanceState + Diff *terraform.InstanceDiff + Key string + Value interface{} + Ok bool }{ Schema: map[string]*Schema{ "availability_zone": { @@ -1910,7 +1914,7 @@ func TestResourceDiffGetOkExistsSetNewComputed(t *testing.T) { Ok: false, } - d := newResourceDiff(tc.Schema, testConfig(t, map[string]interface{}{}), tc.State, tc.Diff) + d := newResourceDiff(schemaMapWithIdentity{tc.Schema, tc.IdentitySchema}, testConfig(t, map[string]interface{}{}), tc.State, tc.Diff) if err := d.SetNewComputed(tc.Key); err != nil { t.Fatalf("unexpected SetNewComputed error: %s", err) @@ -1925,13 +1929,14 @@ func TestResourceDiffGetOkExistsSetNewComputed(t *testing.T) { func TestResourceDiffNewValueKnown(t *testing.T) { cases := []struct { - Name string - Schema map[string]*Schema - State *terraform.InstanceState - Config *terraform.ResourceConfig - Diff *terraform.InstanceDiff - Key string - Expected bool + Name string + Schema map[string]*Schema + IdentitySchema map[string]*Schema + State *terraform.InstanceState + Config *terraform.ResourceConfig + Diff *terraform.InstanceDiff + Key string + Expected bool }{ { Name: "in config, no state", @@ -2102,7 +2107,7 @@ func TestResourceDiffNewValueKnown(t *testing.T) { for i, tc := range cases { t.Run(fmt.Sprintf("%d-%s", i, tc.Name), func(t *testing.T) { - d := newResourceDiff(tc.Schema, tc.Config, tc.State, tc.Diff) + d := newResourceDiff(schemaMapWithIdentity{tc.Schema, tc.IdentitySchema}, tc.Config, tc.State, tc.Diff) actual := d.NewValueKnown(tc.Key) if tc.Expected != actual { @@ -2114,13 +2119,14 @@ func TestResourceDiffNewValueKnown(t *testing.T) { func TestResourceDiffNewValueKnownSetNew(t *testing.T) { tc := struct { - Schema map[string]*Schema - State *terraform.InstanceState - Config *terraform.ResourceConfig - Diff *terraform.InstanceDiff - Key string - Value interface{} - Expected bool + Schema map[string]*Schema + IdentitySchema map[string]*Schema + State *terraform.InstanceState + Config *terraform.ResourceConfig + Diff *terraform.InstanceDiff + Key string + Value interface{} + Expected bool }{ Schema: map[string]*Schema{ "availability_zone": { @@ -2154,7 +2160,7 @@ func TestResourceDiffNewValueKnownSetNew(t *testing.T) { Expected: true, } - d := newResourceDiff(tc.Schema, tc.Config, tc.State, tc.Diff) + d := newResourceDiff(schemaMapWithIdentity{tc.Schema, tc.IdentitySchema}, tc.Config, tc.State, tc.Diff) if err := d.SetNew(tc.Key, tc.Value); err != nil { t.Fatalf("unexpected SetNew error: %s", err) @@ -2168,12 +2174,13 @@ func TestResourceDiffNewValueKnownSetNew(t *testing.T) { func TestResourceDiffNewValueKnownSetNewComputed(t *testing.T) { tc := struct { - Schema map[string]*Schema - State *terraform.InstanceState - Config *terraform.ResourceConfig - Diff *terraform.InstanceDiff - Key string - Expected bool + Schema map[string]*Schema + IdentitySchema map[string]*Schema + State *terraform.InstanceState + Config *terraform.ResourceConfig + Diff *terraform.InstanceDiff + Key string + Expected bool }{ Schema: map[string]*Schema{ "availability_zone": { @@ -2194,7 +2201,7 @@ func TestResourceDiffNewValueKnownSetNewComputed(t *testing.T) { Expected: false, } - d := newResourceDiff(tc.Schema, tc.Config, tc.State, tc.Diff) + d := newResourceDiff(schemaMapWithIdentity{tc.Schema, tc.IdentitySchema}, tc.Config, tc.State, tc.Diff) if err := d.SetNewComputed(tc.Key); err != nil { t.Fatalf("unexpected SetNewComputed error: %s", err) @@ -2208,11 +2215,12 @@ func TestResourceDiffNewValueKnownSetNewComputed(t *testing.T) { func TestResourceDiffHasChanges(t *testing.T) { cases := []struct { - Schema map[string]*Schema - State *terraform.InstanceState - Diff *terraform.InstanceDiff - Keys []string - Change bool + Schema map[string]*Schema + IdentitySchema map[string]*Schema + State *terraform.InstanceState + Diff *terraform.InstanceDiff + Keys []string + Change bool }{ // empty call d.HasChanges() { @@ -2301,7 +2309,7 @@ func TestResourceDiffHasChanges(t *testing.T) { } for i, tc := range cases { - d := newResourceDiff(tc.Schema, testConfig(t, map[string]interface{}{}), tc.State, tc.Diff) + d := newResourceDiff(schemaMapWithIdentity{tc.Schema, tc.IdentitySchema}, testConfig(t, map[string]interface{}{}), tc.State, tc.Diff) actual := d.HasChanges(tc.Keys...) if actual != tc.Change { diff --git a/helper/schema/resource_identity.go b/helper/schema/resource_identity.go new file mode 100644 index 00000000000..5609416c448 --- /dev/null +++ b/helper/schema/resource_identity.go @@ -0,0 +1,89 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// Implementation of a single identity schema version upgrade. +type IdentityUpgrader struct { + // Version is the version schema that this Upgrader will handle, converting + // it to Version+1. + Version int64 + + // Type describes the schema that this function can upgrade. Type is + // required to decode the schema if the state was stored in a legacy + // flatmap format. + Type tftypes.Type + + // Upgrade takes the JSON encoded state and the provider meta value, and + // upgrades the state one single schema version. The provided state is + // decoded into the default json types using a map[string]interface{}. It + // is up to the StateUpgradeFunc to ensure that the returned value can be + // encoded using the new schema. + Upgrade ResourceIdentityUpgradeFunc +} + +type ResourceIdentity struct { + // Version is the identity schema version. + Version int64 + + // SchemaFunc is the function that returns the schema for the + // identity. Using a function for this field allows to prevent + // storing all identity schema information in memory for the + // lifecycle of a provider. + // The types of the schema values are restricted to the types: + // - TypeBool + // - TypeFloat + // - TypeInt + // - TypeString + // - TypeList (of any of the above types) + SchemaFunc func() map[string]*Schema + + // New struct, will be similar to (Resource).StateUpgraders + IdentityUpgraders []IdentityUpgrader +} + +// Function signature for an identity schema version upgrade handler. +// +// The Context parameter stores SDK information, such as loggers. It also +// is wired to receive any cancellation from Terraform such as a system or +// practitioner sending SIGINT (Ctrl-c). +// +// The map[string]interface{} parameter contains the previous identity schema +// version data for a managed resource instance. The keys are top level attribute +// names mapped to values that can be type asserted similar to +// fetching values using the ResourceData Get* methods: +// +// - TypeBool: bool +// - TypeFloat: float +// - TypeInt: int +// - TypeList: []interface{} +// - TypeString: string +// +// In certain scenarios, the map may be nil, so checking for that condition +// upfront is recommended to prevent potential panics. +// +// The interface{} parameter is the result of the Provider type +// ConfigureFunc field execution. If the Provider does not define +// a ConfigureFunc, this will be nil. This parameter is conventionally +// used to store API clients and other provider instance specific data. +// +// The map[string]interface{} return parameter should contain the upgraded +// identity schema version data for a managed resource instance. Values must +// align to the typing mentioned above. +type ResourceIdentityUpgradeFunc func(ctx context.Context, rawState map[string]interface{}, meta interface{}) (map[string]interface{}, error) + +// SchemaMap returns the schema information for this resource identity +// defined via the SchemaFunc field. +func (ri *ResourceIdentity) SchemaMap() map[string]*Schema { + if ri == nil || ri.SchemaFunc == nil { + return nil + } + + return ri.SchemaFunc() +} diff --git a/helper/schema/resource_identity_test.go b/helper/schema/resource_identity_test.go new file mode 100644 index 00000000000..360093af710 --- /dev/null +++ b/helper/schema/resource_identity_test.go @@ -0,0 +1,13 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +import "testing" + +func TestResourceIdentity_SchemaMap_handles_nil_identity(t *testing.T) { + var ri *ResourceIdentity + if ri.SchemaMap() != nil { + t.Fatal("expected nil schema map") + } +} diff --git a/helper/schema/resource_importer.go b/helper/schema/resource_importer.go index ad9a5c3b9c6..4dd691d2269 100644 --- a/helper/schema/resource_importer.go +++ b/helper/schema/resource_importer.go @@ -6,6 +6,7 @@ package schema import ( "context" "errors" + "fmt" ) // ResourceImporter defines how a resource is imported in Terraform. This @@ -77,6 +78,55 @@ func ImportStatePassthrough(d *ResourceData, m interface{}) ([]*ResourceData, er // ImportStatePassthroughContext is an implementation of StateContextFunc that can be // used to simply pass the ID directly through. This should be used only // in the case that an ID-only refresh is possible. +// Please note that this implementation does not work when using resource identity as +// an Id still has to be set and the identity might contain multiple fields +// that are not the same as the ID. func ImportStatePassthroughContext(ctx context.Context, d *ResourceData, m interface{}) ([]*ResourceData, error) { return []*ResourceData{d}, nil } + +// ImportStatePassthroughWithIdentity creates a StateContextFunc that supports both +// identity-based and ID-only resource import scenarios. This function is useful +// when a resource can be imported either by its unique ID or by an identity attribute. +// +// The `idAttributePath` parameter specifies the name of the identity attribute +// to use when importing by identity. Since identity attributes are "flat", +// `idAttributePath` should be a simple attribute name (e.g., "name" or "identifier"). +// Note that the identity attribute must be a string, as this function expects +// to set the resource ID using the value of the specified attribute. +// +// If the resource is imported by ID (i.e., `d.Id()` is already set), the function +// simply returns the resource data as-is. Otherwise, it attempts to retrieve the +// identity attribute specified by `idAttributePath` and sets it as the resource ID. +// +// Parameters: +// - idAttributePath: The name of the identity attribute to use for setting the ID. +// +// Returns: +// - A StateContextFunc that handles the import logic. +func ImportStatePassthroughWithIdentity(idAttributePath string) StateContextFunc { + return func(ctx context.Context, d *ResourceData, m interface{}) ([]*ResourceData, error) { + // If we import by id, we just return the resource data as is, no need to change it + if d.Id() != "" { + return []*ResourceData{d}, nil + } + + // If we import by identity, we need to set the id based on the idAttributePath + identity, err := d.Identity() + if err != nil { + return nil, fmt.Errorf("error getting identity: %s", err) + } + id, exists := identity.GetOk(idAttributePath) + if !exists { + return nil, fmt.Errorf("expected identity to contain key %s", idAttributePath) + } + idStr, ok := id.(string) + if !ok { + return nil, fmt.Errorf("expected identity key %s to be a string, was: %T", idAttributePath, id) + } + + d.SetId(idStr) + + return []*ResourceData{d}, nil + } +} diff --git a/helper/schema/resource_importer_test.go b/helper/schema/resource_importer_test.go index 36e4256a0bd..6a08634c1de 100644 --- a/helper/schema/resource_importer_test.go +++ b/helper/schema/resource_importer_test.go @@ -3,7 +3,11 @@ package schema -import "testing" +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) func TestInternalValidate(t *testing.T) { r := &ResourceImporter{ @@ -14,3 +18,186 @@ func TestInternalValidate(t *testing.T) { t.Fatal("ResourceImporter should not allow State and StateContext to be set") } } + +func TestImportStatePassthroughWithIdentity(t *testing.T) { + // shared among all tests, defined once to keep them shorter + identitySchema := map[string]*Schema{ + "email": { + Type: TypeString, + RequiredForImport: true, + }, + "region": { + Type: TypeString, + OptionalForImport: true, + }, + } + + tests := []struct { + name string + idAttributePath string + resourceData *ResourceData + expectedResourceData *ResourceData + expectedError string + }{ + { + name: "import from id just sets id", + idAttributePath: "email", + resourceData: &ResourceData{ + identitySchema: identitySchema, + state: &terraform.InstanceState{ + ID: "hello@example.internal", + }, + }, + expectedResourceData: &ResourceData{ + identitySchema: identitySchema, + state: &terraform.InstanceState{ + ID: "hello@example.internal", + }, + }, + }, + { + name: "import from identity sets id and identity", + idAttributePath: "email", + resourceData: &ResourceData{ + identitySchema: identitySchema, + state: &terraform.InstanceState{ + Identity: map[string]string{ + "email": "hello@example.internal", + }, + }, + }, + expectedResourceData: &ResourceData{ + identitySchema: identitySchema, + state: &terraform.InstanceState{ + ID: "hello@example.internal", + }, + newIdentity: &IdentityData{ + schema: identitySchema, + raw: map[string]string{ + "email": "hello@example.internal", + }, + }, + }, + }, + { + name: "import from identity sets id and identity (with region set)", + idAttributePath: "email", + resourceData: &ResourceData{ + identitySchema: identitySchema, + state: &terraform.InstanceState{ + Identity: map[string]string{ + "email": "hello@example.internal", + "region": "eu-west-1", + }, + }, + }, + expectedResourceData: &ResourceData{ + identitySchema: identitySchema, + state: &terraform.InstanceState{ + ID: "hello@example.internal", + }, + newIdentity: &IdentityData{ + schema: identitySchema, + raw: map[string]string{ + "email": "hello@example.internal", + "region": "eu-west-1", + }, + }, + }, + }, + { + name: "import from identity fails without required field", + idAttributePath: "email", + resourceData: &ResourceData{ + identitySchema: identitySchema, + state: &terraform.InstanceState{ + Identity: map[string]string{ + "region": "eu-west-1", + }, + }, + }, + expectedError: "expected identity to contain key email", + }, + { + name: "import from identity fails if attribute is not a string", + idAttributePath: "number", + resourceData: &ResourceData{ + identitySchema: map[string]*Schema{ + "number": { + Type: TypeInt, + RequiredForImport: true, + }, + }, + state: &terraform.InstanceState{ + Identity: map[string]string{ + "number": "1", + }, + }, + }, + expectedError: "expected identity key number to be a string, was: int", + }, + { + name: "import from identity fails without schema", + idAttributePath: "email", + resourceData: &ResourceData{ + state: &terraform.InstanceState{ + Identity: map[string]string{ + "email": "hello@example.internal", + }, + }, + }, + expectedError: "error getting identity: Resource does not have Identity schema. Please set one in order to use Identity(). This is always a problem in the provider code.", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + results, err := ImportStatePassthroughWithIdentity(test.idAttributePath)(nil, test.resourceData, nil) + if err != nil { + if test.expectedError == "" { + t.Fatalf("unexpected error: %s", err) + } + if err.Error() != test.expectedError { + t.Fatalf("expected error: %s, got: %s", test.expectedError, err) + } + return // we don't expect any results if there is an error + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got: %d", len(results)) + } + // compare id and identity in resource data + if results[0].Id() != test.expectedResourceData.Id() { + t.Fatalf("expected id: %s, got: %s", test.expectedResourceData.Id(), results[0].Id()) + } + // compare identity + expectedIdentity, err := test.expectedResourceData.Identity() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + resultIdentity, err := results[0].Identity() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + // check whether all result identity attributes exist as expected + for key := range expectedIdentity.schema { + expected := expectedIdentity.getRaw(key) + if expected.Exists { + result := resultIdentity.getRaw(key) + if !result.Exists { + t.Fatalf("expected identity attribute %s to exist", key) + } + if expected.Value != result.Value { + t.Fatalf("expected identity attribute %s to be %s, got: %s", key, expected.Value, result.Value) + } + } + } + // check whether there are no additional attributes in the result identity + for key := range resultIdentity.schema { + if _, ok := expectedIdentity.schema[key]; !ok { + t.Fatalf("unexpected identity attribute %s", key) + } + } + }) + } +} diff --git a/helper/schema/resource_test.go b/helper/schema/resource_test.go index 447338e6d52..3a9c03f48ff 100644 --- a/helper/schema/resource_test.go +++ b/helper/schema/resource_test.go @@ -1659,3 +1659,432 @@ func TestResource_ContextTimeout(t *testing.T) { t.Fatal("context does not have timeout") } } + +func TestResourceInternalIdentityValidate(t *testing.T) { + cases := map[string]struct { + In *ResourceIdentity + Err bool + }{ + "nil": { + nil, + true, + }, + + "schema is nil": { + &ResourceIdentity{}, + true, + }, + + "OptionalForImport and RequiredForImport both false": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + OptionalForImport: false, + RequiredForImport: false, + }, + } + }, + }, + true, + }, + + "OptionalForImport and RequiredForImport both true": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + OptionalForImport: true, + RequiredForImport: true, + }, + } + }, + }, + true, + }, + + "TypeMap is not valid": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": {Type: TypeMap, OptionalForImport: true}, + } + }, + }, + true, + }, + + "TypeSet is not valid": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": {Type: TypeSet, OptionalForImport: true}, + } + }, + }, + true, + }, + + "TypeObject is not valid": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": {Type: typeObject, OptionalForImport: true}, + } + }, + }, + true, + }, + + "TypeInvalid is not valid": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": {Type: TypeInvalid, OptionalForImport: true}, + } + }, + }, + true, + }, + + "TypeList contains TypeMap": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeList, Elem: TypeMap, OptionalForImport: true, + }, + } + }, + }, + true, + }, + + " TypeList contains TypeSet": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeList, + Elem: &Resource{ + Schema: map[string]*Schema{ + "bar": { + Type: TypeSet, + RequiredForImport: true, + }, + }, + }, + }, + } + }, + }, + true, + }, + + "TypeList contains TypeInvalid": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeList, Elem: TypeInvalid, OptionalForImport: true, + }, + } + }, + }, + true, + }, + + "ForceNew is set": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + ForceNew: true, + OptionalForImport: true, + }, + } + }, + }, + true, + }, + + "Optional is set": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + OptionalForImport: true, + }, + } + }, + }, + true, + }, + + "Required is set": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + Required: true, + OptionalForImport: true, + }, + } + }, + }, + true, + }, + + "WriteOnly is set": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + WriteOnly: true, + OptionalForImport: true, + }, + } + }, + }, + true, + }, + + "Computed is set": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + Computed: true, + OptionalForImport: true, + }, + } + }, + }, + true, + }, + + "Deprecated is set": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + Deprecated: "deprecated", + OptionalForImport: true, + }, + } + }, + }, + true, + }, + + "Default is set": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + Default: 42, + OptionalForImport: true, + }, + } + }, + }, + true, + }, + + "MaxItems is set": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + MaxItems: 5, + OptionalForImport: true, + }, + } + }, + }, + true, + }, + + "MinItems is set": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + MinItems: 1, + OptionalForImport: true, + }, + } + }, + }, + true, + }, + + "DiffSuppressOnRefresh is set": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + DiffSuppressOnRefresh: true, + OptionalForImport: true, + }, + } + }, + }, + true, + }, + + "RequiredWith is set": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + RequiredWith: []string{"bar"}, + OptionalForImport: true, + }, + } + }, + }, + true, + }, + + "ComputedWhen is set": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + ComputedWhen: []string{"bar"}, + OptionalForImport: true, + }, + } + }, + }, + true, + }, + + "DefaultFunc is set": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + DefaultFunc: func() (interface{}, error) { return 42, nil }, + OptionalForImport: true, + }, + } + }, + }, + true, + }, + + "StateFunc is set": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + StateFunc: func(val interface{}) string { return "" }, + OptionalForImport: true, + }, + } + }, + }, + true, + }, + + "ValidateFunc is set": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + ValidateFunc: func(val interface{}, key string) (ws []string, es []error) { return nil, nil }, + OptionalForImport: true, + }, + } + }, + }, + true, + }, + + "AtLeastOneOf is set": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + AtLeastOneOf: []string{"bar"}, + OptionalForImport: true, + }, + } + }, + }, + true, + }, + + "ConflictsWith is set": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, + ConflictsWith: []string{"bar"}, + OptionalForImport: true, + }, + } + }, + }, + true, + }, + + "Valid resource identity OptionalForImport": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, OptionalForImport: true}, + } + }, + }, + false, + }, + + "Valid resource identity RequiredorImport": { + &ResourceIdentity{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "foo": { + Type: TypeInt, RequiredForImport: true}, + } + }, + }, + false, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := tc.In.InternalIdentityValidate() + if err != nil && !tc.Err { + t.Fatalf("%s: expected validation to pass: %s", name, err) + } + if err == nil && tc.Err { + t.Fatalf("%s: expected validation to fail", name) + } + }) + } +} diff --git a/helper/schema/schema.go b/helper/schema/schema.go index ea6cd768d18..2861b4ad31a 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -93,6 +93,22 @@ type Schema struct { // with Required. Optional bool + // RequiredForImport indicates whether the practitioner must enter a value + // in the import block for this attribute when importing a resource. + // + // RequiredForImport is only valid for identity schemas and either + // RequiredForImport or OptionalForImport must be set to true. + RequiredForImport bool + + // OptionalForImport indicates whether the practitioner can choose to not + // enter a value in the import block for this attribute when importing a + // resource. For example, this can be data that would normally be the default + // of the configured provider running the import. + // + // OptionalForImport is only valid for identity schemas and either + // RequiredForImport or OptionalForImport must be set to true. + OptionalForImport bool + // Computed indicates whether the provider may return its own value for // this attribute or not. Computed cannot be used with Required. If // Required and Optional are both false, the attribute will be considered @@ -631,6 +647,13 @@ type InternalMap = schemaMap // schemaMap is a wrapper that adds nice functions on top of schemas. type schemaMap map[string]*Schema +// schemaMapWithIdentity is a wrapper around schemaMap that allows passing an +// identity schema to ResourceData{} structs that are returned. +type schemaMapWithIdentity struct { + schemaMap + identitySchema map[string]*Schema +} + func (m schemaMap) panicOnError() bool { return os.Getenv("TF_ACC") != "" } @@ -638,17 +661,27 @@ func (m schemaMap) panicOnError() bool { // Data returns a ResourceData for the given schema, state, and diff. // // The diff is optional. -func (m schemaMap) Data( +func (m schemaMapWithIdentity) Data( s *terraform.InstanceState, d *terraform.InstanceDiff) (*ResourceData, error) { return &ResourceData{ - schema: m, - state: s, - diff: d, - panicOnError: m.panicOnError(), + schema: m.schemaMap, + identitySchema: m.identitySchema, + state: s, + diff: d, + panicOnError: m.panicOnError(), }, nil } +// Data returns a ResourceData for the given schema, state, and diff. +// +// The diff is optional. +func (m schemaMap) Data( + s *terraform.InstanceState, + d *terraform.InstanceDiff) (*ResourceData, error) { + return schemaMapWithIdentity{m, nil}.Data(s, d) +} + // DeepCopy returns a copy of this schemaMap. The copy can be safely modified // without affecting the original. func (m *schemaMap) DeepCopy() schemaMap { @@ -659,9 +692,20 @@ func (m *schemaMap) DeepCopy() schemaMap { return *copiedMap.(*schemaMap) } +// DeepCopy returns a copy of this schemaMapWithIdentity. The copy can be safely modified +// without affecting the original. +func (m *schemaMapWithIdentity) DeepCopy() schemaMapWithIdentity { + copiedMap := schemaMapWithIdentity{} + copiedMap.schemaMap = m.schemaMap.DeepCopy() + identitySchema := schemaMap(m.identitySchema) + copiedMap.identitySchema = identitySchema.DeepCopy() + + return copiedMap +} + // Diff returns the diff for a resource given the schema map, // state, and configuration. -func (m schemaMap) Diff( +func (m schemaMapWithIdentity) Diff( ctx context.Context, s *terraform.InstanceState, c *terraform.ResourceConfig, @@ -677,16 +721,18 @@ func (m schemaMap) Diff( result.RawConfig = s.RawConfig result.RawState = s.RawState result.RawPlan = s.RawPlan + result.Identity = s.Identity } d := &ResourceData{ - schema: m, - state: s, - config: c, - panicOnError: m.panicOnError(), + schema: m.schemaMap, + identitySchema: m.identitySchema, + state: s, + config: c, + panicOnError: m.panicOnError(), } - for k, schema := range m { + for k, schema := range m.schemaMap { err := m.diff(ctx, k, schema, result, d, false) if err != nil { return nil, err @@ -714,11 +760,36 @@ func (m schemaMap) Diff( return nil, err } for _, k := range rd.UpdatedKeys() { - err := m.diff(ctx, k, mc[k], result, rd, false) + err := m.diff(ctx, k, mc.schemaMap[k], result, rd, false) if err != nil { return nil, err } } + // copy over identity data (by getting it so we also include changes) + // In order to build the final identity attributes, we read the full + // attribute set as a map[string]interface{}, write it to a MapFieldWriter, + // and then use that map. + rawMapIdentity := make(map[string]interface{}) + identityData, err := rd.Identity() + if err == nil && d.identitySchema != nil { + for k := range d.identitySchema { + raw := identityData.get([]string{k}) + if raw.Exists && !raw.Computed { + rawMapIdentity[k] = raw.Value + if raw.ValueProcessed != nil { + rawMapIdentity[k] = raw.ValueProcessed + } + } + } + + mapWIdentity := &MapFieldWriter{Schema: d.identitySchema} + if err := mapWIdentity.WriteField(nil, rawMapIdentity); err != nil { + log.Printf("[ERR] Error writing identity fields: %s", err) + return nil, err + } + + result.Identity = mapWIdentity.Map() + } // TODO: else log error? } if handleRequiresNew { @@ -743,7 +814,7 @@ func (m schemaMap) Diff( d.init() // Perform the diff again - for k, schema := range m { + for k, schema := range m.schemaMap { err := m.diff(ctx, k, schema, result2, d, false) if err != nil { return nil, err @@ -758,11 +829,37 @@ func (m schemaMap) Diff( return nil, err } for _, k := range rd.UpdatedKeys() { - err := m.diff(ctx, k, mc[k], result2, rd, false) + err := m.diff(ctx, k, mc.schemaMap[k], result2, rd, false) if err != nil { return nil, err } } + // copy over identity data (by getting it so we also include changes) + // In order to build the final identity attributes, we read the full + // attribute set as a map[string]interface{}, write it to a MapFieldWriter, + // and then use that map. + rawMapIdentity := make(map[string]interface{}) + identityData, err := rd.Identity() + if err == nil && d.identitySchema != nil { + for k := range d.identitySchema { + raw := identityData.get([]string{k}) + if raw.Exists && !raw.Computed { + rawMapIdentity[k] = raw.Value + if raw.ValueProcessed != nil { + rawMapIdentity[k] = raw.ValueProcessed + } + } + } + + mapWIdentity := &MapFieldWriter{Schema: d.identitySchema} + if err := mapWIdentity.WriteField(nil, rawMapIdentity); err != nil { + log.Printf("[ERR] Error writing identity fields: %s", err) + return nil, err + } + + result2.Identity = mapWIdentity.Map() + } // TODO: else log error? + } // Force all the fields to not force a new since we know what we @@ -817,6 +914,18 @@ func (m schemaMap) Diff( return result, nil } +// Diff returns the diff for a resource given the schema map, +// state, and configuration. +func (m schemaMap) Diff( + ctx context.Context, + s *terraform.InstanceState, + c *terraform.ResourceConfig, + customizeDiff CustomizeDiffFunc, + meta interface{}, + handleRequiresNew bool) (*terraform.InstanceDiff, error) { + return schemaMapWithIdentity{m, nil}.Diff(ctx, s, c, customizeDiff, meta, handleRequiresNew) +} + // Validate validates the configuration against this schema mapping. func (m schemaMap) Validate(c *terraform.ResourceConfig) diag.Diagnostics { return m.validateObject("", m, c, cty.Path{}) @@ -829,6 +938,7 @@ func (m schemaMap) InternalValidate(topSchemaMap schemaMap) error { return m.internalValidate(topSchemaMap, false) } +// TODO: Think about how to check something is a resource Identity so that we can check if RequiredForImport or OptionalForImport is set func (m schemaMap) internalValidate(topSchemaMap schemaMap, attrsOnly bool) error { if topSchemaMap == nil { topSchemaMap = m @@ -862,6 +972,13 @@ func (m schemaMap) internalValidate(topSchemaMap schemaMap, attrsOnly bool) erro return fmt.Errorf("%s: WriteOnly cannot be set with ForceNew", k) } + if v.RequiredForImport { + return fmt.Errorf("%s: RequiredForImport is only valid for resource identity schemas", k) + } + if v.OptionalForImport { + return fmt.Errorf("%s: OptionalForImport is only valid for resource identity schemas", k) + } + computedOnly := v.Computed && !v.Optional switch v.ConfigMode { @@ -902,6 +1019,10 @@ func (m schemaMap) internalValidate(topSchemaMap schemaMap, attrsOnly bool) erro return fmt.Errorf("%s: Default cannot be set with WriteOnly", k) } + if v.WriteOnly && v.Default != nil { + return fmt.Errorf("%s: Default cannot be set with WriteOnly", k) + } + if v.WriteOnly && v.DefaultFunc != nil { return fmt.Errorf("%s: DefaultFunc cannot be set with WriteOnly", k) } @@ -1652,7 +1773,7 @@ func (m schemaMap) diffString( // DiffSuppressOnRefresh, checks whether the new value is materially different // than the old and if not it overwrites the new value with the old one, // in-place. -func (m schemaMap) handleDiffSuppressOnRefresh(ctx context.Context, oldState, newState *terraform.InstanceState) { +func (m schemaMapWithIdentity) handleDiffSuppressOnRefresh(ctx context.Context, oldState, newState *terraform.InstanceState) { if newState == nil || oldState == nil { return // nothing to do, then } @@ -1672,7 +1793,7 @@ func (m schemaMap) handleDiffSuppressOnRefresh(ctx context.Context, oldState, ne continue // no change to test } - schemaList := addrToSchema(strings.Split(k, "."), m) + schemaList := addrToSchema(strings.Split(k, "."), m.schemaMap) if len(schemaList) == 0 { continue // no schema? weird, but not our responsibility to handle } diff --git a/helper/schema/schema_test.go b/helper/schema/schema_test.go index 988e43ea027..bfb566e9210 100644 --- a/helper/schema/schema_test.go +++ b/helper/schema/schema_test.go @@ -3114,8 +3114,8 @@ func TestSchemaMap_Diff(t *testing.T) { t.Fatalf("err: %s", err) } - if !reflect.DeepEqual(tc.Diff, d) { - t.Fatalf("expected:\n%#v\n\ngot:\n%#v", tc.Diff, d) + if diff := cmp.Diff(tc.Diff, d); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) } }) } @@ -5414,6 +5414,26 @@ func TestSchemaMap_InternalValidate(t *testing.T) { }, true, }, + "OptionalForImport returns error": { + map[string]*Schema{ + "foo": { + Type: TypeInt, + OptionalForImport: true, + Optional: true, + }, + }, + true, + }, + "RequiredForImport returns error": { + map[string]*Schema{ + "foo": { + Type: TypeString, + RequiredForImport: true, + Required: true, + }, + }, + true, + }, } for tn, tc := range cases { @@ -5878,7 +5898,7 @@ func TestSchema_DiffSuppressOnRefresh(t *testing.T) { for tn, tc := range cases { t.Run(tn, func(t *testing.T) { - schema := tc.Schema + schema := schemaMapWithIdentity{tc.Schema, nil} // TODO: add IdentitySchema here priorState := &terraform.InstanceState{ Attributes: tc.PriorState, } diff --git a/helper/schema/shims.go b/helper/schema/shims.go index e8baebd70cf..fb351358df4 100644 --- a/helper/schema/shims.go +++ b/helper/schema/shims.go @@ -43,7 +43,7 @@ func diffFromValues(ctx context.Context, prior, planned, config cty.Value, res * removeConfigUnknowns(cfg.Config) removeConfigUnknowns(cfg.Raw) - diff, err := schemaMap(res.SchemaMap()).Diff(ctx, instanceState, cfg, cust, nil, false) + diff, err := schemaMapWithIdentity{res.SchemaMap(), res.Identity.SchemaMap()}.Diff(ctx, instanceState, cfg, cust, nil, false) if err != nil { return nil, err } diff --git a/helper/schema/testing.go b/helper/schema/testing.go index bdf56d9012f..202f8a31f60 100644 --- a/helper/schema/testing.go +++ b/helper/schema/testing.go @@ -30,3 +30,20 @@ func TestResourceDataRaw(t testing.T, schema map[string]*Schema, raw map[string] return result } + +// TestResourceDataWithIdentityRaw creates a ResourceData with an identity from a raw identity map. +func TestResourceDataWithIdentityRaw(t testing.T, schema map[string]*Schema, identitySchema map[string]*Schema, raw map[string]string) *ResourceData { + t.Helper() + + sm := schemaMapWithIdentity{schema, identitySchema} + state := terraform.InstanceState{ + Identity: raw, + } + + result, err := sm.Data(&state, nil) + if err != nil { + t.Fatalf("err: %s", err) + } + + return result +} diff --git a/helper/validation/strings_test.go b/helper/validation/strings_test.go index 9068c4f72f3..1bb78630a0a 100644 --- a/helper/validation/strings_test.go +++ b/helper/validation/strings_test.go @@ -243,6 +243,25 @@ func TestValidationStringIsWhiteSpace(t *testing.T) { } } +func TestValidationStringLenBetween(t *testing.T) { + runTestCases(t, []testCase{ + { + val: "abc", + f: StringLenBetween(1, 5), + }, + { + val: "InvalidValue", + f: StringLenBetween(1, 5), + expectedErr: regexp.MustCompile(`expected length of [\w]+ to be in the range \(1 \- 5\), got InvalidValue`), + }, + { + val: 1, + f: StringLenBetween(1, 5), + expectedErr: regexp.MustCompile(`expected type of [\w]+ to be string`), + }, + }) +} + func TestValidationStringIsBase64(t *testing.T) { cases := map[string]struct { Value interface{} diff --git a/helper/validation/write_only.go b/helper/validation/write_only.go index 303e72dd2d1..84418ef170e 100644 --- a/helper/validation/write_only.go +++ b/helper/validation/write_only.go @@ -22,6 +22,10 @@ import ( // For lists: cty.Index(cty.UnknownVal(cty.Number)), // For maps: cty.Index(cty.UnknownVal(cty.String)), // For sets: cty.Index(cty.UnknownVal(cty.Object(nil))), +// +// NOTE: This validator will produce persistent warnings for practitioners on every Terraform run as long as the specified non-write-only attribute +// has a value in the configuration. The validator will also produce warnings for users of shared modules +// who cannot immediately take action on the warning. func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttribute cty.Path) schema.ValidateRawResourceConfigFunc { return func(ctx context.Context, req schema.ValidateResourceConfigFuncRequest, resp *schema.ValidateResourceConfigFuncResponse) { if !req.WriteOnlyAttributesAllowed { diff --git a/internal/configs/configschema/schema.go b/internal/configs/configschema/schema.go index 983d20bdf08..5410376cfad 100644 --- a/internal/configs/configschema/schema.go +++ b/internal/configs/configschema/schema.go @@ -95,6 +95,16 @@ type Attribute struct { // Practitioners that choose a value for this attribute with older // versions of Terraform will receive an error. WriteOnly bool + + // RequiredForImport, if set to true, specifies that an omitted or null value is + // not permitted when importing by the identity. This field conflicts with OptionalForImport. + // Only valid for identity schemas. + RequiredForImport bool + + // OptionalForImport, if set to true, specifies that an omitted or null value is + // permitted when importing by the identity. This field conflicts with RequiredForImport. + // Only valid for identity schemas. + OptionalForImport bool } // NestedBlock represents the embedding of one block within another. diff --git a/internal/plugin/convert/schema.go b/internal/plugin/convert/schema.go index a02aaec0078..d6fd7d8d9ab 100644 --- a/internal/plugin/convert/schema.go +++ b/internal/plugin/convert/schema.go @@ -172,6 +172,30 @@ func ConfigSchemaToProto(ctx context.Context, b *configschema.Block) *tfprotov5. return block } +func ConfigIdentitySchemaToProto(ctx context.Context, identitySchema *configschema.Block) []*tfprotov5.ResourceIdentitySchemaAttribute { + output := make([]*tfprotov5.ResourceIdentitySchemaAttribute, 0) + + for name, a := range identitySchema.Attributes { + + attr := &tfprotov5.ResourceIdentitySchemaAttribute{ + Name: name, + Description: a.Description, + OptionalForImport: a.OptionalForImport, + RequiredForImport: a.RequiredForImport, + } + + var err error + attr.Type, err = tftypeFromCtyType(a.Type) + if err != nil { + panic(err) + } + + output = append(output, attr) + } + + return output +} + func protoStringKind(ctx context.Context, k configschema.StringKind) tfprotov5.StringKind { switch k { default: diff --git a/meta/meta.go b/meta/meta.go index 0a928c8b042..1ba16498736 100644 --- a/meta/meta.go +++ b/meta/meta.go @@ -17,7 +17,7 @@ import ( // // Deprecated: Use Go standard library [runtime/debug] package build information // instead. -var SDKVersion = "2.36.1" +var SDKVersion = "2.37.0" // A pre-release marker for the version. If this is "" (empty string) // then it means that it is a final release. Otherwise, this is a pre-release diff --git a/terraform/diff.go b/terraform/diff.go index 3b4179b4b3b..383da828f97 100644 --- a/terraform/diff.go +++ b/terraform/diff.go @@ -51,6 +51,10 @@ type InstanceDiff struct { // meant to be used for additional data a resource may want to pass through. // The value here must only contain Go primitives and collections. Meta map[string]interface{} + + // Identity is the identity data used to track resource identity + // starting in Terraform 1.12+ + Identity map[string]string } func (d *InstanceDiff) Lock() { d.mu.Lock() } @@ -663,7 +667,8 @@ func (d *InstanceDiff) Empty() bool { return !d.Destroy && !d.DestroyTainted && !d.DestroyDeposed && - len(d.Attributes) == 0 + len(d.Attributes) == 0 && + len(d.Identity) == 0 } // Equal compares two diffs for exact equality. diff --git a/terraform/state.go b/terraform/state.go index 60723de772a..f905c2a93d2 100644 --- a/terraform/state.go +++ b/terraform/state.go @@ -30,6 +30,13 @@ const ( stateVersion = 3 ) +// ImportBeforeReadMetaKey is an internal private field used to indicate that the current resource state and identity +// were provided most recently by the ImportResourceState RPC. This indicates that the state is an import stub and identity +// has not been stored in state yet. +// +// When detected, this key should be cleared before returning from the ReadResource RPC. +var ImportBeforeReadMetaKey = ".import_before_read" + // rootModulePath is the path of the root module var rootModulePath = []string{"root"} @@ -1305,6 +1312,10 @@ type InstanceState struct { // and collections. Meta map[string]interface{} `json:"meta"` + // Identity is the identity data used to track resource identity + // starting in Terraform 1.12+ + Identity map[string]string `json:"identity"` + ProviderMeta cty.Value RawConfig cty.Value @@ -1330,6 +1341,9 @@ func (s *InstanceState) init() { if s.Meta == nil { s.Meta = make(map[string]interface{}) } + if s.Identity == nil { + s.Identity = make(map[string]string) + } s.Ephemeral.init() } @@ -1391,6 +1405,7 @@ func (s *InstanceState) Set(from *InstanceState) { s.Ephemeral = from.Ephemeral s.Meta = from.Meta s.Tainted = from.Tainted + s.Identity = from.Identity } func (s *InstanceState) DeepCopy() *InstanceState { @@ -1470,6 +1485,8 @@ func (s *InstanceState) Equal(other *InstanceState) bool { return false } + // TODO: compare identity + return true } @@ -1547,6 +1564,8 @@ func (s *InstanceState) String() string { buf.WriteString(fmt.Sprintf("%s = %s\n", ak, av)) } + // TODO: add identity + buf.WriteString(fmt.Sprintf("Tainted = %t\n", s.Tainted)) return buf.String() diff --git a/tools/go.mod b/tools/go.mod index 4dbb34ee456..b9c4f8c01da 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -1,8 +1,8 @@ module tools -go 1.22.7 +go 1.23.7 -require github.com/hashicorp/copywrite v0.20.0 +require github.com/hashicorp/copywrite v0.22.0 require ( github.com/AlecAivazis/survey/v2 v2.3.7 // indirect @@ -17,7 +17,7 @@ require ( github.com/fsnotify/fsnotify v1.5.4 // indirect github.com/go-openapi/errors v0.20.2 // indirect github.com/go-openapi/strfmt v0.21.3 // indirect - github.com/golang-jwt/jwt/v4 v4.5.1 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/go-github/v45 v45.2.0 // indirect github.com/google/go-github/v53 v53.0.0 // indirect @@ -47,14 +47,14 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/thanhpk/randstr v1.0.4 // indirect go.mongodb.org/mongo-driver v1.10.0 // indirect - golang.org/x/crypto v0.31.0 // indirect + golang.org/x/crypto v0.36.0 // indirect golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect - golang.org/x/net v0.33.0 // indirect + golang.org/x/net v0.38.0 // indirect golang.org/x/oauth2 v0.8.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/term v0.27.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/term v0.30.0 // indirect + golang.org/x/text v0.23.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/tools/go.sum b/tools/go.sum index a889a22a8a6..ac09f376cc0 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -96,8 +96,8 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= -github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -144,8 +144,8 @@ github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgf github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/consul/api v1.13.0/go.mod h1:ZlVrynguJKcYr54zGaDbaL3fOvKC9m72FhPvA8T35KQ= github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= -github.com/hashicorp/copywrite v0.20.0 h1:i+iNq4lWsGopKIhC0HfZjUvNAnXnU/Pc5e+4L5WF+1Y= -github.com/hashicorp/copywrite v0.20.0/go.mod h1:mu6DAyUI6m6vq8weoJn9a0HDuUUrV+0GQdRp4mD50yU= +github.com/hashicorp/copywrite v0.22.0 h1:mqjMrgP3VptS7aLbu2l39rtznoK+BhphHst6i7HiTAo= +github.com/hashicorp/copywrite v0.22.0/go.mod h1:FqvGJt2+yoYDpVYgFSdg3R2iyhkCVaBmPMhfso0MR2k= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= @@ -372,8 +372,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM= golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= @@ -411,8 +411,8 @@ 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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 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-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -429,8 +429,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -477,16 +477,16 @@ golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= @@ -498,8 +498,8 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/website/README.md b/website/README.md index 03473e00623..8d1e314f14b 100644 --- a/website/README.md +++ b/website/README.md @@ -1,5 +1,8 @@ # Terraform Documentation +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + This directory contains the portions of [the Terraform website][terraform.io] that pertain to the Terraform Plugin SDK. The files in this directory are intended to be used in conjunction with diff --git a/website/docs/plugin/sdkv2/best-practices/deprecations.mdx b/website/docs/plugin/sdkv2/best-practices/deprecations.mdx index 3499ad9fa74..e9309db140a 100644 --- a/website/docs/plugin/sdkv2/best-practices/deprecations.mdx +++ b/website/docs/plugin/sdkv2/best-practices/deprecations.mdx @@ -3,6 +3,9 @@ page_title: 'Plugin Development - SDKv2 Deprecations, Removals, and Renames Best description: 'Recommendations for deprecations, removals, and renames.' --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Deprecations, Removals, and Renames Terraform is trusted for managing many facets of infrastructure across many organizations. Part of that trust is due to consistent versioning guidelines and setting expectations for various levels of upgrades. Ensuring backwards compatibility for all patch and minor releases, potentially in concert with any upcoming major changes, is recommended and supported by the Terraform development framework. This allows operators to iteratively update their Terraform configurations rather than require massive refactoring. diff --git a/website/docs/plugin/sdkv2/best-practices/detecting-drift.mdx b/website/docs/plugin/sdkv2/best-practices/detecting-drift.mdx index 762c6347022..0e1f6df0cb8 100644 --- a/website/docs/plugin/sdkv2/best-practices/detecting-drift.mdx +++ b/website/docs/plugin/sdkv2/best-practices/detecting-drift.mdx @@ -6,6 +6,9 @@ description: |- infrastructure has changed. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Detecting Drift One of the core challenges of infrastructure as code is keeping an up-to-date diff --git a/website/docs/plugin/sdkv2/best-practices/index.mdx b/website/docs/plugin/sdkv2/best-practices/index.mdx index 85d6b2d44e4..1b5463df3a0 100644 --- a/website/docs/plugin/sdkv2/best-practices/index.mdx +++ b/website/docs/plugin/sdkv2/best-practices/index.mdx @@ -4,6 +4,9 @@ description: >- Patterns that ensure a consistent user experience, including deprecation, beta features, and detecting drift. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + This best practices section only contains guidance for plugins built with [Terraform Plugin SDK](/terraform/plugin/sdkv2). More generic best practices that apply to both SDK and [Terraform Plugin Framework](/terraform/plugin/framework) can be found in the [Plugin Development Best Practices](/terraform/plugin/best-practices) section. diff --git a/website/docs/plugin/sdkv2/debugging.mdx b/website/docs/plugin/sdkv2/debugging.mdx index 22341e0df43..3e17d31f2f2 100644 --- a/website/docs/plugin/sdkv2/debugging.mdx +++ b/website/docs/plugin/sdkv2/debugging.mdx @@ -3,6 +3,9 @@ page_title: Plugin Development - Debugging SDKv2 Providers description: How to implement debugger support in SDKv2 Terraform providers. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Debugging SDKv2 Providers This page contains implementation details for inspecting runtime information of a Terraform provider developed with SDKv2 via a debugger tool. Review the top level [Debugging](/terraform/plugin/debugging) page for information pertaining to the overall Terraform provider debugging process and other inspection options, such as log-based debugging. diff --git a/website/docs/plugin/sdkv2/guides/terraform-0.12-compatibility.mdx b/website/docs/plugin/sdkv2/guides/terraform-0.12-compatibility.mdx index 442ffdb76ad..709cde63211 100644 --- a/website/docs/plugin/sdkv2/guides/terraform-0.12-compatibility.mdx +++ b/website/docs/plugin/sdkv2/guides/terraform-0.12-compatibility.mdx @@ -5,6 +5,9 @@ description: |- codebases. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Terraform 0.12 Compatibility for Providers Terraform 0.12 introduced a new type system for the Terraform language, and diff --git a/website/docs/plugin/sdkv2/guides/v1-upgrade-guide.mdx b/website/docs/plugin/sdkv2/guides/v1-upgrade-guide.mdx index fc5e8d99a59..6c31944315e 100644 --- a/website/docs/plugin/sdkv2/guides/v1-upgrade-guide.mdx +++ b/website/docs/plugin/sdkv2/guides/v1-upgrade-guide.mdx @@ -3,6 +3,9 @@ page_title: Terraform Plugin SDK description: Official standalone SDK for Terraform plugin development --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Terraform Plugin SDK As of September 2019, Terraform provider developers importing the Go module `github.com/hashicorp/terraform`, known as Terraform Core, should switch to `github.com/hashicorp/terraform-plugin-sdk`, the Terraform Plugin SDK, instead. diff --git a/website/docs/plugin/sdkv2/guides/v2-upgrade-guide.mdx b/website/docs/plugin/sdkv2/guides/v2-upgrade-guide.mdx index f34ad78fa36..9119c4b814a 100644 --- a/website/docs/plugin/sdkv2/guides/v2-upgrade-guide.mdx +++ b/website/docs/plugin/sdkv2/guides/v2-upgrade-guide.mdx @@ -3,6 +3,9 @@ page_title: Terraform Plugin SDK v2 Upgrade Guide description: Upgrade guide for version 2 of the Terraform Plugin SDK. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Terraform Plugin SDK v2 Upgrade Guide Version 2.0.0 of the Terraform Plugin SDK is a major release and includes some diff --git a/website/docs/plugin/sdkv2/index.mdx b/website/docs/plugin/sdkv2/index.mdx index 8947e80b065..fc6d7cdad3d 100644 --- a/website/docs/plugin/sdkv2/index.mdx +++ b/website/docs/plugin/sdkv2/index.mdx @@ -3,6 +3,9 @@ page_title: 'Home - Plugin Development: SDKv2' description: Maintain plugins built on the legacy SDK. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Terraform Plugin SDKv2 Terraform Plugin SDKv2 is a way to maintain Terraform Plugins on [protocol version 5](/terraform/plugin/terraform-plugin-protocol#protocol-version-5). diff --git a/website/docs/plugin/sdkv2/logging/http-transport.mdx b/website/docs/plugin/sdkv2/logging/http-transport.mdx index 4302b6d6405..5800f5e0a51 100644 --- a/website/docs/plugin/sdkv2/logging/http-transport.mdx +++ b/website/docs/plugin/sdkv2/logging/http-transport.mdx @@ -4,6 +4,9 @@ description: |- SDKv2 provides a helper to send all the HTTP transactions to structured logging. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # HTTP Transport Terraform's public interface has included `helper/logging` [`NewTransport()`](https://github.com/hashicorp/terraform-plugin-sdk/blob/main/helper/logging/transport.go) since `v0.9.5`. This helper is an implementation of the Golang standard library [`http.RoundTripper`](https://pkg.go.dev/net/http#RoundTripper) that lets you add logging at the `DEBUG` level to your provider's HTTP transactions. diff --git a/website/docs/plugin/sdkv2/logging/index.mdx b/website/docs/plugin/sdkv2/logging/index.mdx index 9cfc333c6fa..7542619a891 100644 --- a/website/docs/plugin/sdkv2/logging/index.mdx +++ b/website/docs/plugin/sdkv2/logging/index.mdx @@ -4,6 +4,9 @@ description: |- High-quality logs are important when debugging your provider. Learn to set-up logging and write meaningful logs. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Logging Terraform Plugin SDKv2 integrates with the structured logging framework [terraform-plugin-log](/terraform/plugin/log). High-quality logs are critical to quickly [debugging your provider](/terraform/plugin/debugging). diff --git a/website/docs/plugin/sdkv2/resources/customizing-differences.mdx b/website/docs/plugin/sdkv2/resources/customizing-differences.mdx index b04afa21d4d..b8ad606ab7b 100644 --- a/website/docs/plugin/sdkv2/resources/customizing-differences.mdx +++ b/website/docs/plugin/sdkv2/resources/customizing-differences.mdx @@ -3,6 +3,9 @@ page_title: Resources - Customizing Differences description: Difference customization within Resources. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Resources - Customizing Differences Terraform tracks the state of provisioned resources in its state file, and compares the user-passed configuration against that state. When Terraform detects a discrepancy, it presents the user with the differences between the configuration and the state. diff --git a/website/docs/plugin/sdkv2/resources/data-consistency-errors.mdx b/website/docs/plugin/sdkv2/resources/data-consistency-errors.mdx index bb50e1564a0..0197ef84256 100644 --- a/website/docs/plugin/sdkv2/resources/data-consistency-errors.mdx +++ b/website/docs/plugin/sdkv2/resources/data-consistency-errors.mdx @@ -3,6 +3,9 @@ page_title: Resources - Data Consistency Errors description: Fixing data consistency errors caused by this SDK. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Resources - Data Consistency Errors Resources written with `terraform-plugin-sdk` are by default allowed to perform unexpected data handling operations from Terraform's perspective. Terraform versions 0.12 and later have stricter [data consistency rules](https://github.com/hashicorp/terraform/blob/main/docs/resource-instance-change-lifecycle.md) than the design and implementation of this SDK, which predated those rules. diff --git a/website/docs/plugin/sdkv2/resources/import.mdx b/website/docs/plugin/sdkv2/resources/import.mdx index 7b2e1c51755..a56a8872b6c 100644 --- a/website/docs/plugin/sdkv2/resources/import.mdx +++ b/website/docs/plugin/sdkv2/resources/import.mdx @@ -3,6 +3,9 @@ page_title: Resources - Import description: Implementing resource import support. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Resources - Import Adding import support for Terraform resources will allow existing infrastructure to be managed within Terraform. This type of enhancement generally requires a small to moderate amount of code changes. diff --git a/website/docs/plugin/sdkv2/resources/index.mdx b/website/docs/plugin/sdkv2/resources/index.mdx index 127148e9683..e2fd9f02726 100644 --- a/website/docs/plugin/sdkv2/resources/index.mdx +++ b/website/docs/plugin/sdkv2/resources/index.mdx @@ -5,6 +5,9 @@ description: >- resource APIs. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Resources A key component to Terraform Provider development is defining the creation, read, update, and deletion functionality of a resource to map those API operations into the Terraform lifecycle. While the [Call APIs with Terraform Providers tutorial](/terraform/tutorials/providers?utm_source=WEBSITE&utm_medium=WEB_IO&utm_offer=ARTICLE_PAGE&utm_content=DOCS) and [Schemas documentation](/terraform/plugin/sdkv2/schemas) cover the basic aspects of developing Terraform resources, this section covers more advanced features of resource development. diff --git a/website/docs/plugin/sdkv2/resources/retries-and-customizable-timeouts.mdx b/website/docs/plugin/sdkv2/resources/retries-and-customizable-timeouts.mdx index 985ad53b60a..fc7f729b5d6 100644 --- a/website/docs/plugin/sdkv2/resources/retries-and-customizable-timeouts.mdx +++ b/website/docs/plugin/sdkv2/resources/retries-and-customizable-timeouts.mdx @@ -3,6 +3,9 @@ page_title: Resources - Retries and Customizable Timeouts description: Helpers for handling retries within Resources. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Resources - Retries and Customizable Timeouts The reality of cloud infrastructure is that it typically takes time to perform operations such as booting operating systems, discovering services, and replicating state across network edges. As the provider developer you should take known delays in resource APIs into account in the CRUD functions of the resource. Terraform supports configurable timeouts to assist in these situations. diff --git a/website/docs/plugin/sdkv2/resources/state-migration.mdx b/website/docs/plugin/sdkv2/resources/state-migration.mdx index 689fc26e949..fcc7f1c42d0 100644 --- a/website/docs/plugin/sdkv2/resources/state-migration.mdx +++ b/website/docs/plugin/sdkv2/resources/state-migration.mdx @@ -3,6 +3,9 @@ page_title: Resources - State Migration description: Migrating state values within resources. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Resources - State Migration Resources define the data types and API interactions required to create, update, and destroy infrastructure with a cloud vendor while the [Terraform state](/terraform/language/state) stores mapping and metadata information for those remote objects. There are several reasons why a resource implementation needs to change: backend APIs Terraform interacts with will change overtime, or the current implementation might be incorrect or unmaintainable. Some of these changes may not be backward compatible and a migration is needed for resources provisioned in the wild with old schema configurations. diff --git a/website/docs/plugin/sdkv2/resources/write-only-arguments.mdx b/website/docs/plugin/sdkv2/resources/write-only-arguments.mdx index f4a82ad436c..5848c2045b4 100644 --- a/website/docs/plugin/sdkv2/resources/write-only-arguments.mdx +++ b/website/docs/plugin/sdkv2/resources/write-only-arguments.mdx @@ -3,6 +3,9 @@ page_title: Resources - Write-only Arguments description: Implementing write-only arguments within resources. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Resources - Write-only Arguments ~> **NOTE:** Write-only arguments are only supported in Terraform `v1.11` or higher @@ -203,6 +206,13 @@ if !woVal.IsNull() { ## PreferWriteOnlyAttribute Validator + + + This validator will produce persistent warnings for practitioners on every Terraform run as long as the specified non-write-only attribute + has a value in the configuration. The validator will also produce warnings for users of shared modules who cannot immediately take action on the warning. + + + `PreferWriteOnlyAttribute()` is a validator that takes a `cty.Path` to an existing configuration attribute (required/optional) and a `cty.Path` to a write-only argument. Use this validator when you have a write-only version of an existing attribute, and you want to encourage practitioners to use the write-only version whenever possible. diff --git a/website/docs/plugin/sdkv2/schemas/index.mdx b/website/docs/plugin/sdkv2/schemas/index.mdx index 6a00e371b2d..36f92963dc1 100644 --- a/website/docs/plugin/sdkv2/schemas/index.mdx +++ b/website/docs/plugin/sdkv2/schemas/index.mdx @@ -5,6 +5,9 @@ description: |- in SDKv2. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Terraform Schemas Terraform Plugins are expressed using schemas to define attributes and their diff --git a/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx b/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx index 30ac5262d9e..5a215213256 100644 --- a/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx +++ b/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx @@ -5,6 +5,9 @@ description: |- you can use to define element behaviors in SDKv2. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Schema Behaviors Schema fields that can have an effect at plan or apply time are collectively diff --git a/website/docs/plugin/sdkv2/schemas/schema-methods.mdx b/website/docs/plugin/sdkv2/schemas/schema-methods.mdx index 7d56b8bf9df..083bc097473 100644 --- a/website/docs/plugin/sdkv2/schemas/schema-methods.mdx +++ b/website/docs/plugin/sdkv2/schemas/schema-methods.mdx @@ -5,6 +5,9 @@ description: |- to extend Terraform's core offering. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Terraform Schemas Methods _NOTE_ should distinguish between `schema.Provider`, `schema.Resource`, diff --git a/website/docs/plugin/sdkv2/schemas/schema-types.mdx b/website/docs/plugin/sdkv2/schemas/schema-types.mdx index 9003cbd0640..a087c0a61ce 100644 --- a/website/docs/plugin/sdkv2/schemas/schema-types.mdx +++ b/website/docs/plugin/sdkv2/schemas/schema-types.mdx @@ -6,6 +6,9 @@ description: |- element. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Schema Attributes and Types Almost every Terraform Plugin offers user configurable parameters, examples such diff --git a/website/docs/plugin/sdkv2/testing/acceptance-tests/index.mdx b/website/docs/plugin/sdkv2/testing/acceptance-tests/index.mdx index f0d2cecb22d..de5e1ab66dc 100644 --- a/website/docs/plugin/sdkv2/testing/acceptance-tests/index.mdx +++ b/website/docs/plugin/sdkv2/testing/acceptance-tests/index.mdx @@ -5,6 +5,9 @@ description: |- imitate applying one or more configuration files. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Acceptance Tests diff --git a/website/docs/plugin/sdkv2/testing/acceptance-tests/sweepers.mdx b/website/docs/plugin/sdkv2/testing/acceptance-tests/sweepers.mdx index ddcceaf3a30..f17f7593268 100644 --- a/website/docs/plugin/sdkv2/testing/acceptance-tests/sweepers.mdx +++ b/website/docs/plugin/sdkv2/testing/acceptance-tests/sweepers.mdx @@ -5,6 +5,9 @@ description: >- testing framework. Sweepers clean up leftover infrastructure. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Sweepers diff --git a/website/docs/plugin/sdkv2/testing/acceptance-tests/testcase.mdx b/website/docs/plugin/sdkv2/testing/acceptance-tests/testcase.mdx index d44b2852345..629a91068b1 100644 --- a/website/docs/plugin/sdkv2/testing/acceptance-tests/testcase.mdx +++ b/website/docs/plugin/sdkv2/testing/acceptance-tests/testcase.mdx @@ -5,6 +5,9 @@ description: |- creates a set of resources then verifies the new infrastructure. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Acceptance Tests: TestCases diff --git a/website/docs/plugin/sdkv2/testing/acceptance-tests/teststep.mdx b/website/docs/plugin/sdkv2/testing/acceptance-tests/teststep.mdx index 6f696d326e2..6fe69ac5b4a 100644 --- a/website/docs/plugin/sdkv2/testing/acceptance-tests/teststep.mdx +++ b/website/docs/plugin/sdkv2/testing/acceptance-tests/teststep.mdx @@ -5,6 +5,9 @@ description: |- file to a given state. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Acceptance Tests: TestSteps diff --git a/website/docs/plugin/sdkv2/testing/index.mdx b/website/docs/plugin/sdkv2/testing/index.mdx index f92e3d86ae5..4d6e3eb0a5c 100644 --- a/website/docs/plugin/sdkv2/testing/index.mdx +++ b/website/docs/plugin/sdkv2/testing/index.mdx @@ -5,6 +5,9 @@ description: |- plugins. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Testing Terraform Plugins diff --git a/website/docs/plugin/sdkv2/testing/testing-api.mdx b/website/docs/plugin/sdkv2/testing/testing-api.mdx index de0a59bd9f1..f38c5daa8d4 100644 --- a/website/docs/plugin/sdkv2/testing/testing-api.mdx +++ b/website/docs/plugin/sdkv2/testing/testing-api.mdx @@ -5,4 +5,7 @@ description: |- to extend Terraform's core offering. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Testing API diff --git a/website/docs/plugin/sdkv2/testing/testing-patterns.mdx b/website/docs/plugin/sdkv2/testing/testing-patterns.mdx index d68074cca2f..a704e0095a9 100644 --- a/website/docs/plugin/sdkv2/testing/testing-patterns.mdx +++ b/website/docs/plugin/sdkv2/testing/testing-patterns.mdx @@ -5,4 +5,7 @@ description: |- to extend Terraform's core offering. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Testing Patterns diff --git a/website/docs/plugin/sdkv2/testing/unit-testing.mdx b/website/docs/plugin/sdkv2/testing/unit-testing.mdx index ea69c174e44..ce59c93a49b 100644 --- a/website/docs/plugin/sdkv2/testing/unit-testing.mdx +++ b/website/docs/plugin/sdkv2/testing/unit-testing.mdx @@ -5,6 +5,9 @@ description: |- flatten API responses into data structures that Terraform stores as state. --- +> [!IMPORTANT] +> **Documentation Update:** Product documentation previously located in `/website` has moved to the [`hashicorp/web-unified-docs`](https://github.com/hashicorp/web-unified-docs) repository, where all product documentation is now centralized. Please make contributions directly to `web-unified-docs`, since changes to `/website` in this repository will not appear on developer.hashicorp.com. + # Unit Testing