diff --git a/pkg/tfshim/sdk-v2/cty_json.go b/pkg/tfshim/sdk-v2/cty_json.go deleted file mode 100644 index fd8a337f1..000000000 --- a/pkg/tfshim/sdk-v2/cty_json.go +++ /dev/null @@ -1,207 +0,0 @@ -package sdkv2 - -import ( - "bytes" - "encoding/json" - "fmt" - "sort" - - "github.com/hashicorp/go-cty/cty" - cty_json "github.com/hashicorp/go-cty/cty/json" -) - -// marshal takes a cty.Value and converts it to a JSON string -// This is a fork of the marshal function for the hashicorp/go-cty package -// https://github.com/hashicorp/go-cty/blob/master/cty/json/marshal.go -// -// The only difference being the handling of Unknown values. In our case we want to convert -// the unknown value into a string value -func marshal(val cty.Value, t cty.Type, path cty.Path, b *bytes.Buffer) error { - if val.IsMarked() { - return path.NewErrorf("value has marks, so it cannot be serialized") - } - - // If we're going to decode as DynamicPseudoType then we need to save - // dynamic type information to recover the real type. - if t == cty.DynamicPseudoType && val.Type() != cty.DynamicPseudoType { - return marshalDynamic(val, path, b) - } - - if val.IsNull() { - b.WriteString("null") - return nil - } - - // This is the one difference between the hashicorp/go-cty marshal function - // This function does not correctly handle Unknown values. At this point it could be any unknown type, - // an unknown map, list, etc, but it is not possible to further recurse on an Unknown type - // so the best we can do is just convert it to a string sentinel - if !val.IsKnown() { - b.WriteString(fmt.Sprintf("%q", terraformUnknownVariableValue)) - return nil - } - - // The caller should've guaranteed that the given val is conformant with - // the given type t, so we'll proceed under that assumption here. - switch { - case t.IsPrimitiveType(): - switch t { - case cty.String: - json, err := json.Marshal(val.AsString()) - if err != nil { - return path.NewErrorf("failed to serialize value: %s", err) - } - b.Write(json) - return nil - case cty.Number: - if val.RawEquals(cty.PositiveInfinity) || val.RawEquals(cty.NegativeInfinity) { - return path.NewErrorf("cannot serialize infinity as JSON") - } - b.WriteString(val.AsBigFloat().Text('f', -1)) - return nil - case cty.Bool: - if val.True() { - b.WriteString("true") - } else { - b.WriteString("false") - } - return nil - default: - panic("unsupported primitive type") - } - case t.IsListType(), t.IsSetType(): - b.WriteRune('[') - first := true - ety := t.ElementType() - it := val.ElementIterator() - path := append(path, nil) // local override of 'path' with extra element - for it.Next() { - if !first { - b.WriteRune(',') - } - ek, ev := it.Element() - path[len(path)-1] = cty.IndexStep{ - Key: ek, - } - err := marshal(ev, ety, path, b) - if err != nil { - return err - } - first = false - } - b.WriteRune(']') - return nil - case t.IsMapType(): - b.WriteRune('{') - first := true - ety := t.ElementType() - it := val.ElementIterator() - path := append(path, nil) // local override of 'path' with extra element - for it.Next() { - if !first { - b.WriteRune(',') - } - ek, ev := it.Element() - path[len(path)-1] = cty.IndexStep{ - Key: ek, - } - var err error - err = marshal(ek, ek.Type(), path, b) - if err != nil { - return err - } - b.WriteRune(':') - err = marshal(ev, ety, path, b) - if err != nil { - return err - } - first = false - } - b.WriteRune('}') - return nil - case t.IsTupleType(): - b.WriteRune('[') - etys := t.TupleElementTypes() - it := val.ElementIterator() - path := append(path, nil) // local override of 'path' with extra element - i := 0 - for it.Next() { - if i > 0 { - b.WriteRune(',') - } - ety := etys[i] - ek, ev := it.Element() - path[len(path)-1] = cty.IndexStep{ - Key: ek, - } - err := marshal(ev, ety, path, b) - if err != nil { - return err - } - i++ - } - b.WriteRune(']') - return nil - case t.IsObjectType(): - b.WriteRune('{') - atys := t.AttributeTypes() - path := append(path, nil) // local override of 'path' with extra element - - names := make([]string, 0, len(atys)) - for k := range atys { - names = append(names, k) - } - sort.Strings(names) - - for i, k := range names { - aty := atys[k] - if i > 0 { - b.WriteRune(',') - } - av := val.GetAttr(k) - path[len(path)-1] = cty.GetAttrStep{ - Name: k, - } - var err error - err = marshal(cty.StringVal(k), cty.String, path, b) - if err != nil { - return err - } - b.WriteRune(':') - err = marshal(av, aty, path, b) - if err != nil { - return err - } - } - b.WriteRune('}') - return nil - case t.IsCapsuleType(): - rawVal := val.EncapsulatedValue() - jsonVal, err := json.Marshal(rawVal) - if err != nil { - return path.NewError(err) - } - b.Write(jsonVal) - return nil - default: - // should never happen - return path.NewErrorf("cannot JSON-serialize %s", t.FriendlyName()) - } -} - -// marshalDynamic adds an extra wrapping object containing dynamic type -// information for the given value. -func marshalDynamic(val cty.Value, path cty.Path, b *bytes.Buffer) error { - typeJSON, err := cty_json.MarshalType(val.Type()) - if err != nil { - return path.NewErrorf("failed to serialize type: %s", err) - } - b.WriteString(`{"value":`) - if err := marshal(val, val.Type(), path, b); err != nil { - return err - } - b.WriteString(`,"type":`) - b.Write(typeJSON) - b.WriteRune('}') - return nil -} diff --git a/pkg/tfshim/sdk-v2/instance_state.go b/pkg/tfshim/sdk-v2/instance_state.go index b4def4cf1..29a7083e0 100644 --- a/pkg/tfshim/sdk-v2/instance_state.go +++ b/pkg/tfshim/sdk-v2/instance_state.go @@ -15,14 +15,10 @@ package sdkv2 import ( - "bytes" - "encoding/json" "strings" - "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" - "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" diff_reader "github.com/pulumi/terraform-diff-reader/sdk-v2" shim "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim" @@ -67,34 +63,6 @@ func (s v2InstanceState) Object(sch shim.SchemaMap) (map[string]interface{}, err return s.objectV1(sch) } -// This is needed because json.Unmarshal uses float64 for numbers by default which truncates int64 numbers. -func unmarshalJSON(data []byte, v interface{}) error { - dec := json.NewDecoder(bytes.NewReader(data)) - dec.UseNumber() - return dec.Decode(v) -} - -// objectFromCtyValue takes a cty.Value and converts it to JSON object. -// We do not care about type checking the values, we just want to do our best to recursively convert -// the cty.Value to the underlying value -// -// NOTE: one of the transforms this needs to handle is converting unknown values. -// cty.Value that are also unknown cannot be converted to their underlying value. To get -// around this we just convert to a sentinel, which so far does not seem to cause any issues downstream -func objectFromCtyValue(v cty.Value) map[string]interface{} { - var path cty.Path - buf := &bytes.Buffer{} - // The round trip here to JSON is redundant, we could instead convert from cty to map[string]interface{} directly - err := marshal(v, v.Type(), path, buf) - contract.AssertNoErrorf(err, "Failed to marshal cty.Value to a JSON string value") - - var m map[string]interface{} - err = unmarshalJSON(buf.Bytes(), &m) - contract.AssertNoErrorf(err, "failed to unmarshal: %s", buf.String()) - - return m -} - // The legacy version of Object used custom Pulumi code forked from TF sources. func (s v2InstanceState) objectV1(sch shim.SchemaMap) (map[string]interface{}, error) { obj := make(map[string]interface{}) diff --git a/pkg/tfshim/sdk-v2/object_from_cty.go b/pkg/tfshim/sdk-v2/object_from_cty.go new file mode 100644 index 000000000..78c5d552f --- /dev/null +++ b/pkg/tfshim/sdk-v2/object_from_cty.go @@ -0,0 +1,60 @@ +package sdkv2 + +import ( + "github.com/hashicorp/go-cty/cty" + "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" +) + +func objectFromCtyValue(val cty.Value) map[string]any { + res := objectFromCtyValueInner(val) + if res == nil { + return nil + } + return res.(map[string]any) +} + +func objectFromCtyValueInner(val cty.Value) any { + contract.Assertf(!val.IsMarked(), "value has marks, so it cannot be serialized") + if val.IsNull() { + return nil + } + + if !val.IsKnown() { + return terraformUnknownVariableValue + } + + switch { + case val.Type().IsPrimitiveType(): + switch val.Type() { + case cty.String: + return val.AsString() + case cty.Number: + return val.AsBigFloat().Text('f', -1) + case cty.Bool: + return val.True() + default: + contract.Failf("unsupported primitive type: %s", val.Type().FriendlyName()) + } + case val.Type().IsListType(), val.Type().IsSetType(), val.Type().IsTupleType(): + l := make([]interface{}, 0, val.LengthInt()) + it := val.ElementIterator() + for it.Next() { + _, ev := it.Element() + elem := objectFromCtyValueInner(ev) + l = append(l, elem) + } + return l + case val.Type().IsObjectType(), val.Type().IsMapType(): + l := make(map[string]interface{}) + it := val.ElementIterator() + for it.Next() { + ek, ev := it.Element() + cv := objectFromCtyValueInner(ev) + l[ek.AsString()] = cv + } + return l + } + + contract.Failf("unsupported type: %s", val.Type().FriendlyName()) + return nil +} diff --git a/pkg/tfshim/sdk-v2/object_from_cty_test.go b/pkg/tfshim/sdk-v2/object_from_cty_test.go new file mode 100644 index 000000000..8fd83c53e --- /dev/null +++ b/pkg/tfshim/sdk-v2/object_from_cty_test.go @@ -0,0 +1,157 @@ +package sdkv2 + +import ( + "reflect" + "testing" + + "github.com/hashicorp/go-cty/cty" + "github.com/stretchr/testify/require" +) + +func TestNilCase(t *testing.T) { + t.Parallel() + val := cty.NullVal(cty.String) + result := objectFromCtyValue(val) + require.Nil(t, result) +} + +func TestEncapsulatedTypeFail(t *testing.T) { + t.Parallel() + innerVal := "test" + val := cty.CapsuleVal(cty.Capsule("name", reflect.TypeOf(innerVal)), &innerVal) + require.Panics(t, func() { + objectFromCtyValueInner(val) + }) +} + +func TestObjectFromCtyValue(t *testing.T) { + t.Parallel() + t.Run("Null", func(t *testing.T) { + val := cty.NullVal(cty.String) + result := objectFromCtyValueInner(val) + require.Nil(t, result) + }) + + t.Run("Unknown", func(t *testing.T) { + val := cty.UnknownVal(cty.String) + result := objectFromCtyValueInner(val) + require.Equal(t, terraformUnknownVariableValue, result) + }) + + t.Run("Dynamic Value", func(t *testing.T) { + val := cty.DynamicVal + result := objectFromCtyValueInner(val) + require.Equal(t, terraformUnknownVariableValue, result) + }) + + t.Run("String", func(t *testing.T) { + val := cty.StringVal("test") + result := objectFromCtyValueInner(val) + require.Equal(t, "test", result) + }) + + t.Run("Number", func(t *testing.T) { + val := cty.NumberIntVal(42) + result := objectFromCtyValueInner(val) + require.Equal(t, "42", result) + }) + + t.Run("Bool", func(t *testing.T) { + val := cty.BoolVal(true) + result := objectFromCtyValueInner(val) + require.Equal(t, true, result) + }) + + t.Run("List", func(t *testing.T) { + val := cty.ListVal([]cty.Value{ + cty.StringVal("one"), + cty.StringVal("two"), + }) + result := objectFromCtyValueInner(val) + expected := []interface{}{"one", "two"} + + require.Equal(t, expected, result) + }) + + t.Run("Set", func(t *testing.T) { + val := cty.SetVal([]cty.Value{ + cty.StringVal("one"), + cty.StringVal("two"), + }) + result := objectFromCtyValueInner(val) + // Note: sets might have non-deterministic order when converted to list + elements := result.([]interface{}) + require.Equal(t, 2, len(elements)) + require.Contains(t, elements, "one") + require.Contains(t, elements, "two") + }) + + t.Run("Map", func(t *testing.T) { + val := cty.MapVal(map[string]cty.Value{ + "key1": cty.StringVal("value1"), + "key2": cty.StringVal("value2"), + }) + result := objectFromCtyValue(val) + expected := map[string]interface{}{ + "key1": "value1", + "key2": "value2", + } + + require.Equal(t, expected, result) + }) + + t.Run("Map with different types", func(t *testing.T) { + val := cty.ObjectVal(map[string]cty.Value{ + "key1": cty.StringVal("value1"), + "key2": cty.NumberIntVal(42), + }) + result := objectFromCtyValue(val) + expected := map[string]interface{}{ + "key1": "value1", + "key2": "42", + } + + require.Equal(t, expected, result) + }) + + t.Run("Object", func(t *testing.T) { + val := cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("test"), + "count": cty.NumberIntVal(5), + "valid": cty.BoolVal(true), + }) + + result := objectFromCtyValue(val) + expected := map[string]interface{}{ + "name": "test", + "count": "5", + "valid": true, + } + + require.Equal(t, expected, result) + }) + + t.Run("Nested", func(t *testing.T) { + val := cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("parent"), + "child": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("child"), + "list": cty.ListVal([]cty.Value{ + cty.StringVal("item1"), + cty.StringVal("item2"), + }), + }), + }) + + result := objectFromCtyValue(val) + expected := map[string]interface{}{ + "name": "parent", + "child": map[string]interface{}{ + "name": "child", + "list": []interface{}{"item1", "item2"}, + }, + } + + require.Equal(t, expected, result) + }) +}