| // Copyright (c) The OpenTofu Authors | |
| // SPDX-License-Identifier: MPL-2.0 | |
| // Copyright (c) 2023 HashiCorp, Inc. | |
| // SPDX-License-Identifier: MPL-2.0 | |
| package jsonplan | |
| import ( | |
| "encoding/json" | |
| "reflect" | |
| "testing" | |
| "github.com/zclconf/go-cty/cty" | |
| "github.com/opentofu/opentofu/internal/addrs" | |
| "github.com/opentofu/opentofu/internal/configs/configschema" | |
| "github.com/opentofu/opentofu/internal/plans" | |
| "github.com/opentofu/opentofu/internal/providers" | |
| "github.com/opentofu/opentofu/internal/states" | |
| "github.com/opentofu/opentofu/internal/tofu" | |
| ) | |
| func TestMarshalAttributeValues(t *testing.T) { | |
| tests := []struct { | |
| Attr cty.Value | |
| Schema *configschema.Block | |
| Want AttributeValues | |
| }{ | |
| { | |
| cty.NilVal, | |
| &configschema.Block{ | |
| Attributes: map[string]*configschema.Attribute{ | |
| "foo": { | |
| Type: cty.String, | |
| Optional: true, | |
| }, | |
| }, | |
| }, | |
| nil, | |
| }, | |
| { | |
| cty.NullVal(cty.String), | |
| &configschema.Block{ | |
| Attributes: map[string]*configschema.Attribute{ | |
| "foo": { | |
| Type: cty.String, | |
| Optional: true, | |
| }, | |
| }, | |
| }, | |
| nil, | |
| }, | |
| { | |
| cty.ObjectVal(map[string]cty.Value{ | |
| "foo": cty.StringVal("bar"), | |
| }), | |
| &configschema.Block{ | |
| Attributes: map[string]*configschema.Attribute{ | |
| "foo": { | |
| Type: cty.String, | |
| Optional: true, | |
| }, | |
| }, | |
| }, | |
| AttributeValues{"foo": json.RawMessage(`"bar"`)}, | |
| }, | |
| { | |
| cty.ObjectVal(map[string]cty.Value{ | |
| "foo": cty.NullVal(cty.String), | |
| }), | |
| &configschema.Block{ | |
| Attributes: map[string]*configschema.Attribute{ | |
| "foo": { | |
| Type: cty.String, | |
| Optional: true, | |
| }, | |
| }, | |
| }, | |
| AttributeValues{"foo": json.RawMessage(`null`)}, | |
| }, | |
| { | |
| cty.ObjectVal(map[string]cty.Value{ | |
| "bar": cty.MapVal(map[string]cty.Value{ | |
| "hello": cty.StringVal("world"), | |
| }), | |
| "baz": cty.ListVal([]cty.Value{ | |
| cty.StringVal("goodnight"), | |
| cty.StringVal("moon"), | |
| }), | |
| }), | |
| &configschema.Block{ | |
| Attributes: map[string]*configschema.Attribute{ | |
| "bar": { | |
| Type: cty.Map(cty.String), | |
| Required: true, | |
| }, | |
| "baz": { | |
| Type: cty.List(cty.String), | |
| Optional: true, | |
| }, | |
| }, | |
| }, | |
| AttributeValues{ | |
| "bar": json.RawMessage(`{"hello":"world"}`), | |
| "baz": json.RawMessage(`["goodnight","moon"]`), | |
| }, | |
| }, | |
| } | |
| for _, test := range tests { | |
| got := marshalAttributeValues(test.Attr, test.Schema) | |
| eq := reflect.DeepEqual(got, test.Want) | |
| if !eq { | |
| t.Fatalf("wrong result:\nGot: %#v\nWant: %#v\n", got, test.Want) | |
| } | |
| } | |
| } | |
| func TestMarshalPlannedOutputs(t *testing.T) { | |
| after, _ := plans.NewDynamicValue(cty.StringVal("after"), cty.DynamicPseudoType) | |
| tests := []struct { | |
| Changes *plans.Changes | |
| Want map[string]Output | |
| Err bool | |
| }{ | |
| { | |
| &plans.Changes{}, | |
| nil, | |
| false, | |
| }, | |
| { | |
| &plans.Changes{ | |
| Outputs: []*plans.OutputChangeSrc{ | |
| { | |
| Addr: addrs.OutputValue{Name: "bar"}.Absolute(addrs.RootModuleInstance), | |
| ChangeSrc: plans.ChangeSrc{ | |
| Action: plans.Create, | |
| After: after, | |
| }, | |
| Sensitive: false, | |
| }, | |
| }, | |
| }, | |
| map[string]Output{ | |
| "bar": { | |
| Sensitive: false, | |
| Type: json.RawMessage(`"string"`), | |
| Value: json.RawMessage(`"after"`), | |
| }, | |
| }, | |
| false, | |
| }, | |
| { // Delete action | |
| &plans.Changes{ | |
| Outputs: []*plans.OutputChangeSrc{ | |
| { | |
| Addr: addrs.OutputValue{Name: "bar"}.Absolute(addrs.RootModuleInstance), | |
| ChangeSrc: plans.ChangeSrc{ | |
| Action: plans.Delete, | |
| }, | |
| Sensitive: false, | |
| }, | |
| }, | |
| }, | |
| map[string]Output{}, | |
| false, | |
| }, | |
| } | |
| for _, test := range tests { | |
| got, err := marshalPlannedOutputs(test.Changes) | |
| if test.Err { | |
| if err == nil { | |
| t.Fatal("succeeded; want error") | |
| } | |
| return | |
| } else if err != nil { | |
| t.Fatalf("unexpected error: %s", err) | |
| } | |
| eq := reflect.DeepEqual(got, test.Want) | |
| if !eq { | |
| t.Fatalf("wrong result:\nGot: %#v\nWant: %#v\n", got, test.Want) | |
| } | |
| } | |
| } | |
| func TestMarshalPlanResources(t *testing.T) { | |
| tests := map[string]struct { | |
| Action plans.Action | |
| Before cty.Value | |
| After cty.Value | |
| Want []Resource | |
| Err bool | |
| }{ | |
| "create with unknowns": { | |
| Action: plans.Create, | |
| Before: cty.NullVal(cty.EmptyObject), | |
| After: cty.ObjectVal(map[string]cty.Value{ | |
| "woozles": cty.UnknownVal(cty.String), | |
| "foozles": cty.UnknownVal(cty.String), | |
| }), | |
| Want: []Resource{{ | |
| Address: "test_thing.example", | |
| Mode: "managed", | |
| Type: "test_thing", | |
| Name: "example", | |
| Index: addrs.InstanceKey(nil), | |
| ProviderName: "registry.opentofu.org/hashicorp/test", | |
| SchemaVersion: 1, | |
| AttributeValues: AttributeValues{}, | |
| SensitiveValues: json.RawMessage("{}"), | |
| }}, | |
| Err: false, | |
| }, | |
| "delete with null and nil": { | |
| Action: plans.Delete, | |
| Before: cty.NullVal(cty.EmptyObject), | |
| After: cty.NilVal, | |
| Want: nil, | |
| Err: false, | |
| }, | |
| "delete": { | |
| Action: plans.Delete, | |
| Before: cty.ObjectVal(map[string]cty.Value{ | |
| "woozles": cty.StringVal("foo"), | |
| "foozles": cty.StringVal("bar"), | |
| }), | |
| After: cty.NullVal(cty.Object(map[string]cty.Type{ | |
| "woozles": cty.String, | |
| "foozles": cty.String, | |
| })), | |
| Want: nil, | |
| Err: false, | |
| }, | |
| "update without unknowns": { | |
| Action: plans.Update, | |
| Before: cty.ObjectVal(map[string]cty.Value{ | |
| "woozles": cty.StringVal("foo"), | |
| "foozles": cty.StringVal("bar"), | |
| }), | |
| After: cty.ObjectVal(map[string]cty.Value{ | |
| "woozles": cty.StringVal("baz"), | |
| "foozles": cty.StringVal("bat"), | |
| }), | |
| Want: []Resource{{ | |
| Address: "test_thing.example", | |
| Mode: "managed", | |
| Type: "test_thing", | |
| Name: "example", | |
| Index: addrs.InstanceKey(nil), | |
| ProviderName: "registry.opentofu.org/hashicorp/test", | |
| SchemaVersion: 1, | |
| AttributeValues: AttributeValues{ | |
| "woozles": json.RawMessage(`"baz"`), | |
| "foozles": json.RawMessage(`"bat"`), | |
| }, | |
| SensitiveValues: json.RawMessage("{}"), | |
| }}, | |
| Err: false, | |
| }, | |
| } | |
| for name, test := range tests { | |
| t.Run(name, func(t *testing.T) { | |
| before, err := plans.NewDynamicValue(test.Before, test.Before.Type()) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| after, err := plans.NewDynamicValue(test.After, test.After.Type()) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| testChange := &plans.Changes{ | |
| Resources: []*plans.ResourceInstanceChangeSrc{ | |
| { | |
| Addr: addrs.Resource{ | |
| Mode: addrs.ManagedResourceMode, | |
| Type: "test_thing", | |
| Name: "example", | |
| }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), | |
| ProviderAddr: addrs.AbsProviderConfig{ | |
| Provider: addrs.NewDefaultProvider("test"), | |
| Module: addrs.RootModule, | |
| }, | |
| ChangeSrc: plans.ChangeSrc{ | |
| Action: test.Action, | |
| Before: before, | |
| After: after, | |
| }, | |
| }, | |
| }, | |
| } | |
| ris := testResourceAddrs() | |
| got, err := marshalPlanResources(testChanges(testChange), ris, testSchemas()) | |
| if test.Err { | |
| if err == nil { | |
| t.Fatal("succeeded; want error") | |
| } | |
| return | |
| } else if err != nil { | |
| t.Fatalf("unexpected error: %s", err) | |
| } | |
| eq := reflect.DeepEqual(got, test.Want) | |
| if !eq { | |
| t.Fatalf("wrong result:\nGot: %#v\nWant: %#v\n", got, test.Want) | |
| } | |
| }) | |
| } | |
| // Ephemeral resources are not allowed in the plan | |
| t.Run("ephemeral denied", func(t *testing.T) { | |
| resAddr := mustAddr("ephemeral.foo_res.bar_name") | |
| changeMap := map[string]*plans.ResourceInstanceChangeSrc{ | |
| "ephemeral.foo_res.bar_name": { | |
| Addr: resAddr, | |
| PrevRunAddr: resAddr, | |
| ProviderAddr: addrs.AbsProviderConfig{ | |
| Provider: addrs.Provider{ | |
| Type: "foo", | |
| Namespace: "bar", | |
| }, | |
| }, | |
| }, | |
| } | |
| r, err := marshalPlanResources(changeMap, []addrs.AbsResourceInstance{resAddr}, nil) | |
| if len(r) != 0 { | |
| t.Errorf("expected to have no resources generated but got %d: %+v", len(r), r) | |
| } | |
| if err == nil { | |
| t.Fatalf("expected error but got nil") | |
| } | |
| gotErr := err.Error() | |
| const wantErr = `ephemeral resource "ephemeral.foo_res.bar_name" detected in the plan. This is an error in OpenTofu` | |
| if wantErr != gotErr { | |
| t.Errorf("invalid error received. wanted:\n\t%s\ngot:\n\t%s", wantErr, gotErr) | |
| } | |
| }) | |
| } | |
| func TestMarshalPlanValuesNoopDeposed(t *testing.T) { | |
| dynamicNull, err := plans.NewDynamicValue(cty.NullVal(cty.DynamicPseudoType), cty.DynamicPseudoType) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| testChange := &plans.Changes{ | |
| Resources: []*plans.ResourceInstanceChangeSrc{ | |
| { | |
| Addr: addrs.Resource{ | |
| Mode: addrs.ManagedResourceMode, | |
| Type: "test_thing", | |
| Name: "example", | |
| }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), | |
| DeposedKey: "12345678", | |
| ProviderAddr: addrs.AbsProviderConfig{ | |
| Provider: addrs.NewDefaultProvider("test"), | |
| Module: addrs.RootModule, | |
| }, | |
| ChangeSrc: plans.ChangeSrc{ | |
| Action: plans.NoOp, | |
| Before: dynamicNull, | |
| After: dynamicNull, | |
| }, | |
| }, | |
| }, | |
| } | |
| _, err = marshalPlannedValues(testChange, testSchemas()) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| } | |
| func testSchemas() *tofu.Schemas { | |
| return &tofu.Schemas{ | |
| Providers: map[addrs.Provider]providers.ProviderSchema{ | |
| addrs.NewDefaultProvider("test"): providers.ProviderSchema{ | |
| ResourceTypes: map[string]providers.Schema{ | |
| "test_thing": { | |
| Version: 1, | |
| Block: &configschema.Block{ | |
| Attributes: map[string]*configschema.Attribute{ | |
| "woozles": {Type: cty.String, Optional: true, Computed: true}, | |
| "foozles": {Type: cty.String, Optional: true}, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| } | |
| } | |
| func testChanges(changes *plans.Changes) map[string]*plans.ResourceInstanceChangeSrc { | |
| ret := make(map[string]*plans.ResourceInstanceChangeSrc) | |
| for _, resource := range changes.Resources { | |
| if resource.DeposedKey == states.NotDeposed { | |
| ret[resource.Addr.String()] = resource | |
| } | |
| } | |
| return ret | |
| } | |
| func testResourceAddrs() []addrs.AbsResourceInstance { | |
| return []addrs.AbsResourceInstance{ | |
| mustAddr("test_thing.example"), | |
| } | |
| } | |
| func mustAddr(str string) addrs.AbsResourceInstance { | |
| addr, diags := addrs.ParseAbsResourceInstanceStr(str) | |
| if diags.HasErrors() { | |
| panic(diags.Err()) | |
| } | |
| return addr | |
| } |