From 1210d6883628f2870bfa2454ee1c3ef213cdbfe2 Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Fri, 14 Mar 2025 11:15:03 +0100 Subject: [PATCH] Output resource identities in State JSON (TF-23753) (#36615) * jsonstate: Marshal identity values * jsonstate: Test identity marshalling * Add identity to prepareStateV4 * Check identity schema version when marshaling state * Marshal identity for deposed resources * Marshal identity version if `0` * Check for missing resource identity schema --- internal/command/jsonstate/state.go | 53 ++++++++++ internal/command/jsonstate/state_test.go | 129 +++++++++++++++++++++++ internal/states/statefile/version4.go | 9 +- 3 files changed, 189 insertions(+), 2 deletions(-) diff --git a/internal/command/jsonstate/state.go b/internal/command/jsonstate/state.go index 4f2694ca18..38f9179bf0 100644 --- a/internal/command/jsonstate/state.go +++ b/internal/command/jsonstate/state.go @@ -110,12 +110,26 @@ type Resource struct { // Deposed is set if the resource is deposed in terraform state. DeposedKey string `json:"deposed_key,omitempty"` + + // The version of the resource identity schema the "identity" property + // conforms to. + // It's a pointer, because it should be optional, but also 0 is a valid + // schema version. + IdentitySchemaVersion *uint64 `json:"identity_schema_version,omitempty"` + + // The JSON representation of the resource identity, whose structure + // depends on the resource identity schema. + IdentityValues IdentityValues `json:"identity,omitempty"` } // AttributeValues is the JSON representation of the attribute values of the // resource, whose structure depends on the resource type schema. type AttributeValues map[string]json.RawMessage +// IdentityValues is the JSON representation of the identity values of the +// resource, whose structure depends on the resource identity schema. +type IdentityValues map[string]json.RawMessage + func marshalAttributeValues(value cty.Value) (unmarkedVal cty.Value, marshalledVals AttributeValues, sensitivePaths []cty.Path, err error) { // unmark our value to show all values value, sensitivePaths, err = unmarkValueForMarshaling(value) @@ -138,6 +152,21 @@ func marshalAttributeValues(value cty.Value) (unmarkedVal cty.Value, marshalledV return value, ret, sensitivePaths, nil } +func marshalIdentityValues(value cty.Value) (IdentityValues, error) { + if value == cty.NilVal || value.IsNull() { + return nil, nil + } + + ret := make(IdentityValues) + it := value.ElementIterator() + for it.Next() { + k, v := it.Element() + vJSON, _ := ctyjson.Marshal(v, v.Type()) + ret[k.AsString()] = json.RawMessage(vJSON) + } + return ret, nil +} + // newState() returns a minimally-initialized state func newState() *state { return &state{ @@ -396,6 +425,20 @@ func marshalResources(resources map[string]*states.Resource, module addrs.Module if schema.Body == nil { return nil, fmt.Errorf("no schema found for %s (in provider %s)", resAddr.String(), r.ProviderConfig.Provider) } + + // Check if we have an identity in the state + if ri.Current.IdentityJSON != nil { + if schema.IdentityVersion != int64(ri.Current.IdentitySchemaVersion) { + return nil, fmt.Errorf("resource identity schema version %d for %s in state does not match version %d from the provider", ri.Current.IdentitySchemaVersion, resAddr, schema.IdentityVersion) + } + + if schema.Identity == nil { + return nil, fmt.Errorf("no resource identity schema found for %s (in provider %s)", resAddr.String(), r.ProviderConfig.Provider) + } + + current.IdentitySchemaVersion = &ri.Current.IdentitySchemaVersion + } + riObj, err := ri.Current.Decode(schema) if err != nil { return nil, err @@ -415,6 +458,11 @@ func marshalResources(resources map[string]*states.Resource, module addrs.Module } current.SensitiveValues = v + current.IdentityValues, err = marshalIdentityValues(riObj.Identity) + if err != nil { + return nil, fmt.Errorf("preparing identity values for %s: %w", current.Address, err) + } + if len(riObj.Dependencies) > 0 { dependencies := make([]string, len(riObj.Dependencies)) for i, v := range riObj.Dependencies { @@ -467,6 +515,11 @@ func marshalResources(resources map[string]*states.Resource, module addrs.Module } deposed.SensitiveValues = v + deposed.IdentityValues, err = marshalIdentityValues(riObj.Identity) + if err != nil { + return nil, fmt.Errorf("preparing identity values for %s: %w", current.Address, err) + } + if len(riObj.Dependencies) > 0 { dependencies := make([]string, len(riObj.Dependencies)) for i, v := range riObj.Dependencies { diff --git a/internal/command/jsonstate/state_test.go b/internal/command/jsonstate/state_test.go index 433c496505..f94ba37fd6 100644 --- a/internal/command/jsonstate/state_test.go +++ b/internal/command/jsonstate/state_test.go @@ -20,6 +20,10 @@ import ( "github.com/hashicorp/terraform/internal/terraform" ) +func ptrOf[T any](v T) *T { + return &v +} + func TestMarshalOutputs(t *testing.T) { tests := []struct { Outputs map[string]*states.OutputValue @@ -620,6 +624,115 @@ func TestMarshalResources(t *testing.T) { }, false, }, + "single resource with identity": { + map[string]*states.Resource{ + "test_identity.bar": { + Addr: addrs.AbsResource{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_identity", + Name: "bar", + }, + }, + Instances: map[addrs.InstanceKey]*states.ResourceInstance{ + addrs.NoKey: { + Current: &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"woozles":"confuzles","foozles":"sensuzles","name":"bar"}`), + IdentityJSON: []byte(`{"foozles":"sensuzles","name":"bar"}`), + }, + }, + }, + ProviderConfig: addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + }, + }, + testSchemas(), + []Resource{ + { + Address: "test_identity.bar", + Mode: "managed", + Type: "test_identity", + Name: "bar", + Index: nil, + ProviderName: "registry.terraform.io/hashicorp/test", + AttributeValues: AttributeValues{ + "name": json.RawMessage(`"bar"`), + "foozles": json.RawMessage(`"sensuzles"`), + "woozles": json.RawMessage(`"confuzles"`), + }, + SensitiveValues: json.RawMessage("{\"foozles\":true}"), + IdentityValues: IdentityValues{ + "name": json.RawMessage(`"bar"`), + "foozles": json.RawMessage(`"sensuzles"`), + }, + IdentitySchemaVersion: ptrOf[uint64](0), + }, + }, + false, + }, + "single resource wrong identity schema": { + map[string]*states.Resource{ + "test_identity.bar": { + Addr: addrs.AbsResource{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_identity", + Name: "bar", + }, + }, + Instances: map[addrs.InstanceKey]*states.ResourceInstance{ + addrs.NoKey: { + Current: &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"woozles":"confuzles","foozles":"sensuzles","name":"bar"}`), + IdentitySchemaVersion: 1, + IdentityJSON: []byte(`{"foozles":"sensuzles","name":"bar"}`), + }, + }, + }, + ProviderConfig: addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + }, + }, + testSchemas(), + nil, + true, + }, + "single resource missing identity schema": { + map[string]*states.Resource{ + "test_thing.bar": { + Addr: addrs.AbsResource{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_thing", + Name: "bar", + }, + }, + Instances: map[addrs.InstanceKey]*states.ResourceInstance{ + addrs.NoKey: { + Current: &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"woozles":"confuzles","foozles":"sensuzles"}`), + IdentitySchemaVersion: 0, + IdentityJSON: []byte(`{"foozles":"sensuzles","name":"bar"}`), + }, + }, + }, + ProviderConfig: addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + }, + }, + testSchemas(), + nil, + true, + }, } for name, test := range tests { @@ -867,6 +980,22 @@ func testSchemas() *terraform.Schemas { }, }, }, + "test_identity": { + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "name": {Type: cty.String, Required: true}, + "woozles": {Type: cty.String, Optional: true, Computed: true}, + "foozles": {Type: cty.String, Optional: true, Sensitive: true}, + }, + }, + Identity: &configschema.Object{ + Attributes: map[string]*configschema.Attribute{ + "name": {Type: cty.String, Required: true}, + "foozles": {Type: cty.String, Optional: true}, + }, + Nesting: configschema.NestingSingle, + }, + }, }, }, }, diff --git a/internal/states/statefile/version4.go b/internal/states/statefile/version4.go index 22b438c19b..aa389cbf41 100644 --- a/internal/states/statefile/version4.go +++ b/internal/states/statefile/version4.go @@ -136,8 +136,9 @@ func prepareStateV4(sV4 *stateV4) (*File, tfdiags.Diagnostics) { instAddr := rAddr.Instance(key) obj := &states.ResourceInstanceObjectSrc{ - SchemaVersion: isV4.SchemaVersion, - CreateBeforeDestroy: isV4.CreateBeforeDestroy, + SchemaVersion: isV4.SchemaVersion, + CreateBeforeDestroy: isV4.CreateBeforeDestroy, + IdentitySchemaVersion: isV4.IdentitySchemaVersion, } { @@ -156,6 +157,10 @@ func prepareStateV4(sV4 *stateV4) (*File, tfdiags.Diagnostics) { } } + if isV4.IdentityRaw != nil { + obj.IdentityJSON = isV4.IdentityRaw + } + // Sensitive paths if isV4.AttributeSensitivePaths != nil { paths, pathsDiags := unmarshalPaths([]byte(isV4.AttributeSensitivePaths))