diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 083d5d9..55568ef 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,9 +11,6 @@ updates: interval: weekly labels: - dependencies - # only update HashiCorp actions, external actions managed by TSCCR - allow: - - dependency-name: hashicorp/* groups: github-actions-breaking: update-types: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 44e56bc..340fec3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,7 +43,7 @@ jobs: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Go - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 with: go-version: ${{ matrix.go }} - name: Go mod download diff --git a/CODEOWNERS b/CODEOWNERS index a99f162..c13d2ca 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,2 +1,2 @@ # This codebase has shared ownership and responsibility. -* @hashicorp/terraform-core @hashicorp/terraform-devex @hashicorp/tf-editor-experience-engineers +* @hashicorp/terraform-core @hashicorp/terraform-core-plugins @hashicorp/tf-editor-experience-engineers diff --git a/catalog-info.yaml b/catalog-info.yaml index 9842854..f64e838 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -13,5 +13,5 @@ metadata: jira/label: terraform-json spec: type: library - owner: terraform-core + owner: team-tf-core lifecycle: production diff --git a/go.mod b/go.mod index a656af2..b8afe3a 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,11 @@ go 1.18 require ( github.com/davecgh/go-spew v1.1.1 - github.com/google/go-cmp v0.6.0 + github.com/google/go-cmp v0.7.0 github.com/hashicorp/go-version v1.7.0 github.com/mitchellh/copystructure v1.2.0 github.com/sebdah/goldie v1.0.0 - github.com/zclconf/go-cty v1.15.1 + github.com/zclconf/go-cty v1.16.2 github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b ) diff --git a/go.sum b/go.sum index 723f2b5..d8b709c 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ 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= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -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/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -27,8 +27,8 @@ github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= -github.com/zclconf/go-cty v1.15.1 h1:RgQYm4j2EvoBRXOPxhUvxPzRrGDo1eCOhHXuGfrj5S0= -github.com/zclconf/go-cty v1.15.1/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +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-20191215020915-b22d67c1ba0b h1:FosyBZYxY34Wul7O/MSKey3txpPYyCqVO5ZyceuQJEI= github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= diff --git a/parse_test.go b/parse_test.go index 79c9a66..725e281 100644 --- a/parse_test.go +++ b/parse_test.go @@ -6,7 +6,6 @@ package tfjson import ( "bytes" "encoding/json" - "io/ioutil" "os" "path/filepath" "reflect" @@ -21,7 +20,7 @@ const testGoldenStateFileName = "state.json" const testGoldenSchemasFileName = "schemas.json" func testParse(t *testing.T, filename string, typ reflect.Type) { - entries, err := ioutil.ReadDir(testFixtureDir) + entries, err := os.ReadDir(testFixtureDir) if err != nil { t.Fatalf("err: %s", err) } @@ -32,7 +31,7 @@ func testParse(t *testing.T, filename string, typ reflect.Type) { } t.Run(e.Name(), func(t *testing.T) { - expected, err := ioutil.ReadFile(filepath.Join(testFixtureDir, e.Name(), filename)) + expected, err := os.ReadFile(filepath.Join(testFixtureDir, e.Name(), filename)) if err != nil { if os.IsNotExist(err) { t.Skip(err.Error()) diff --git a/plan.go b/plan.go index d861898..f60739c 100644 --- a/plan.go +++ b/plan.go @@ -266,6 +266,11 @@ type Change struct { // is either an integer pointing to a child of a set/list, or a string // pointing to the child of a map, object, or block. ReplacePaths []interface{} `json:"replace_paths,omitempty"` + + // BeforeIdentity and AfterIdentity are representations of the resource + // identity value both before and after the action. + BeforeIdentity interface{} `json:"before_identity,omitempty"` + AfterIdentity interface{} `json:"after_identity,omitempty"` } // Importing is a nested object for the resource import metadata. @@ -273,6 +278,16 @@ type Importing struct { // The original ID of this resource used to target it as part of planned // import operation. ID string `json:"id,omitempty"` + + // Unknown indicates the ID or identity was unknown at the time of + // planning. This would have led to the overall change being deferred, as + // such this should only be true when processing changes from the deferred + // changes list. + Unknown bool `json:"unknown,omitempty"` + + // The identity can be used instead of the ID to target the resource as part + // of the planned import operation. + Identity interface{} `json:"identity,omitempty"` } // PlanVariable is a top-level variable in the Terraform plan. diff --git a/plan_test.go b/plan_test.go index 1f59fc1..c97f8f9 100644 --- a/plan_test.go +++ b/plan_test.go @@ -13,19 +13,34 @@ import ( ) func TestPlanValidate(t *testing.T) { - f, err := os.Open("testdata/basic/plan.json") - if err != nil { - t.Fatal(err) + cases := map[string]struct { + planPath string + }{ + "basic plan": { + planPath: "testdata/basic/plan.json", + }, + "plan with identity": { + planPath: "testdata/identity/plan.json", + }, } - defer f.Close() - var plan *Plan - if err := json.NewDecoder(f).Decode(&plan); err != nil { - t.Fatal(err) - } + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + f, err := os.Open(tc.planPath) + if err != nil { + t.Fatal(err) + } + defer f.Close() - if err := plan.Validate(); err != nil { - t.Fatal(err) + var plan *Plan + if err := json.NewDecoder(f).Decode(&plan); err != nil { + t.Fatal(err) + } + + if err := plan.Validate(); err != nil { + t.Fatal(err) + } + }) } } diff --git a/schemas.go b/schemas.go index 13d0d38..3073d9d 100644 --- a/schemas.go +++ b/schemas.go @@ -92,6 +92,9 @@ type ProviderSchema struct { // The definitions for any functions in this provider. Functions map[string]*FunctionSignature `json:"functions,omitempty"` + + // The schemas for resources identities in this provider. + ResourceIdentitySchemas map[string]*IdentitySchema `json:"resource_identity_schemas,omitempty"` } // Schema is the JSON representation of a particular schema @@ -294,3 +297,31 @@ type SchemaNestedAttributeType struct { // of this attribute type (not applicable to single nesting mode). MaxItems uint64 `json:"max_items,omitempty"` } + +// IdentitySchema is the JSON representation of a particular +// resource identity schema +type IdentitySchema struct { + // The version of the particular resource identity schema. + Version uint64 `json:"version"` + + // Map of identity attributes + Attributes map[string]*IdentityAttribute `json:"attributes,omitempty"` +} + +// IdentityAttribute describes an identity attribute +type IdentityAttribute struct { + // The identity attribute type + IdentityType cty.Type `json:"type,omitempty"` + + // The description of the identity attribute + Description string `json:"description,omitempty"` + + // RequiredForImport when enabled signifies that this attribute must be + // specified in the configuration during import + RequiredForImport bool `json:"required_for_import,omitempty"` + + // OptionalForImport when enabled signifies that this attribute is not + // required to be specified during import, because it can be supplied by the + // provider + OptionalForImport bool `json:"optional_for_import,omitempty"` +} diff --git a/schemas_test.go b/schemas_test.go index d659e79..56d45d3 100644 --- a/schemas_test.go +++ b/schemas_test.go @@ -25,6 +25,9 @@ func TestProviderSchemasValidate(t *testing.T) { "a provider schema including a resource with write-only attribute(s) is validated": { testDataPath: "testdata/write_only_attribute_on_resource/schemas.json", }, + "a provider schema including resource identity schemas is validated": { + testDataPath: "testdata/identity/schemas.json", + }, } for tn, tc := range cases { diff --git a/state.go b/state.go index e533632..ff17aef 100644 --- a/state.go +++ b/state.go @@ -173,6 +173,14 @@ type StateResource struct { // DeposedKey is set if the resource instance has been marked Deposed and // will be destroyed on the next apply. DeposedKey string `json:"deposed_key,omitempty"` + + // The version of the resource identity schema the "identity" property + // conforms to. + IdentitySchemaVersion *uint64 `json:"identity_schema_version,omitempty"` + + // The JSON representation of the resource identity, whose structure + // depends on the resource identity schema. + IdentityValues map[string]interface{} `json:"identity,omitempty"` } // StateOutput represents an output value in a common state diff --git a/state_test.go b/state_test.go index dee9021..a817a21 100644 --- a/state_test.go +++ b/state_test.go @@ -5,25 +5,40 @@ package tfjson import ( "encoding/json" - "io/ioutil" + "io" "os" "testing" ) func TestStateValidate_raw(t *testing.T) { - f, err := os.Open("testdata/no_changes/state.json") - if err != nil { - t.Fatal(err) - } - defer f.Close() - - var state State - if err := json.NewDecoder(f).Decode(&state); err != nil { - t.Fatal(err) - } - - if err := state.Validate(); err != nil { - t.Fatal(err) + cases := map[string]struct { + statePath string + }{ + "basic state": { + statePath: "testdata/no_changes/state.json", + }, + "state with identity": { + statePath: "testdata/identity/state.json", + }, + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + f, err := os.Open(tc.statePath) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + var state State + if err := json.NewDecoder(f).Decode(&state); err != nil { + t.Fatal(err) + } + + if err := state.Validate(); err != nil { + t.Fatal(err) + } + }) } } @@ -34,7 +49,7 @@ func TestStateUnmarshal_valid(t *testing.T) { } defer f.Close() - b, err := ioutil.ReadAll(f) + b, err := io.ReadAll(f) if err != nil { t.Fatal(err) } @@ -53,7 +68,7 @@ func TestStateUnmarshal_internalState(t *testing.T) { } defer f.Close() - b, err := ioutil.ReadAll(f) + b, err := io.ReadAll(f) if err != nil { t.Fatal(err) } diff --git a/testdata/identity/plan.json b/testdata/identity/plan.json new file mode 100644 index 0000000..ba7b4b6 --- /dev/null +++ b/testdata/identity/plan.json @@ -0,0 +1 @@ +{"format_version":"1.2","terraform_version":"1.13.0-dev","planned_values":{"root_module":{"resources":[{"address":"corner_user_identity.user","mode":"managed","type":"corner_user_identity","name":"user","provider_name":"registry.terraform.io/hashicorp/corner","schema_version":0,"values":{"age":999,"email":"a@example.com","id":"a@example.com","name":"test"},"sensitive_values":{},"identity_schema_version":1,"identity":{"email":"a@example.com"}}]}},"resource_changes":[{"address":"corner_user_identity.user","mode":"managed","type":"corner_user_identity","name":"user","provider_name":"registry.terraform.io/hashicorp/corner","change":{"actions":["update"],"before":{"age":null,"email":"a@example.com","id":"a@example.com","name":null},"after":{"age":999,"email":"a@example.com","id":"a@example.com","name":"test"},"after_unknown":{},"before_sensitive":{},"after_sensitive":{},"importing":{"identity":{"email":"a@example.com"}},"before_identity":{"email":"a@example.com"},"after_identity":{"email":"a@example.com"}}}],"prior_state":{"format_version":"1.0","terraform_version":"1.13.0","values":{"root_module":{"resources":[{"address":"corner_user_identity.user","mode":"managed","type":"corner_user_identity","name":"user","provider_name":"registry.terraform.io/hashicorp/corner","schema_version":0,"values":{"age":null,"email":"a@example.com","id":"a@example.com","name":null},"sensitive_values":{},"identity_schema_version":1,"identity":{"email":"a@example.com"}}]}}},"configuration":{"provider_config":{"corner":{"name":"corner","full_name":"registry.terraform.io/hashicorp/corner"}},"root_module":{"resources":[{"address":"corner_user_identity.user","mode":"managed","type":"corner_user_identity","name":"user","provider_config_key":"corner","expressions":{"age":{"constant_value":999},"email":{"constant_value":"a@example.com"},"name":{"constant_value":"test"}},"schema_version":0}]}},"timestamp":"2025-04-30T11:34:17Z"} diff --git a/testdata/identity/schemas.json b/testdata/identity/schemas.json new file mode 100644 index 0000000..97e0d64 --- /dev/null +++ b/testdata/identity/schemas.json @@ -0,0 +1 @@ +{"format_version":"1.0","provider_schemas":{"example":{"provider":{"version":0,"block":{"attributes":{"example":{"type":"string","description_kind":"plain","optional":true}},"description_kind":"plain"}},"resource_schemas":{"framework_example":{"version":0,"block":{"attributes":{"id":{"type":"string","description":"Example identifier","description_kind":"markdown","computed":true}},"description":"Example resource","description_kind":"markdown"}}},"data_source_schemas":{"framework_example":{"version":0,"block":{"attributes":{"id":{"type":"string","description":"Example identifier","description_kind":"markdown","computed":true}},"description":"Example data source","description_kind":"markdown"}}},"functions":{"example":{"description":"Echoes given argument as result","summary":"Example function","return_type":"string","parameters":[{"name":"input","description":"String to echo","type":"string"}]}},"resource_identity_schemas":{"framework_example":{"version":0,"attributes":{"number":{"type":"number","description":"A specific number","optional_for_import":true},"string":{"type":"string","required_for_import":true}}}}}}} diff --git a/testdata/identity/state.json b/testdata/identity/state.json new file mode 100644 index 0000000..d6b0739 --- /dev/null +++ b/testdata/identity/state.json @@ -0,0 +1 @@ +{"format_version":"1.0","terraform_version":"1.12.0","values":{"root_module":{"resources":[{"address":"corner_bigint.number","mode":"managed","type":"corner_bigint","name":"number","provider_name":"registry.terraform.io/hashicorp/corner","schema_version":0,"values":{"id":"5","int64":5,"number":5},"sensitive_values":{}},{"address":"corner_user.user","mode":"managed","type":"corner_user","name":"user","provider_name":"registry.terraform.io/hashicorp/corner","schema_version":0,"values":{"age":999,"email":"a@example.com","id":"a@example.com","name":"test"},"sensitive_values":{},"identity_schema_version":0,"identity":{"age":999,"email":"a@example.com","name":"test"}}]}}}