diff --git a/pkg/pf/internal/pfutils/raw_state.go b/pkg/pf/internal/pfutils/raw_state.go deleted file mode 100644 index 07082f535..000000000 --- a/pkg/pf/internal/pfutils/raw_state.go +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2016-2023, Pulumi Corporation. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package pfutils - -import ( - "github.com/hashicorp/terraform-plugin-go/tfprotov6" - "github.com/hashicorp/terraform-plugin-go/tftypes" -) - -func NewRawState(t tftypes.Type, v tftypes.Value) (*tfprotov6.RawState, error) { - json, err := ValueToJSON(t, v) - if err != nil { - return nil, err - } - return &tfprotov6.RawState{JSON: json}, nil -} diff --git a/pkg/pf/internal/schemashim/datasource.go b/pkg/pf/internal/schemashim/datasource.go index dcfe3ffe0..ad4626293 100644 --- a/pkg/pf/internal/schemashim/datasource.go +++ b/pkg/pf/internal/schemashim/datasource.go @@ -15,9 +15,14 @@ package schemashim import ( + "context" + + "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/internal/internalinter" "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/pf/internal/runtypes" shim "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/valueshim" ) type schemaOnlyDataSource struct { @@ -27,6 +32,12 @@ type schemaOnlyDataSource struct { var _ shim.Resource = (*schemaOnlyDataSource)(nil) +func (r *schemaOnlyDataSource) SchemaType() valueshim.Type { + protoSchema, err := r.tf.ResourceProtoSchema(context.Background()) + contract.AssertNoErrorf(err, "ResourceProtoSchema failed") + return valueshim.FromTType(protoSchema.ValueType()) +} + func (r *schemaOnlyDataSource) Schema() shim.SchemaMap { return r.tf.Shim() } diff --git a/pkg/pf/internal/schemashim/object_pseudoresource.go b/pkg/pf/internal/schemashim/object_pseudoresource.go index 40db2a67c..c70ce9e30 100644 --- a/pkg/pf/internal/schemashim/object_pseudoresource.go +++ b/pkg/pf/internal/schemashim/object_pseudoresource.go @@ -28,6 +28,7 @@ import ( "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/internal/internalinter" "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/pf/internal/pfutils" shim "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/valueshim" ) // An Object type that masquerades as a Resource. This is a workaround to reusing tfgen code for generating schemas, @@ -77,6 +78,10 @@ func (r *objectPseudoResource) Schema() shim.SchemaMap { return r } +func (r *objectPseudoResource) SchemaType() valueshim.Type { + return valueshim.FromTType(tftypes.Object{}) +} + func (*objectPseudoResource) SchemaVersion() int { panic("This is an Object type encoded as a shim.Resource, and " + "SchemaVersion() should not be called on this entity during schema generation") @@ -200,6 +205,10 @@ func newTuplePseudoResource(t attr.TypeWithElementTypes) shim.Resource { } } +func (r *tuplePseudoResource) SchemaType() valueshim.Type { + return valueshim.FromTType(tftypes.Object{}) +} + func (*tuplePseudoResource) SchemaVersion() int { panic("TODO") } func (*tuplePseudoResource) DeprecationMessage() string { panic("TODO") } diff --git a/pkg/pf/internal/schemashim/resource.go b/pkg/pf/internal/schemashim/resource.go index 99807bf4b..d69295d7e 100644 --- a/pkg/pf/internal/schemashim/resource.go +++ b/pkg/pf/internal/schemashim/resource.go @@ -15,9 +15,14 @@ package schemashim import ( + "context" + + "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/internal/internalinter" "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/pf/internal/runtypes" shim "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/valueshim" ) type schemaOnlyResource struct { @@ -43,6 +48,12 @@ func (r *schemaOnlyResource) DeprecationMessage() string { return r.tf.DeprecationMessage() } +func (r *schemaOnlyResource) SchemaType() valueshim.Type { + s, err := r.tf.ResourceProtoSchema(context.Background()) + contract.AssertNoErrorf(err, "failed to extract schema") + return valueshim.FromTType(s.ValueType()) +} + func (*schemaOnlyResource) Importer() shim.ImportFunc { panic("schemaOnlyResource does not implement runtime operation ImporterFunc") } diff --git a/pkg/pf/proto/element.go b/pkg/pf/proto/element.go index 9e8d421f7..d1e7a324b 100644 --- a/pkg/pf/proto/element.go +++ b/pkg/pf/proto/element.go @@ -19,6 +19,7 @@ import ( "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/internal/internalinter" shim "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/valueshim" ) var ( @@ -113,6 +114,10 @@ func (o elementObject) Schema() shim.SchemaMap { return elementObjectMap(o.typ) } +func (o elementObject) SchemaType() valueshim.Type { + return valueshim.FromTType(o.typ) +} + func (m elementObjectMap) Len() int { return len(m.AttributeTypes) } func (m elementObjectMap) Get(key string) shim.Schema { return getSchemaMap(m, key) } diff --git a/pkg/pf/proto/object.go b/pkg/pf/proto/object.go index d3dc6132f..74bd9d4db 100644 --- a/pkg/pf/proto/object.go +++ b/pkg/pf/proto/object.go @@ -20,6 +20,7 @@ import ( "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/internal/internalinter" shim "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/valueshim" ) var ( @@ -33,6 +34,11 @@ type object struct { internalinter.Internal } +func (o object) SchemaType() valueshim.Type { + ty := o.obj.ValueType() + return valueshim.FromTType(ty) +} + func (o object) Schema() shim.SchemaMap { contract.Assertf(o.obj.Nesting != tfprotov6.SchemaObjectNestingModeMap, "%T cannot be a map, since that would require `o` to represent a Map type", o) diff --git a/pkg/pf/proto/resource.go b/pkg/pf/proto/resource.go index 9555457b5..cbb309075 100644 --- a/pkg/pf/proto/resource.go +++ b/pkg/pf/proto/resource.go @@ -20,6 +20,7 @@ import ( "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/internal/internalinter" shim "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/valueshim" ) var ( @@ -70,6 +71,11 @@ func newResource(r *tfprotov6.Schema) *resource { return &resource{r, internalinter.Internal{}} } +func (r resource) SchemaType() valueshim.Type { + ty := r.r.Block.ValueType() + return valueshim.FromTType(ty) +} + func (r resource) Schema() shim.SchemaMap { return blockMap{r.r.Block} } diff --git a/pkg/pf/proto/schema.go b/pkg/pf/proto/schema.go index 576f7c607..8e98328cb 100644 --- a/pkg/pf/proto/schema.go +++ b/pkg/pf/proto/schema.go @@ -15,14 +15,19 @@ package proto import ( - // "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" + "github.com/hashicorp/terraform-plugin-go/tftypes" shim "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/valueshim" ) // pseudoResource represents a type that must pretent to be a [shim.Resource], but does not represent a resource. type pseudoResource struct{} +func (pseudoResource) SchemaType() valueshim.Type { + return valueshim.FromTType(tftypes.Object{}) // not a top-level resource +} + func (pseudoResource) SchemaVersion() int { return 0 } func (pseudoResource) Importer() shim.ImportFunc { return nil } func (pseudoResource) Timeouts() *shim.ResourceTimeout { return nil } diff --git a/pkg/pf/tests/diff_test/diff_test.go b/pkg/pf/tests/diff_test/diff_test.go index 9fb5e48d5..157c60550 100644 --- a/pkg/pf/tests/diff_test/diff_test.go +++ b/pkg/pf/tests/diff_test/diff_test.go @@ -3,15 +3,15 @@ package tfbridgetests import ( "context" "math/big" - "os" "testing" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/resource" rschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hexops/autogold/v2" - "github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil" "github.com/zclconf/go-cty/cty" pb "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/pf/internal/providerbuilder" @@ -184,10 +184,6 @@ func TestPFDetailedDiffStringAttribute(t *testing.T) { func TestPFDetailedDiffDynamicType(t *testing.T) { t.Parallel() - if d, ok := os.LookupEnv("PULUMI_RAW_STATE_DELTA_ENABLED"); ok && cmdutil.IsTruthy(d) { - // TODO[pulumi/pulumi-terraform-bridge#3078] - t.Skip("Does not work with PULUMI_RAW_STATE_DELTA_ENABLED=true") - } attributeSchema := rschema.Schema{ Attributes: map[string]rschema.Attribute{ @@ -222,7 +218,7 @@ func TestPFDetailedDiffDynamicType(t *testing.T) { }) t.Run("type change", func(t *testing.T) { - // TODO[pulumi/pulumi-terraform-bridge#3078] + // TODO[pulumi/pulumi-terraform-bridge#3122] t.Skip(`Error converting tftypes.Number<"1"> (value2) at "AttributeName(\"key\")": can't unmarshal tftypes.Number into *string, expected string`) crosstests.Diff(t, res, map[string]cty.Value{"key": cty.StringVal("value")}, @@ -231,10 +227,56 @@ func TestPFDetailedDiffDynamicType(t *testing.T) { }) } +func TestPFDetailedDiffNestedDynamicType(t *testing.T) { + t.Parallel() + + attributeSchema := rschema.Schema{ + Attributes: map[string]rschema.Attribute{ + "key": rschema.ObjectAttribute{ + Optional: true, + AttributeTypes: map[string]attr.Type{ + "nested": types.DynamicType, + }, + }, + }, + } + res := pb.NewResource(pb.NewResourceArgs{ + ResourceSchema: attributeSchema, + }) + + t.Run("no change", func(t *testing.T) { + crosstests.Diff(t, res, + map[string]cty.Value{"key": cty.ObjectVal(map[string]cty.Value{"nested": cty.StringVal("value")})}, + map[string]cty.Value{"key": cty.ObjectVal(map[string]cty.Value{"nested": cty.StringVal("value")})}, + ) + }) + + t.Run("change", func(t *testing.T) { + crosstests.Diff(t, res, + map[string]cty.Value{"key": cty.ObjectVal(map[string]cty.Value{"nested": cty.StringVal("value")})}, + map[string]cty.Value{"key": cty.ObjectVal(map[string]cty.Value{"nested": cty.StringVal("value1")})}, + ) + }) + + t.Run("int no change", func(t *testing.T) { + crosstests.Diff(t, res, + map[string]cty.Value{"key": cty.ObjectVal(map[string]cty.Value{"nested": cty.NumberVal(big.NewFloat(1))})}, + map[string]cty.Value{"key": cty.ObjectVal(map[string]cty.Value{"nested": cty.NumberVal(big.NewFloat(1))})}, + ) + }) + + t.Run("type change", func(t *testing.T) { + // TODO[pulumi/pulumi-terraform-bridge#3122] + t.Skip(`Error converting tftypes.Number<"1"> (value2) at "AttributeName(\"key\")": can't unmarshal tftypes.Number into *string, expected string`) + crosstests.Diff(t, res, + map[string]cty.Value{"key": cty.ObjectVal(map[string]cty.Value{"nested": cty.StringVal("value")})}, + map[string]cty.Value{"key": cty.ObjectVal(map[string]cty.Value{"nested": cty.NumberVal(big.NewFloat(1))})}, + ) + }) +} + func TestPFDetailedDiffDynamicTypeWithMigration(t *testing.T) { t.Parallel() - // TODO[pulumi/pulumi-terraform-bridge#3078] - t.Skip("DynamicPseudoType is not supported") attributeSchema := rschema.Schema{ Attributes: map[string]rschema.Attribute{ diff --git a/pkg/pf/tfbridge/provider.go b/pkg/pf/tfbridge/provider.go index f43c5d869..6ae562ff6 100644 --- a/pkg/pf/tfbridge/provider.go +++ b/pkg/pf/tfbridge/provider.go @@ -339,7 +339,8 @@ func (p *provider) returnTerraformConfig() (resource.PropertyMap, error) { } // use valueshim package to marshal tfConfigValue into raw json, // which can be unmarshaled into a map[string]interface{} - configJSONMessage, err := valueshim.FromTValue(tfConfigValue).Marshal() + value := valueshim.FromTValue(tfConfigValue) + configJSONMessage, err := value.Marshal(value.Type()) if err != nil { return nil, fmt.Errorf("error marshaling into raw JSON message: %v", err) } diff --git a/pkg/pf/tfbridge/resource_state.go b/pkg/pf/tfbridge/resource_state.go index 4fb1219fd..3cb45fedb 100644 --- a/pkg/pf/tfbridge/resource_state.go +++ b/pkg/pf/tfbridge/resource_state.go @@ -29,7 +29,6 @@ import ( "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/convert" - "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/pf/internal/pfutils" "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/reservedkeys" "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfbridge" "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/valueshim" @@ -185,7 +184,8 @@ func insertRawStateDelta(ctx context.Context, rh *resourceHandle, pm resource.Pr schemaInfos := rh.pulumiResourceInfo.GetFields() v := valueshim.FromTValue(state) - delta, err := tfbridge.RawStateComputeDelta(ctx, rh.schema.Shim(), schemaInfos, pm, v) + st := valueshim.FromTType(rh.schema.Type(ctx)) + delta, err := tfbridge.RawStateComputeDelta(ctx, rh.schema.Shim(), schemaInfos, pm, st, v) if err != nil { return err } @@ -268,10 +268,11 @@ func (p *provider) parseAndUpgradeResourceState( } tfType := rh.schema.Type(ctx).(tftypes.Object) - rawState, err := pfutils.NewRawState(tfType, value) + rawStateBytes, err := valueshim.FromTValue(value).Marshal(valueshim.FromTType(tfType)) if err != nil { return nil, fmt.Errorf("[pf/tfbridge] Error calling NewRawState: %w", err) } + rawState := &tfprotov6.RawState{JSON: []byte(rawStateBytes)} return p.upgradeResourceState(ctx, rh, rawState, parsedMeta.PrivateState, stateVersion) } diff --git a/pkg/tests/schema_pulumi_test.go b/pkg/tests/schema_pulumi_test.go index 4e10512da..9d25f7106 100644 --- a/pkg/tests/schema_pulumi_test.go +++ b/pkg/tests/schema_pulumi_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -1016,3 +1017,49 @@ Resources: pt.Up(t) }) } + +func TestTimeoutsHandling(t *testing.T) { + t.Parallel() + + res := &schema.Resource{ + Schema: map[string]*schema.Schema{ + "test": { + Type: schema.TypeString, + Optional: true, + }, + }, + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(time.Second * 10), + Update: schema.DefaultTimeout(time.Second * 10), + }, + } + + tfp := &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "prov_test": res, + }, + } + + bridgedProvider := pulcheck.BridgedProvider(t, "prov", tfp) + + pt := pulcheck.PulCheck(t, bridgedProvider, ` + name: test + runtime: yaml + resources: + mainRes: + type: prov:index:Test + properties: + test: hello`) + // we just check that no errors occur. + pt.Up(t) + + pt.WritePulumiYaml(t, ` + name: test + runtime: yaml + resources: + mainRes: + type: prov:index:Test + properties: + test: hello1`) + pt.Up(t) +} diff --git a/pkg/tfbridge/provider.go b/pkg/tfbridge/provider.go index e778c2d01..44af91262 100644 --- a/pkg/tfbridge/provider.go +++ b/pkg/tfbridge/provider.go @@ -1371,7 +1371,8 @@ func (p *Provider) Create(ctx context.Context, req *pulumirpc.CreateRequest) (*p } if p.info.RawStateDeltaEnabled() { - if err := RawStateInjectDelta(ctx, res.TF.Schema(), res.Schema.Fields, props, newstate); err != nil { + s := res.TF.SchemaType() + if err := RawStateInjectDelta(ctx, res.TF.Schema(), res.Schema.Fields, props, s, newstate); err != nil { return nil, err } } @@ -1521,7 +1522,8 @@ func (p *Provider) Read(ctx context.Context, req *pulumirpc.ReadRequest) (*pulum } if p.info.RawStateDeltaEnabled() { - err := RawStateInjectDelta(ctx, res.TF.Schema(), res.Schema.Fields, props, newstate) + s := res.TF.SchemaType() + err := RawStateInjectDelta(ctx, res.TF.Schema(), res.Schema.Fields, props, s, newstate) if err != nil { return nil, err } @@ -1750,7 +1752,8 @@ func (p *Provider) Update(ctx context.Context, req *pulumirpc.UpdateRequest) (*p } if p.info.RawStateDeltaEnabled() { - if err := RawStateInjectDelta(ctx, res.TF.Schema(), res.Schema.Fields, props, newstate); err != nil { + s := res.TF.SchemaType() + if err := RawStateInjectDelta(ctx, res.TF.Schema(), res.Schema.Fields, props, s, newstate); err != nil { return nil, err } } diff --git a/pkg/tfbridge/rawstate.go b/pkg/tfbridge/rawstate.go index 45d22ea90..6797ee7a3 100644 --- a/pkg/tfbridge/rawstate.go +++ b/pkg/tfbridge/rawstate.go @@ -454,6 +454,7 @@ func RawStateInjectDelta( schemaMap shim.SchemaMap, // top-level schema for a resource schemaInfos map[string]*SchemaInfo, // top-level schema overrides for a resource outMap resource.PropertyMap, + schemaType valueshim.Type, instanceState shim.InstanceState, ) error { // If called in a pulumi preview e.g. Create(preview=true) or in a continue-on-error scenario, bail because the @@ -467,7 +468,7 @@ func RawStateInjectDelta( return nil } v := instanceStateCty.Value() - d, err := RawStateComputeDelta(ctx, schemaMap, schemaInfos, outMap, v) + d, err := RawStateComputeDelta(ctx, schemaMap, schemaInfos, outMap, schemaType, v) if err != nil { return err } @@ -480,18 +481,20 @@ func RawStateComputeDelta( schemaMap shim.SchemaMap, // top-level schema for a resource schemaInfos map[string]*SchemaInfo, // top-level schema overrides for a resource outMap resource.PropertyMap, + schemaType valueshim.Type, v valueshim.Value, ) (RawStateDelta, error) { ih := &rawStateDeltaHelper{ schemaMap: schemaMap, schemaInfos: schemaInfos, logger: log.TryGetLogger(ctx), + schemaType: schemaType, } pv := resource.NewObjectProperty(outMap) + delta := ih.delta(pv, v) vWithoutTimeouts := v.Remove("timeouts") - delta := ih.delta(pv, vWithoutTimeouts) - err := delta.turnaroundCheck(ctx, newRawStateFromValue(vWithoutTimeouts), pv) + err := delta.turnaroundCheck(ctx, newRawStateFromValue(schemaType, vWithoutTimeouts), pv) if err != nil { return RawStateDelta{}, err } @@ -571,6 +574,7 @@ type rawStateDeltaHelper struct { schemaMap shim.SchemaMap // top-level schema for a resource schemaInfos map[string]*SchemaInfo // top-level schema overrides for a resource logger log.Logger + schemaType valueshim.Type } func (ih *rawStateDeltaHelper) delta(pv resource.PropertyValue, v valueshim.Value) RawStateDelta { @@ -603,7 +607,14 @@ func (ih *rawStateDeltaHelper) replaceDeltaAt( err, )) } - return RawStateDelta{Replace: &replaceDelta{Raw: newRawStateFromValue(v)}} + + relevantSchemaType, err := walk.LookupType(path, ih.schemaType) + if err != nil { + // If lookup failed, ignore the schema type; will only affect DynamicPseudoType. + relevantSchemaType = v.Type() + } + + return RawStateDelta{Replace: &replaceDelta{Raw: newRawStateFromValue(relevantSchemaType, v)}} } // Errors returned from this inner function are simply missed opportunities for optimization, as [deltaAt] will always @@ -632,6 +643,14 @@ func (ih *rawStateDeltaHelper) computeDeltaAt( } } + schType, err := walk.LookupType(path, ih.schemaType) + if err == nil { + contract.Assertf(schType != nil, "schType is nil") + if schType.IsDynamicType() { + return RawStateDelta{Replace: &replaceDelta{Raw: newRawStateFromValue(schType, v)}}, nil + } + } + // For assets and archives, save their AssetTranslation, so that at read time this AssetTranslation can be // invoked to TranslateAsset or TranslateArchive. if pv.IsAsset() || pv.IsArchive() { @@ -809,9 +828,15 @@ func (ih *rawStateDeltaHelper) computeDeltaAt( if subPV, isIntersectingKey := pvElements[key]; isIntersectingKey { delta = ih.deltaAt(subPath, subPV, v) } else { - // Missing matching PropertyValue for key, generate a replace delta. - n := resource.NewNullProperty() - delta = ih.replaceDeltaAt(subPath, n, v, fmt.Errorf("No PropertyValue at key")) + if len(path) == 0 && key == "timeouts" { + // Timeouts are a special property that accidentally gets pushed here for historical reasons; it is not + // relevant for the permanent RawState storage. Ignore it for now. + delta = RawStateDelta{} + } else { + // Missing matching PropertyValue for key, generate a replace delta. + n := resource.NewNullProperty() + delta = ih.replaceDeltaAt(subPath, n, v, fmt.Errorf("No PropertyValue at key")) + } } oDelta.set(k, key, delta) handledKeys[key] = struct{}{} @@ -834,8 +859,8 @@ func (ih *rawStateDeltaHelper) computeDeltaAt( } } -func newRawStateFromValue(v valueshim.Value) rawstate.RawState { - raw, err := v.Marshal() +func newRawStateFromValue(schemaType valueshim.Type, v valueshim.Value) rawstate.RawState { + raw, err := v.Marshal(schemaType) contract.AssertNoErrorf(err, "v.Marshal() failed") return rawstate.RawState(raw) } diff --git a/pkg/tfbridge/rawstate_test.go b/pkg/tfbridge/rawstate_test.go index d0f2f6d8d..33d4cce9b 100644 --- a/pkg/tfbridge/rawstate_test.go +++ b/pkg/tfbridge/rawstate_test.go @@ -416,6 +416,7 @@ func Test_rawstate_delta_turnaround(t *testing.T) { "prop": {Name: "prop"}, } } + ih.schemaType = valueshim.FromHCtyType(cv.Type()) t.Logf("pv: %v", pv.String()) t.Logf("cv: %v", cv.GoString()) @@ -1154,9 +1155,11 @@ func Test_rawstate_against_MakeTerraformOutputs(t *testing.T) { outMap := MakeTerraformOutputs(ctx, p, stateObj, tfs, tc.ps, assets, supportsSecrets) + schemaType := p.ResourcesMap().Get(tok).SchemaType() ih := &rawStateDeltaHelper{ schemaMap: tfs, schemaInfos: tc.ps, + schemaType: schemaType, } pv := resource.NewObjectProperty(outMap) @@ -1167,8 +1170,7 @@ func Test_rawstate_against_MakeTerraformOutputs(t *testing.T) { deltaJSON, err := json.MarshalIndent(deltaPV.Mappable(), "", " ") require.NoError(t, err) tc.infl.Equal(t, string(deltaJSON)) - - err = delta.turnaroundCheck(ctx, newRawStateFromValue(stateValue), pv) + err = delta.turnaroundCheck(ctx, newRawStateFromValue(schemaType, stateValue), pv) assert.NoError(t, err) }) } diff --git a/pkg/tfshim/schema/resource.go b/pkg/tfshim/schema/resource.go index 8edb3d5be..e5566305f 100644 --- a/pkg/tfshim/schema/resource.go +++ b/pkg/tfshim/schema/resource.go @@ -5,6 +5,7 @@ import ( "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/internal/internalinter" shim "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/valueshim" ) var _ = shim.ResourceMap(ResourceMap{}) @@ -15,6 +16,7 @@ type Resource struct { Importer shim.ImportFunc DeprecationMessage string Timeouts *shim.ResourceTimeout + SchemaType valueshim.Type } func (r *Resource) Shim() shim.Resource { @@ -42,6 +44,10 @@ func (r ResourceShim) DeprecationMessage() string { return r.V.DeprecationMessage } +func (r ResourceShim) SchemaType() valueshim.Type { + return r.V.SchemaType +} + func (r ResourceShim) Timeouts() *shim.ResourceTimeout { return r.V.Timeouts } diff --git a/pkg/tfshim/sdk-v1/resource.go b/pkg/tfshim/sdk-v1/resource.go index caa754269..60c7f74f1 100644 --- a/pkg/tfshim/sdk-v1/resource.go +++ b/pkg/tfshim/sdk-v1/resource.go @@ -8,6 +8,7 @@ import ( "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/internal/internalinter" shim "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/valueshim" ) var ( @@ -32,6 +33,10 @@ func (r v1Resource) SchemaVersion() int { return r.tf.SchemaVersion } +func (r v1Resource) SchemaType() valueshim.Type { + return valueshim.FromCtyType(r.tf.CoreConfigSchema().ImpliedType()) +} + func (r v1Resource) Importer() shim.ImportFunc { if r.tf.Importer == nil { return nil diff --git a/pkg/tfshim/sdk-v2/provider2.go b/pkg/tfshim/sdk-v2/provider2.go index b01230dd7..1abed38e5 100644 --- a/pkg/tfshim/sdk-v2/provider2.go +++ b/pkg/tfshim/sdk-v2/provider2.go @@ -75,6 +75,10 @@ func (r *v2Resource2) DecodeTimeouts(config shim.ResourceConfig) (*shim.Resource return v2Resource{tf: r.tf}.DecodeTimeouts(config) } +func (r *v2Resource2) SchemaType() valueshim.Type { + return valueshim.FromHctyResourceType(r.tf.CoreConfigSchema().ImpliedType()) +} + type v2InstanceState2 struct { resourceType string stateValue cty.Value diff --git a/pkg/tfshim/sdk-v2/resource.go b/pkg/tfshim/sdk-v2/resource.go index 606b24636..9bb8548fa 100644 --- a/pkg/tfshim/sdk-v2/resource.go +++ b/pkg/tfshim/sdk-v2/resource.go @@ -9,6 +9,7 @@ import ( "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/internal/internalinter" shim "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/valueshim" ) var ( @@ -33,6 +34,10 @@ func (r v2Resource) SchemaVersion() int { return r.tf.SchemaVersion } +func (r v2Resource) SchemaType() valueshim.Type { + return valueshim.FromHctyResourceType(r.tf.CoreConfigSchema().ImpliedType()) +} + func (r v2Resource) Importer() shim.ImportFunc { // When v2Resource represents resources, it is wrapped in v2Resource2 and v2Resource2.Importer() is called. // The residual use case is v2Resource representing data sources, but those do not support importers. diff --git a/pkg/tfshim/sdk-v2/resource_config.go b/pkg/tfshim/sdk-v2/resource_config.go index 68b09ec8c..6cdae4201 100644 --- a/pkg/tfshim/sdk-v2/resource_config.go +++ b/pkg/tfshim/sdk-v2/resource_config.go @@ -42,7 +42,8 @@ func (c v2ResourceConfig) GetRawConfigMap() (map[string]any, error) { msg := "ConfigMap contains unknowns" return nil, fmt.Errorf("%s", msg) } - configJSONMessage, err := valueshim.FromHCtyValue(ctyValue).Marshal() + value := valueshim.FromHCtyValue(ctyValue) + configJSONMessage, err := value.Marshal(value.Type()) if err != nil { return nil, fmt.Errorf("error marshaling into raw JSON message: %v", err) } diff --git a/pkg/tfshim/shim.go b/pkg/tfshim/shim.go index 3bba20a91..ab6b56a80 100644 --- a/pkg/tfshim/shim.go +++ b/pkg/tfshim/shim.go @@ -263,6 +263,7 @@ type Resource interface { DecodeTimeouts(config ResourceConfig) (*ResourceTimeout, error) + SchemaType() valueshim.Type // This is a no-op internal interface to prevent external users from implementing the interface. internalinter.InternalInterface } diff --git a/pkg/tfshim/walk/walk.go b/pkg/tfshim/walk/walk.go index c0388874f..eb7a44f69 100644 --- a/pkg/tfshim/walk/walk.go +++ b/pkg/tfshim/walk/walk.go @@ -27,6 +27,7 @@ import ( shim "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim" "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim/schema" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/valueshim" ) // Represents locations in a tfshim.Schema value as a sequence of steps to locate it. @@ -174,6 +175,32 @@ func LookupSchemaMapPath(path SchemaPath, schemaMap shim.SchemaMap) (shim.Schema return LookupSchemaPath(path, wrapSchemaMap(schemaMap)) } +// Finds a nested Type at a given path. +func LookupType(path SchemaPath, ty valueshim.Type) (valueshim.Type, error) { + current := ty + for i, step := range path { + subPath := path[0:i] + switch step := step.(type) { + case GetAttrStep: + attr, ok := current.AttributeType(step.Name) + if !ok { + return nil, fmt.Errorf("LookupType mismatch: no attribute %q at path %v", + step.Name, subPath) + } + current = attr + case ElementStep: + el, ok := current.ElementType() + if !ok { + return nil, fmt.Errorf("LookupType mismatch: no element type at path %v", subPath) + } + current = el + default: + contract.Failf("unexpected SchemaPathStep case") + } + } + return current, nil +} + // Represents elements of a SchemaPath. // // This interface is closed, the only the implementations given in the current package are allowed. diff --git a/pkg/tfshim/walk/walk_test.go b/pkg/tfshim/walk/walk_test.go index e618e9b7b..f22086d1b 100644 --- a/pkg/tfshim/walk/walk_test.go +++ b/pkg/tfshim/walk/walk_test.go @@ -18,11 +18,13 @@ import ( "fmt" "testing" + "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim" "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim/schema" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/valueshim" ) var strSchema = (&schema.Schema{ @@ -168,3 +170,104 @@ func TestEncodeDecodeSchemaPath(t *testing.T) { }) } } + +func TestLookupType(t *testing.T) { + t.Parallel() + + type testCase struct { + p SchemaPath + toplevelType valueshim.Type + expectedType valueshim.Type + isErr bool + } + + testCases := []testCase{ + { + p: NewSchemaPath(), + toplevelType: valueshim.FromTType(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "x": tftypes.String, + }, + }), + expectedType: valueshim.FromTType(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "x": tftypes.String, + }, + }), + }, + { + p: NewSchemaPath().GetAttr("x"), + toplevelType: valueshim.FromTType(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "x": tftypes.String, + }, + }), + expectedType: valueshim.FromTType(tftypes.String), + }, + { + p: NewSchemaPath().GetAttr("y"), + toplevelType: valueshim.FromTType(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "x": tftypes.String, + }, + }), + isErr: true, + }, + { + p: NewSchemaPath().GetAttr("x").GetAttr("y"), + toplevelType: valueshim.FromTType(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "x": tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "y": tftypes.String, + }, + }, + }, + }), + expectedType: valueshim.FromTType(tftypes.String), + }, + { + p: NewSchemaPath().Element(), + toplevelType: valueshim.FromTType(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "x": tftypes.String, + }, + }), + isErr: true, + }, + { + p: NewSchemaPath().Element(), + toplevelType: valueshim.FromTType(tftypes.Map{ + ElementType: tftypes.String, + }), + expectedType: valueshim.FromTType(tftypes.String), + }, + { + p: NewSchemaPath().Element(), + toplevelType: valueshim.FromTType(tftypes.Set{ + ElementType: tftypes.String, + }), + expectedType: valueshim.FromTType(tftypes.String), + }, + { + p: NewSchemaPath().Element(), + toplevelType: valueshim.FromTType(tftypes.List{ + ElementType: tftypes.String, + }), + expectedType: valueshim.FromTType(tftypes.String), + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + actualType, err := LookupType(tc.p, tc.toplevelType) + if tc.isErr { + require.NotNil(t, err) + t.Logf("ERROR: %v", err) + } else { + require.NoError(t, err) + require.Equal(t, tc.expectedType, actualType) + } + }) + } +} diff --git a/pkg/valueshim/cty.go b/pkg/valueshim/cty.go index 30992664c..e6ac47b01 100644 --- a/pkg/valueshim/cty.go +++ b/pkg/valueshim/cty.go @@ -16,19 +16,20 @@ package valueshim import ( "encoding/json" + "fmt" "math/big" - "github.com/hashicorp/go-cty/cty" - ctyjson "github.com/hashicorp/go-cty/cty/json" + "github.com/zclconf/go-cty/cty" + ctyjson "github.com/zclconf/go-cty/cty/json" ) // Wrap a cty.Value as Value. -func FromHCtyValue(v cty.Value) Value { +func FromCtyValue(v cty.Value) Value { return ctyValueShim(v) } // Wrap a cty.Type as Type. -func FromHCtyType(v cty.Type) Type { +func FromCtyType(v cty.Type) Type { return ctyTypeShim(v) } @@ -49,7 +50,7 @@ func (v ctyValueShim) GoString() string { } func (v ctyValueShim) Type() Type { - return FromHCtyType(v.val().Type()) + return FromCtyType(v.val().Type()) } func (v ctyValueShim) StringValue() string { @@ -103,18 +104,24 @@ func (v ctyValueShim) Remove(key string) Value { } } -func (v ctyValueShim) Marshal() (json.RawMessage, error) { +func (v ctyValueShim) Marshal(schemaType Type) (json.RawMessage, error) { vv := v.val() - raw, err := ctyjson.Marshal(vv, vv.Type()) + tt, ok := schemaType.(ctyTypeShim) + if !ok { + return nil, fmt.Errorf("Cannot marshal to RawState: "+ + "expected schemaType to be of type ctyTypeShim, got %#T", + schemaType) + } + raw, err := ctyjson.Marshal(vv, tt.ty()) if err != nil { - return nil, err + return nil, fmt.Errorf("Cannot marshal to RawState: %w", err) } return json.RawMessage(raw), nil } type ctyTypeShim cty.Type -var _ Type = (*ctyTypeShim)(nil) +var _ Type = ctyTypeShim{} func (t ctyTypeShim) ty() cty.Type { return cty.Type(t) @@ -148,6 +155,29 @@ func (t ctyTypeShim) IsObjectType() bool { return t.ty().IsObjectType() } +func (t ctyTypeShim) IsDynamicType() bool { + return t.ty().Equals(cty.DynamicPseudoType) +} + +func (t ctyTypeShim) AttributeType(name string) (Type, bool) { + tt := t.ty() + if !tt.IsObjectType() { + return nil, false + } + if !tt.HasAttribute(name) { + return nil, false + } + return FromCtyType(tt.AttributeType(name)), true +} + +func (t ctyTypeShim) ElementType() (Type, bool) { + tt := t.ty() + if !tt.IsCollectionType() { + return nil, false + } + return FromCtyType(tt.ElementType()), true +} + func (t ctyTypeShim) GoString() string { return t.ty().GoString() } diff --git a/pkg/valueshim/cty_test.go b/pkg/valueshim/cty_test.go index 7db8af729..8184ca86b 100644 --- a/pkg/valueshim/cty_test.go +++ b/pkg/valueshim/cty_test.go @@ -109,8 +109,10 @@ func Test_HCtyValue_Marshal(t *testing.T) { tupType := cty.Tuple([]cty.Type{cty.String, cty.Number}) type testCase struct { - v cty.Value - expect autogold.Value + v cty.Value + expect autogold.Value + schemaType cty.Type + hasSchemaType bool } testCases := []testCase{ @@ -210,10 +212,51 @@ func Test_HCtyValue_Marshal(t *testing.T) { v: cty.TupleVal([]cty.Value{ok, n42}), expect: autogold.Expect(`["OK",42]`), }, + { + v: cty.NullVal(cty.String), + schemaType: cty.DynamicPseudoType, + hasSchemaType: true, + expect: autogold.Expect(`{"value":null,"type":"string"}`), + }, + { + v: cty.StringVal("foo"), + schemaType: cty.DynamicPseudoType, + hasSchemaType: true, + expect: autogold.Expect(`{"value":"foo","type":"string"}`), + }, + { + v: cty.NumberIntVal(42), + schemaType: cty.DynamicPseudoType, + hasSchemaType: true, + expect: autogold.Expect(`{"value":42,"type":"number"}`), + }, + { + v: cty.BoolVal(true), + schemaType: cty.DynamicPseudoType, + hasSchemaType: true, + expect: autogold.Expect(`{"value":true,"type":"bool"}`), + }, + { + v: cty.ListVal([]cty.Value{cty.StringVal("A")}), + schemaType: cty.DynamicPseudoType, + hasSchemaType: true, + expect: autogold.Expect(`{"value":["A"],"type":["list","string"]}`), + }, + { + v: cty.MapVal(map[string]cty.Value{"x": ok, "y": ok2}), + schemaType: cty.DynamicPseudoType, + hasSchemaType: true, + expect: autogold.Expect(`{"value":{"x":"OK","y":"OK2"},"type":["map","string"]}`), + }, } for _, tc := range testCases { - raw, err := valueshim.FromHCtyValue(tc.v).Marshal() + ty := tc.schemaType + if !tc.hasSchemaType { + ty = tc.v.Type() + } + vv := valueshim.FromHCtyValue(tc.v) + raw, err := vv.Marshal(valueshim.FromHCtyType(ty)) require.NoError(t, err) tc.expect.Equal(t, string(raw)) } @@ -271,3 +314,33 @@ func Test_HCty_ToX(t *testing.T) { assert.Equal(t, 42.41, valueshim.FromHCtyValue(cty.NumberFloatVal(42.41)).NumberValue()) assert.Equal(t, true, valueshim.FromHCtyValue(cty.BoolVal(true)).BoolValue()) } + +func Test_HCtyType_AttributeType(t *testing.T) { + t.Parallel() + objTy := cty.Object(map[string]cty.Type{"x": cty.String}) + + ty, ok := valueshim.FromHCtyType(objTy).AttributeType("x") + assert.True(t, ok) + assert.Equal(t, valueshim.FromHCtyType(cty.String), ty) + + _, ok = valueshim.FromHCtyType(objTy).AttributeType("y") + assert.False(t, ok) +} + +func Test_HCtyType_ElementType(t *testing.T) { + t.Parallel() + ty, ok := valueshim.FromHCtyType(cty.Set(cty.Number)).ElementType() + assert.True(t, ok) + assert.Equal(t, valueshim.FromHCtyType(cty.Number), ty) + + ty, ok = valueshim.FromHCtyType(cty.List(cty.Number)).ElementType() + assert.True(t, ok) + assert.Equal(t, valueshim.FromHCtyType(cty.Number), ty) + + ty, ok = valueshim.FromHCtyType(cty.Map(cty.Number)).ElementType() + assert.True(t, ok) + assert.Equal(t, valueshim.FromHCtyType(cty.Number), ty) + + _, ok = valueshim.FromHCtyType(cty.String).ElementType() + assert.False(t, ok) +} diff --git a/pkg/valueshim/hcty.go b/pkg/valueshim/hcty.go new file mode 100644 index 000000000..d2b27ce9a --- /dev/null +++ b/pkg/valueshim/hcty.go @@ -0,0 +1,195 @@ +// Copyright 2016-2025, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package valueshim + +import ( + "encoding/json" + "fmt" + "math/big" + + "github.com/hashicorp/go-cty/cty" + ctyjson "github.com/hashicorp/go-cty/cty/json" +) + +// Wrap a cty.Value as Value. +func FromHCtyValue(v cty.Value) Value { + return hctyValueShim(v) +} + +func FromHctyResourceType(v cty.Type) Type { + if v.IsObjectType() { + // remove the timeouts attribute + attrsCopy := make(map[string]cty.Type, len(v.AttributeTypes())) + for k, v := range v.AttributeTypes() { + attrsCopy[k] = v + } + delete(attrsCopy, "timeouts") + v = cty.Object(attrsCopy) + } + return hctyTypeShim(v) +} + +// Wrap a cty.Type as Type. +func FromHCtyType(v cty.Type) Type { + return hctyTypeShim(v) +} + +type hctyValueShim cty.Value + +var _ Value = (*hctyValueShim)(nil) + +func (v hctyValueShim) val() cty.Value { + return cty.Value(v) +} + +func (v hctyValueShim) IsNull() bool { + return v.val().IsNull() +} + +func (v hctyValueShim) GoString() string { + return v.val().GoString() +} + +func (v hctyValueShim) Type() Type { + return FromHCtyType(v.val().Type()) +} + +func (v hctyValueShim) StringValue() string { + return v.val().AsString() +} + +func (v hctyValueShim) BoolValue() bool { + return v.val().True() +} + +func (v hctyValueShim) NumberValue() float64 { + f, _ := v.val().AsBigFloat().Float64() + return f +} + +func (v hctyValueShim) BigFloatValue() *big.Float { + return v.val().AsBigFloat() +} + +func (v hctyValueShim) AsValueSlice() []Value { + s := v.val().AsValueSlice() + res := make([]Value, len(s)) + for i, v := range s { + res[i] = hctyValueShim(v) + } + return res +} + +func (v hctyValueShim) AsValueMap() map[string]Value { + m := v.val().AsValueMap() + res := make(map[string]Value, len(m)) + + for k, v := range m { + res[k] = hctyValueShim(v) + } + return res +} + +func (v hctyValueShim) Remove(key string) Value { + switch { + case v.val().Type().IsObjectType(): + m := v.val().AsValueMap() + delete(m, key) + if len(m) == 0 { + return hctyValueShim(cty.EmptyObjectVal) + } + return hctyValueShim(cty.ObjectVal(m)) + default: + return v + } +} + +func (v hctyValueShim) Marshal(schemaType Type) (json.RawMessage, error) { + vv := v.val() + tt, ok := schemaType.(hctyTypeShim) + if !ok { + return nil, fmt.Errorf("Cannot marshal to RawState: "+ + "expected schemaType to be of type hctyTypeShim, got %#T", + schemaType) + } + raw, err := ctyjson.Marshal(vv, tt.ty()) + if err != nil { + return nil, fmt.Errorf("Cannot marshal to RawState: %w", err) + } + return json.RawMessage(raw), nil +} + +type hctyTypeShim cty.Type + +var _ Type = hctyTypeShim{} + +func (t hctyTypeShim) ty() cty.Type { + return cty.Type(t) +} + +func (t hctyTypeShim) IsNumberType() bool { + return t.ty().Equals(cty.Number) +} + +func (t hctyTypeShim) IsBooleanType() bool { + return t.ty().Equals(cty.Bool) +} + +func (t hctyTypeShim) IsStringType() bool { + return t.ty().Equals(cty.String) +} + +func (t hctyTypeShim) IsListType() bool { + return t.ty().IsListType() +} + +func (t hctyTypeShim) IsMapType() bool { + return t.ty().IsMapType() +} + +func (t hctyTypeShim) IsSetType() bool { + return t.ty().IsSetType() +} + +func (t hctyTypeShim) IsObjectType() bool { + return t.ty().IsObjectType() +} + +func (t hctyTypeShim) IsDynamicType() bool { + return t.ty().Equals(cty.DynamicPseudoType) +} + +func (t hctyTypeShim) AttributeType(name string) (Type, bool) { + tt := t.ty() + if !tt.IsObjectType() { + return nil, false + } + if !tt.HasAttribute(name) { + return nil, false + } + return FromHCtyType(tt.AttributeType(name)), true +} + +func (t hctyTypeShim) ElementType() (Type, bool) { + tt := t.ty() + if !tt.IsCollectionType() { + return nil, false + } + return FromHCtyType(tt.ElementType()), true +} + +func (t hctyTypeShim) GoString() string { + return t.ty().GoString() +} diff --git a/pkg/valueshim/shim.go b/pkg/valueshim/shim.go index 00e33d146..9b4054a84 100644 --- a/pkg/valueshim/shim.go +++ b/pkg/valueshim/shim.go @@ -29,7 +29,16 @@ type Value interface { AsValueMap() map[string]Value // Marshals into the "raw state" JSON representation. - Marshal() (json.RawMessage, error) + // + // This is the representation expected on the TF protocol UpgradeResourceState method. + // + // For correctly encoding DynamicPseudoType values to {"type": "...", "value": "..."} structures, the + // schemaType is needed. This encoding will be used when the schema a type is a DynamicPseudoType but + // the value type is a concrete type. + // + // In situations where the DynamicPseudoType encoding is not needed, you can also call Marshal with + // value.Type() to assume the intrinsic type of the value. + Marshal(schemaType Type) (json.RawMessage, error) // Removes a top-level property from an Object. Remove(key string) Value @@ -48,5 +57,8 @@ type Type interface { IsMapType() bool IsSetType() bool IsObjectType() bool + IsDynamicType() bool + AttributeType(name string) (Type, bool) + ElementType() (Type, bool) GoString() string } diff --git a/pkg/pf/internal/pfutils/value_to_json.go b/pkg/valueshim/tftype_json.go similarity index 79% rename from pkg/pf/internal/pfutils/value_to_json.go rename to pkg/valueshim/tftype_json.go index 9b2cb3ef8..b1e46fc24 100644 --- a/pkg/pf/internal/pfutils/value_to_json.go +++ b/pkg/valueshim/tftype_json.go @@ -1,4 +1,4 @@ -// Copyright 2016-2023, Pulumi Corporation. +// Copyright 2016-2025, Pulumi Corporation. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package pfutils +package valueshim import ( "encoding/json" @@ -23,7 +23,7 @@ import ( ) // Inverse of tftypes.ValueFromJson. -func ValueToJSON(typ tftypes.Type, v tftypes.Value) ([]byte, error) { +func tftypeValueToJSON(typ tftypes.Type, v tftypes.Value) ([]byte, error) { raw, err := jsonMarshal(v, typ, tftypes.NewAttributePath()) if err != nil { return nil, err @@ -32,12 +32,15 @@ func ValueToJSON(typ tftypes.Type, v tftypes.Value) ([]byte, error) { } func jsonMarshal(v tftypes.Value, typ tftypes.Type, p *tftypes.AttributePath) (interface{}, error) { - if v.IsNull() { - return nil, nil - } if !v.IsKnown() { return nil, p.NewErrorf("unknown values cannot be serialized to JSON") } + if typ.Is(tftypes.DynamicPseudoType) { + return jsonMarshalDynamicPseudoType(v, typ, p) + } + if v.IsNull() { + return nil, nil + } switch { case typ.Is(tftypes.String): return jsonMarshalString(v, typ, p) @@ -45,8 +48,6 @@ func jsonMarshal(v tftypes.Value, typ tftypes.Type, p *tftypes.AttributePath) (i return jsonMarshalNumber(v, typ, p) case typ.Is(tftypes.Bool): return jsonMarshalBool(v, typ, p) - case typ.Is(tftypes.DynamicPseudoType): - return jsonMarshalDynamicPseudoType(v, typ, p) case typ.Is(tftypes.List{}): return jsonMarshalList(v, typ.(tftypes.List).ElementType, p) case typ.Is(tftypes.Set{}): @@ -77,8 +78,7 @@ func jsonMarshalNumber(v tftypes.Value, typ tftypes.Type, p *tftypes.AttributePa if err != nil { return nil, p.NewError(err) } - f64, _ := n.Float64() - return f64, nil + return json.Number(n.Text('f', -1)), nil } func jsonMarshalBool(v tftypes.Value, typ tftypes.Type, p *tftypes.AttributePath) (interface{}, error) { @@ -90,8 +90,30 @@ func jsonMarshalBool(v tftypes.Value, typ tftypes.Type, p *tftypes.AttributePath return b, nil } -func jsonMarshalDynamicPseudoType(v tftypes.Value, typ tftypes.Type, p *tftypes.AttributePath) ([]byte, error) { - return nil, fmt.Errorf("DynamicPseudoType is not yet supported") +func jsonMarshalDynamicPseudoType(v tftypes.Value, _ tftypes.Type, p *tftypes.AttributePath) (interface{}, error) { + valType := v.Type() + //nolint:staticcheck // the method isn't really deprecated but rather internal + typeJSON, err := valType.MarshalJSON() + if err != nil { + return nil, p.NewError(err) + } + + var marshalledValJSON []byte + // The null case is handled separately to prevent infinite recursion. + if v.IsNull() { + marshalledValJSON = []byte("null") + } else { + valJSON, err := jsonMarshal(v, v.Type(), p) + if err != nil { + return nil, p.NewError(err) + } + marshalledValJSON, err = json.Marshal(valJSON) + if err != nil { + return nil, p.NewError(err) + } + } + + return json.RawMessage(fmt.Sprintf(`{"value": %s, "type": %s}`, marshalledValJSON, typeJSON)), nil } func jsonMarshalList(v tftypes.Value, elementType tftypes.Type, p *tftypes.AttributePath) (interface{}, error) { @@ -100,14 +122,14 @@ func jsonMarshalList(v tftypes.Value, elementType tftypes.Type, p *tftypes.Attri if err != nil { return nil, p.NewError(err) } - var res []interface{} + res := make([]interface{}, len(vs)) for i, v := range vs { ep := p.WithElementKeyInt(i) e, err := jsonMarshal(v, elementType, ep) if err != nil { return nil, ep.NewError(err) } - res = append(res, e) + res[i] = e } return res, nil } @@ -118,14 +140,14 @@ func jsonMarshalSet(v tftypes.Value, elementType tftypes.Type, p *tftypes.Attrib if err != nil { return nil, p.NewError(err) } - var res []interface{} - for _, v := range vs { + res := make([]interface{}, len(vs)) + for i, v := range vs { ep := p.WithElementKeyValue(v) e, err := jsonMarshal(v, elementType, ep) if err != nil { return nil, ep.NewError(err) } - res = append(res, e) + res[i] = e } return res, nil } @@ -136,7 +158,7 @@ func jsonMarshalMap(v tftypes.Value, elementType tftypes.Type, p *tftypes.Attrib if err != nil { return nil, p.NewError(err) } - res := map[string]interface{}{} + res := make(map[string]interface{}, len(vs)) for k, v := range vs { ep := p.WithElementKeyValue(v) e, err := jsonMarshal(v, elementType, ep) diff --git a/pkg/valueshim/tftype_json_test.go b/pkg/valueshim/tftype_json_test.go new file mode 100644 index 000000000..0032ea338 --- /dev/null +++ b/pkg/valueshim/tftype_json_test.go @@ -0,0 +1,540 @@ +// Copyright 2016-2025, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package valueshim + +import ( + "encoding/json" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hexops/autogold/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValueToJSON(t *testing.T) { + t.Parallel() + type testCase struct { + name string + typ tftypes.Type + value tftypes.Value + expectJSON autogold.Value + expectError string + } + + testCases := []testCase{ + // String types + { + name: "string null", + typ: tftypes.String, + value: tftypes.NewValue(tftypes.String, nil), + expectJSON: autogold.Expect("null"), + }, + { + name: "string value", + typ: tftypes.String, + value: tftypes.NewValue(tftypes.String, "hello world"), + expectJSON: autogold.Expect(`"hello world"`), + }, + { + name: "string empty", + typ: tftypes.String, + value: tftypes.NewValue(tftypes.String, ""), + expectJSON: autogold.Expect(`""`), + }, + { + name: "string with special characters", + typ: tftypes.String, + value: tftypes.NewValue(tftypes.String, "hello\nworld\t\"quoted\""), + expectJSON: autogold.Expect(`"hello\nworld\t\"quoted\""`), + }, + + // Number types + { + name: "number null", + typ: tftypes.Number, + value: tftypes.NewValue(tftypes.Number, nil), + expectJSON: autogold.Expect("null"), + }, + { + name: "number integer", + typ: tftypes.Number, + value: tftypes.NewValue(tftypes.Number, 42), + expectJSON: autogold.Expect("42"), + }, + { + name: "number float", + typ: tftypes.Number, + value: tftypes.NewValue(tftypes.Number, 3.14159), + expectJSON: autogold.Expect("3.14159"), + }, + { + name: "number zero", + typ: tftypes.Number, + value: tftypes.NewValue(tftypes.Number, 0), + expectJSON: autogold.Expect("0"), + }, + { + name: "number negative", + typ: tftypes.Number, + value: tftypes.NewValue(tftypes.Number, -123.45), + expectJSON: autogold.Expect("-123.45"), + }, + + // Bool types + { + name: "bool null", + typ: tftypes.Bool, + value: tftypes.NewValue(tftypes.Bool, nil), + expectJSON: autogold.Expect("null"), + }, + { + name: "bool true", + typ: tftypes.Bool, + value: tftypes.NewValue(tftypes.Bool, true), + expectJSON: autogold.Expect("true"), + }, + { + name: "bool false", + typ: tftypes.Bool, + value: tftypes.NewValue(tftypes.Bool, false), + expectJSON: autogold.Expect("false"), + }, + + // List types + { + name: "list null", + typ: tftypes.List{ElementType: tftypes.String}, + value: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, nil), + expectJSON: autogold.Expect("null"), + }, + { + name: "list empty", + typ: tftypes.List{ElementType: tftypes.String}, + value: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{}), + expectJSON: autogold.Expect("[]"), + }, + { + name: "list of strings", + typ: tftypes.List{ElementType: tftypes.String}, + value: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "first"), + tftypes.NewValue(tftypes.String, "second"), + tftypes.NewValue(tftypes.String, "third"), + }), + expectJSON: autogold.Expect(`["first","second","third"]`), + }, + { + name: "list of numbers", + typ: tftypes.List{ElementType: tftypes.Number}, + value: tftypes.NewValue(tftypes.List{ElementType: tftypes.Number}, []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 1), + tftypes.NewValue(tftypes.Number, 2), + tftypes.NewValue(tftypes.Number, 3), + }), + expectJSON: autogold.Expect(`[1,2,3]`), + }, + { + name: "list with null element", + typ: tftypes.List{ElementType: tftypes.String}, + value: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "first"), + tftypes.NewValue(tftypes.String, nil), + tftypes.NewValue(tftypes.String, "third"), + }), + expectJSON: autogold.Expect(`["first",null,"third"]`), + }, + + // Set types + { + name: "set null", + typ: tftypes.Set{ElementType: tftypes.String}, + value: tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, nil), + expectJSON: autogold.Expect("null"), + }, + { + name: "set empty", + typ: tftypes.Set{ElementType: tftypes.String}, + value: tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, []tftypes.Value{}), + expectJSON: autogold.Expect("[]"), + }, + { + name: "set of strings", + typ: tftypes.Set{ElementType: tftypes.String}, + value: tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "alpha"), + tftypes.NewValue(tftypes.String, "beta"), + }), + expectJSON: autogold.Expect(`["alpha","beta"]`), + }, + + // Map types + { + name: "map null", + typ: tftypes.Map{ElementType: tftypes.String}, + value: tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, nil), + expectJSON: autogold.Expect("null"), + }, + { + name: "map empty", + typ: tftypes.Map{ElementType: tftypes.String}, + value: tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, map[string]tftypes.Value{}), + expectJSON: autogold.Expect("{}"), + }, + { + name: "map of strings", + typ: tftypes.Map{ElementType: tftypes.String}, + value: tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, map[string]tftypes.Value{ + "key1": tftypes.NewValue(tftypes.String, "value1"), + "key2": tftypes.NewValue(tftypes.String, "value2"), + }), + expectJSON: autogold.Expect(`{"key1":"value1","key2":"value2"}`), + }, + { + name: "map with null value", + typ: tftypes.Map{ElementType: tftypes.String}, + value: tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, map[string]tftypes.Value{ + "key1": tftypes.NewValue(tftypes.String, "value1"), + "key2": tftypes.NewValue(tftypes.String, nil), + }), + expectJSON: autogold.Expect(`{"key1":"value1","key2":null}`), + }, + + // Object types + { + name: "object null", + typ: tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + "age": tftypes.Number, + }}, + value: tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + "age": tftypes.Number, + }}, nil), + expectJSON: autogold.Expect("null"), + }, + { + name: "object simple", + typ: tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + "age": tftypes.Number, + }}, + value: tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + "age": tftypes.Number, + }}, map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "John"), + "age": tftypes.NewValue(tftypes.Number, 30), + }), + expectJSON: autogold.Expect(`{"age":30,"name":"John"}`), + }, + { + name: "object with null attribute", + typ: tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + "email": tftypes.String, + }}, + value: tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + "email": tftypes.String, + }}, map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "John"), + "email": tftypes.NewValue(tftypes.String, nil), + }), + expectJSON: autogold.Expect(`{"email":null,"name":"John"}`), + }, + + // Tuple types + { + name: "tuple null", + typ: tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.String, tftypes.Number}}, + value: tftypes.NewValue(tftypes.Tuple{ElementTypes: []tftypes.Type{ + tftypes.String, + tftypes.Number, + }}, nil), + expectJSON: autogold.Expect("null"), + }, + { + name: "tuple simple", + typ: tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.String, tftypes.Number, tftypes.Bool}}, + value: tftypes.NewValue(tftypes.Tuple{ElementTypes: []tftypes.Type{ + tftypes.String, + tftypes.Number, + tftypes.Bool, + }}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "hello"), + tftypes.NewValue(tftypes.Number, 42), + tftypes.NewValue(tftypes.Bool, true), + }), + expectJSON: autogold.Expect(`["hello",42,true]`), + }, + { + name: "tuple with null element", + typ: tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.String, tftypes.Number}}, + value: tftypes.NewValue(tftypes.Tuple{ElementTypes: []tftypes.Type{ + tftypes.String, + tftypes.Number, + }}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "hello"), + tftypes.NewValue(tftypes.Number, nil), + }), + expectJSON: autogold.Expect(`["hello",null]`), + }, + + // Nested structures + { + name: "nested list of objects", + typ: tftypes.List{ElementType: tftypes.Object{AttributeTypes: map[string]tftypes.Type{"name": tftypes.String}}}, + value: tftypes.NewValue( + tftypes.List{ElementType: tftypes.Object{AttributeTypes: map[string]tftypes.Type{"name": tftypes.String}}}, + []tftypes.Value{ + tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{"name": tftypes.String}}, + map[string]tftypes.Value{"name": tftypes.NewValue(tftypes.String, "Alice")}), + tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{"name": tftypes.String}}, + map[string]tftypes.Value{"name": tftypes.NewValue(tftypes.String, "Bob")}), + }, + ), + expectJSON: autogold.Expect(`[{"name":"Alice"},{"name":"Bob"}]`), + }, + { + name: "object with nested list", + typ: tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + "items": tftypes.List{ElementType: tftypes.String}, + }}, + value: tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + "items": tftypes.List{ElementType: tftypes.String}, + }}, map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "shopping"), + "items": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "milk"), + tftypes.NewValue(tftypes.String, "bread"), + }), + }), + expectJSON: autogold.Expect(`{"items":["milk","bread"],"name":"shopping"}`), + }, + + // Error cases + { + name: "unknown value", + typ: tftypes.String, + value: tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + expectError: "unknown values cannot be serialized to JSON", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + result, err := tftypeValueToJSON(tc.typ, tc.value) + + if tc.expectError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectError) + } else { + require.NoError(t, err) + tc.expectJSON.Equal(t, string(result)) + } + }) + } +} + +func TestValueToJSON_DynamicPseudoType(t *testing.T) { + t.Parallel() + + // Test dynamic pseudo type which requires special handling + // DynamicPseudoType is used to wrap values of any concrete type + // We create a string value but tell the marshal function to treat it as dynamic + stringValue := tftypes.NewValue(tftypes.String, "dynamic content") + + // When marshaling with DynamicPseudoType as the schema type, + // it should produce {"type": "...", "value": "..."} + result, err := tftypeValueToJSON(tftypes.DynamicPseudoType, stringValue) + require.NoError(t, err) + + // The result should be a JSON object with type and value fields + expected := `{"type":"string","value":"dynamic content"}` + assert.JSONEq(t, expected, string(result)) + + // Also test with other types + numberValue := tftypes.NewValue(tftypes.Number, 42) + result, err = tftypeValueToJSON(tftypes.DynamicPseudoType, numberValue) + require.NoError(t, err) + + expected = `{"type":"number","value":42}` + assert.JSONEq(t, expected, string(result)) + + boolValue := tftypes.NewValue(tftypes.Bool, true) + result, err = tftypeValueToJSON(tftypes.DynamicPseudoType, boolValue) + require.NoError(t, err) + + expected = `{"type":"bool","value":true}` + assert.JSONEq(t, expected, string(result)) +} + +func TestValueToJSON_ComplexNested(t *testing.T) { + t.Parallel() + + // Test a complex nested structure + complexType := tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "metadata": tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + "version": tftypes.Number, + }}, + "items": tftypes.List{ElementType: tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "active": tftypes.Bool, + "tags": tftypes.Set{ElementType: tftypes.String}, + }}}, + "config": tftypes.Map{ElementType: tftypes.String}, + }} + + complexValue := tftypes.NewValue(complexType, map[string]tftypes.Value{ + "metadata": tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + "version": tftypes.Number, + }}, map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "test-app"), + "version": tftypes.NewValue(tftypes.Number, 1.5), + }), + "items": tftypes.NewValue(tftypes.List{ElementType: tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "active": tftypes.Bool, + "tags": tftypes.Set{ElementType: tftypes.String}, + }}}, []tftypes.Value{ + tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "active": tftypes.Bool, + "tags": tftypes.Set{ElementType: tftypes.String}, + }}, map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, "item1"), + "active": tftypes.NewValue(tftypes.Bool, true), + "tags": tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "prod"), + tftypes.NewValue(tftypes.String, "web"), + }), + }), + }), + "config": tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, map[string]tftypes.Value{ + "env": tftypes.NewValue(tftypes.String, "production"), + "debug": tftypes.NewValue(tftypes.String, "false"), + "optional": tftypes.NewValue(tftypes.String, nil), + }), + }) + + result, err := tftypeValueToJSON(complexType, complexValue) + require.NoError(t, err) + + // Verify it's valid JSON + var parsed interface{} + err = json.Unmarshal(result, &parsed) + require.NoError(t, err) + + // Convert back to pretty JSON for readability in test output + prettyResult, err := json.MarshalIndent(parsed, "", " ") + require.NoError(t, err) + + autogold.Expect(`{ + "config": { + "debug": "false", + "env": "production", + "optional": null + }, + "items": [ + { + "active": true, + "id": "item1", + "tags": [ + "prod", + "web" + ] + } + ], + "metadata": { + "name": "test-app", + "version": 1.5 + } +}`).Equal(t, string(prettyResult)) +} + +func TestValueToJSON_EdgeCases(t *testing.T) { + t.Parallel() + + type testCase struct { + name string + typ tftypes.Type + value tftypes.Value + expectError string + } + + testCases := []testCase{ + { + name: "unknown string value", + typ: tftypes.String, + value: tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + expectError: "unknown values cannot be serialized to JSON", + }, + { + name: "unknown number value", + typ: tftypes.Number, + value: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + expectError: "unknown values cannot be serialized to JSON", + }, + { + name: "unknown bool value", + typ: tftypes.Bool, + value: tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue), + expectError: "unknown values cannot be serialized to JSON", + }, + { + name: "list with unknown element", + typ: tftypes.List{ElementType: tftypes.String}, + value: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "known"), + tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + }), + expectError: "unknown values cannot be serialized to JSON", + }, + { + name: "object with unknown attribute", + typ: tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "known": tftypes.String, + "unknown": tftypes.String, + }}, + value: tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "known": tftypes.String, + "unknown": tftypes.String, + }}, map[string]tftypes.Value{ + "known": tftypes.NewValue(tftypes.String, "value"), + "unknown": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + }), + expectError: "unknown values cannot be serialized to JSON", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + result, err := tftypeValueToJSON(tc.typ, tc.value) + + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectError) + assert.Nil(t, result) + }) + } +} diff --git a/pkg/valueshim/tfvalue.go b/pkg/valueshim/tfvalue.go index 35e27e06d..24c67d408 100644 --- a/pkg/valueshim/tfvalue.go +++ b/pkg/valueshim/tfvalue.go @@ -16,6 +16,7 @@ package valueshim import ( "encoding/json" + "errors" "math/big" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -74,12 +75,16 @@ func (v tValueShim) AsValueMap() map[string]Value { return res } -func (v tValueShim) Marshal() (json.RawMessage, error) { - inmem, err := jsonMarshal(v.val(), tftypes.NewAttributePath()) +func (v tValueShim) Marshal(schemaType Type) (json.RawMessage, error) { + tt, ok := schemaType.(tTypeShim) + if !ok { + return nil, errors.New("Cannot marshal to RawState: expected schemaType to be of type tTypeShim") + } + raw, err := tftypeValueToJSON(tt.ty(), v.val()) if err != nil { return nil, err } - return json.Marshal(inmem) + return json.RawMessage(raw), nil } func (v tValueShim) Remove(prop string) Value { @@ -144,159 +149,11 @@ func (v tValueShim) StringValue() string { return result } -func jsonMarshal(v tftypes.Value, p *tftypes.AttributePath) (interface{}, error) { - if v.IsNull() { - return nil, nil - } - if !v.IsKnown() { - return nil, p.NewErrorf("unknown values cannot be serialized to JSON") - } - typ := v.Type() - switch { - case typ.Is(tftypes.String): - return jsonMarshalString(v, p) - case typ.Is(tftypes.Number): - return jsonMarshalNumber(v, p) - case typ.Is(tftypes.Bool): - return jsonMarshalBool(v, p) - case typ.Is(tftypes.List{}): - return jsonMarshalList(v, p) - case typ.Is(tftypes.Set{}): - return jsonMarshalSet(v, p) - case typ.Is(tftypes.Map{}): - return jsonMarshalMap(v, p) - case typ.Is(tftypes.Tuple{}): - return jsonMarshalTuple(v, p) - case typ.Is(tftypes.Object{}): - return jsonMarshalObject(v, p) - } - - return nil, p.NewErrorf("unknown type %s", typ) -} - -func jsonMarshalString(v tftypes.Value, p *tftypes.AttributePath) (interface{}, error) { - var stringValue string - err := v.As(&stringValue) - if err != nil { - return nil, p.NewError(err) - } - return stringValue, nil -} - -func jsonMarshalNumber(v tftypes.Value, p *tftypes.AttributePath) (interface{}, error) { - var n big.Float - err := v.As(&n) - if err != nil { - return nil, p.NewError(err) - } - return json.Number(n.Text('f', -1)), nil -} - -func jsonMarshalBool(v tftypes.Value, p *tftypes.AttributePath) (interface{}, error) { - var b bool - err := v.As(&b) - if err != nil { - return nil, p.NewError(err) - } - return b, nil -} - -func jsonMarshalList(v tftypes.Value, p *tftypes.AttributePath) (interface{}, error) { - var vs []tftypes.Value - err := v.As(&vs) - if err != nil { - return nil, p.NewError(err) - } - res := make([]any, len(vs)) - for i, v := range vs { - ep := p.WithElementKeyInt(i) - e, err := jsonMarshal(v, ep) - if err != nil { - return nil, ep.NewError(err) - } - res[i] = e - } - return res, nil -} - -// Important to preserve original order of tftypes.Value here. -func jsonMarshalSet(v tftypes.Value, p *tftypes.AttributePath) (interface{}, error) { - var vs []tftypes.Value - err := v.As(&vs) - if err != nil { - return nil, p.NewError(err) - } - res := make([]any, len(vs)) - for i, v := range vs { - ep := p.WithElementKeyValue(v) - e, err := jsonMarshal(v, ep) - if err != nil { - return nil, ep.NewError(err) - } - res[i] = e - } - return res, nil -} - -func jsonMarshalMap(v tftypes.Value, p *tftypes.AttributePath) (interface{}, error) { - var vs map[string]tftypes.Value - err := v.As(&vs) - if err != nil { - return nil, p.NewError(err) - } - res := make(map[string]any, len(vs)) - for k, v := range vs { - ep := p.WithElementKeyValue(v) - e, err := jsonMarshal(v, ep) - if err != nil { - return nil, ep.NewError(err) - } - res[k] = e - } - return res, nil -} - -func jsonMarshalTuple(v tftypes.Value, p *tftypes.AttributePath) (interface{}, error) { - var vs []tftypes.Value - err := v.As(&vs) - if err != nil { - return nil, p.NewError(err) - } - res := make([]any, len(vs)) - for i, v := range vs { - ep := p.WithElementKeyInt(i) - e, err := jsonMarshal(v, ep) - if err != nil { - return nil, ep.NewError(err) - } - res[i] = e - } - return res, nil -} - -func jsonMarshalObject(v tftypes.Value, p *tftypes.AttributePath) (interface{}, error) { - var vs map[string]tftypes.Value - err := v.As(&vs) - if err != nil { - return nil, p.NewError(err) - } - res := make(map[string]any, len(vs)) - for k, v := range vs { - ep := p.WithAttributeName(k) - e, err := jsonMarshal(v, ep) - if err != nil { - return nil, ep.NewError(err) - } - res[k] = e - } - return res, nil -} - type tTypeShim struct { t tftypes.Type } -var _ Type = (*tTypeShim)(nil) +var _ Type = tTypeShim{} func (t tTypeShim) ty() tftypes.Type { return t.t @@ -330,6 +187,35 @@ func (t tTypeShim) IsObjectType() bool { return t.ty().Is(tftypes.Object{}) } +func (t tTypeShim) IsDynamicType() bool { + return t.ty().Is(tftypes.DynamicPseudoType) +} + func (t tTypeShim) GoString() string { return t.ty().String() } + +func (t tTypeShim) AttributeType(name string) (Type, bool) { + if !t.IsObjectType() { + return nil, false + } + tt := t.ty() + attr, ok := tt.(tftypes.Object).AttributeTypes[name] + if !ok { + return nil, false + } + return FromTType(attr), true +} + +func (t tTypeShim) ElementType() (Type, bool) { + tt := t.ty() + switch { + case tt.Is(tftypes.Map{}): + return FromTType(tt.(tftypes.Map).ElementType), true + case tt.Is(tftypes.List{}): + return FromTType(tt.(tftypes.List).ElementType), true + case tt.Is(tftypes.Set{}): + return FromTType(tt.(tftypes.Set).ElementType), true + } + return nil, false +} diff --git a/pkg/valueshim/tfvalue_test.go b/pkg/valueshim/tfvalue_test.go index a30df0a30..fc22ef6ca 100644 --- a/pkg/valueshim/tfvalue_test.go +++ b/pkg/valueshim/tfvalue_test.go @@ -104,8 +104,10 @@ func Test_TValue_Marshal(t *testing.T) { tupType := tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.String, tftypes.Number}} type testCase struct { - v tftypes.Value - expect autogold.Value + v tftypes.Value + schemaType tftypes.Type + hasSchemaType bool + expect autogold.Value } testCases := []testCase{ @@ -207,10 +209,54 @@ func Test_TValue_Marshal(t *testing.T) { v: tftypes.NewValue(tupType, []tftypes.Value{ok, n42}), expect: autogold.Expect(`["OK",42]`), }, + { + v: tftypes.NewValue(tftypes.String, nil), + schemaType: tftypes.DynamicPseudoType, + hasSchemaType: true, + expect: autogold.Expect(`{"value":null,"type":"string"}`), + }, + { + v: tftypes.NewValue(tftypes.String, "foo"), + schemaType: tftypes.DynamicPseudoType, + hasSchemaType: true, + expect: autogold.Expect(`{"value":"foo","type":"string"}`), + }, + { + v: tftypes.NewValue(tftypes.Number, 42), + schemaType: tftypes.DynamicPseudoType, + hasSchemaType: true, + expect: autogold.Expect(`{"value":42,"type":"number"}`), + }, + { + v: tftypes.NewValue(tftypes.Bool, true), + schemaType: tftypes.DynamicPseudoType, + hasSchemaType: true, + expect: autogold.Expect(`{"value":true,"type":"bool"}`), + }, + { + v: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "OK"), + }), + schemaType: tftypes.DynamicPseudoType, + hasSchemaType: true, + expect: autogold.Expect(`{"value":["OK"],"type":["list","string"]}`), + }, + { + v: tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, map[string]tftypes.Value{ + "foo": tftypes.NewValue(tftypes.String, "OK"), + }), + schemaType: tftypes.DynamicPseudoType, + hasSchemaType: true, + expect: autogold.Expect(`{"value":{"foo":"OK"},"type":["map","string"]}`), + }, } for _, tc := range testCases { - raw, err := valueshim.FromTValue(tc.v).Marshal() + ty := tc.schemaType + if !tc.hasSchemaType { + ty = tc.v.Type() + } + raw, err := valueshim.FromTValue(tc.v).Marshal(valueshim.FromTType(ty)) require.NoError(t, err) tc.expect.Equal(t, string(raw)) } @@ -267,6 +313,36 @@ func Test_TType(t *testing.T) { assert.Equal(t, "tftypes.Object[]", valueshim.FromTType(tftypes.Object{}).GoString()) } +func Test_TType_AttributeType(t *testing.T) { + t.Parallel() + objTy := tftypes.Object{AttributeTypes: map[string]tftypes.Type{"x": tftypes.String}} + + ty, ok := valueshim.FromTType(objTy).AttributeType("x") + assert.True(t, ok) + assert.Equal(t, valueshim.FromTType(tftypes.String), ty) + + _, ok = valueshim.FromTType(objTy).AttributeType("y") + assert.False(t, ok) +} + +func Test_TType_ElementType(t *testing.T) { + t.Parallel() + ty, ok := valueshim.FromTType(tftypes.Set{ElementType: tftypes.Number}).ElementType() + assert.True(t, ok) + assert.Equal(t, valueshim.FromTType(tftypes.Number), ty) + + ty, ok = valueshim.FromTType(tftypes.List{ElementType: tftypes.Number}).ElementType() + assert.True(t, ok) + assert.Equal(t, valueshim.FromTType(tftypes.Number), ty) + + ty, ok = valueshim.FromTType(tftypes.Map{ElementType: tftypes.Number}).ElementType() + assert.True(t, ok) + assert.Equal(t, valueshim.FromTType(tftypes.Number), ty) + + _, ok = valueshim.FromTType(tftypes.String).ElementType() + assert.False(t, ok) +} + func Test_TValue_ToX(t *testing.T) { t.Parallel()