From 7aa41da657b39cb1791d936bd53d6fe06dc3d52c Mon Sep 17 00:00:00 2001 From: Anton Tayanovskyy Date: Fri, 12 Apr 2024 15:20:30 -0400 Subject: [PATCH] Rapid generator for schema-value pairs (#1801) Fixes https://github.com/pulumi/pulumi-terraform-bridge/issues/1790 by building a rapid generator for schemas and associated values. Large-ish problem 1: I do not have it figured out how to test unknown values. TF literals as unknown values are forbidden and do not make sense. We might need a helper resource so that testing unknown values generates references to an output of the helper resource. This is logged for future work. Large-ish problem 2: iteration is pretty slow (x-proc). Normal n=100 rapid tests can take up to 10min. Could try batching so several resources are tried in one shot say 100 resources. Large-ish problem 3: I'm not sure if no-op Update and Create implementations are acceptable. There is something to testing Computed attributes where provider has to set values. Possibly Update also needs to set values? Possibly not. Small problems: - [x] Using TF JSON syntax didn't handle null/empty correctly; that is now discarded, using actual HCL syntax - [x] TF representations are difficult to visualize in failing tests and difficult to assert against - [x] Lots of lost-in-translation papercuts possible between representations (cty.Value, resource.PropertyValue, tftypes.Value) - [x] this requires a change to providertest to abstract from testing.T so we can pass rapid.T - [x] it's very hard to disable annoying TF logging, using env vars for now We are starting to find bugs and discrepancies from this work: - https://github.com/pulumi/pulumi-terraform-bridge/issues/1856 panic corner-case - https://github.com/pulumi/pulumi-terraform-bridge/issues/1852 need to InternalValidate - https://github.com/pulumi/pulumi-terraform-bridge/issues/1828 Future work: - #1856 - #1857 - #1858 - #1859 - #1860 - #1861 - #1862 - #1863 - #1864 - #1865 - #1866 - #1867 --- pkg/tests/cross-tests/adapt.go | 201 +++++++++++ pkg/tests/cross-tests/ci.go | 28 ++ pkg/tests/cross-tests/cross_test.go | 502 ++++---------------------- pkg/tests/cross-tests/diff_check.go | 126 +++++++ pkg/tests/cross-tests/exec.go | 39 ++ pkg/tests/cross-tests/pretty.go | 246 +++++++++++++ pkg/tests/cross-tests/pretty_test.go | 92 +++++ pkg/tests/cross-tests/pu_driver.go | 186 ++++++++++ pkg/tests/cross-tests/rapid_test.go | 80 ++++ pkg/tests/cross-tests/rapid_tv_gen.go | 421 +++++++++++++++++++++ pkg/tests/cross-tests/t.go | 30 ++ pkg/tests/cross-tests/tf_driver.go | 230 ++++++++++++ pkg/tests/cross-tests/tfwrite.go | 75 ++++ pkg/tests/cross-tests/tfwrite_test.go | 182 ++++++++++ pkg/tests/go.mod | 14 +- pkg/tests/go.sum | 13 +- pkg/tfshim/sdk-v2/cty.go | 4 +- 17 files changed, 2029 insertions(+), 440 deletions(-) create mode 100644 pkg/tests/cross-tests/adapt.go create mode 100644 pkg/tests/cross-tests/ci.go create mode 100644 pkg/tests/cross-tests/diff_check.go create mode 100644 pkg/tests/cross-tests/exec.go create mode 100644 pkg/tests/cross-tests/pretty.go create mode 100644 pkg/tests/cross-tests/pretty_test.go create mode 100644 pkg/tests/cross-tests/pu_driver.go create mode 100644 pkg/tests/cross-tests/rapid_test.go create mode 100644 pkg/tests/cross-tests/rapid_tv_gen.go create mode 100644 pkg/tests/cross-tests/t.go create mode 100644 pkg/tests/cross-tests/tf_driver.go create mode 100644 pkg/tests/cross-tests/tfwrite.go create mode 100644 pkg/tests/cross-tests/tfwrite_test.go diff --git a/pkg/tests/cross-tests/adapt.go b/pkg/tests/cross-tests/adapt.go new file mode 100644 index 000000000..e7ca74ded --- /dev/null +++ b/pkg/tests/cross-tests/adapt.go @@ -0,0 +1,201 @@ +// Copyright 2016-2024, 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. + +// Adapters for converting morally equivalent typed representations of TF values for integrating with all the libraries +// cross-testing is using. +package crosstests + +import ( + "math/big" + + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" + "github.com/zclconf/go-cty/cty" +) + +type typeAdapter struct { + typ tftypes.Type +} + +func (ta *typeAdapter) ToCty() cty.Type { + t := ta.typ + switch { + case t.Is(tftypes.String): + return cty.String + case t.Is(tftypes.Number): + return cty.Number + case t.Is(tftypes.Bool): + return cty.Bool + case t.Is(tftypes.List{}): + return cty.List(fromType(t.(tftypes.List).ElementType).ToCty()) + case t.Is(tftypes.Set{}): + return cty.Set(fromType(t.(tftypes.Set).ElementType).ToCty()) + case t.Is(tftypes.Map{}): + return cty.Map(fromType(t.(tftypes.Map).ElementType).ToCty()) + case t.Is(tftypes.Object{}): + fields := map[string]cty.Type{} + for k, v := range t.(tftypes.Object).AttributeTypes { + fields[k] = fromType(v).ToCty() + } + return cty.Object(fields) + default: + contract.Failf("unexpected type %v", t) + return cty.NilType + } +} + +func (ta *typeAdapter) NewValue(value any) tftypes.Value { + t := ta.typ + if value == nil { + return tftypes.NewValue(t, nil) + } + switch t := value.(type) { + case tftypes.Value: + return t + case *tftypes.Value: + return *t + } + switch { + case t.Is(tftypes.List{}): + elT := t.(tftypes.List).ElementType + switch v := value.(type) { + case []any: + values := []tftypes.Value{} + for _, el := range v { + values = append(values, fromType(elT).NewValue(el)) + } + return tftypes.NewValue(t, values) + } + case t.Is(tftypes.Set{}): + elT := t.(tftypes.Set).ElementType + switch v := value.(type) { + case []any: + values := []tftypes.Value{} + for _, el := range v { + values = append(values, fromType(elT).NewValue(el)) + } + return tftypes.NewValue(t, values) + } + case t.Is(tftypes.Map{}): + elT := t.(tftypes.Map).ElementType + switch v := value.(type) { + case map[string]any: + values := map[string]tftypes.Value{} + for k, el := range v { + values[k] = fromType(elT).NewValue(el) + } + return tftypes.NewValue(t, values) + } + case t.Is(tftypes.Object{}): + aT := t.(tftypes.Object).AttributeTypes + switch v := value.(type) { + case map[string]any: + values := map[string]tftypes.Value{} + for k, el := range v { + values[k] = fromType(aT[k]).NewValue(el) + } + return tftypes.NewValue(t, values) + } + } + return tftypes.NewValue(t, value) +} + +func fromType(t tftypes.Type) *typeAdapter { + return &typeAdapter{t} +} + +type valueAdapter struct { + value tftypes.Value +} + +func (va *valueAdapter) ToCty() cty.Value { + v := va.value + t := v.Type() + switch { + case v.IsNull(): + return cty.NullVal(fromType(t).ToCty()) + case !v.IsKnown(): + return cty.UnknownVal(fromType(t).ToCty()) + case t.Is(tftypes.String): + var s string + err := v.As(&s) + contract.AssertNoErrorf(err, "unexpected error converting string") + return cty.StringVal(s) + case t.Is(tftypes.Number): + var n *big.Float + err := v.As(&n) + contract.AssertNoErrorf(err, "unexpected error converting number") + return cty.NumberVal(n) + case t.Is(tftypes.Bool): + var b bool + err := v.As(&b) + contract.AssertNoErrorf(err, "unexpected error converting bool") + return cty.BoolVal(b) + case t.Is(tftypes.List{}): + var vals []tftypes.Value + err := v.As(&vals) + contract.AssertNoErrorf(err, "unexpected error converting list") + if len(vals) == 0 { + return cty.ListValEmpty(fromType(t).ToCty()) + } + outVals := make([]cty.Value, len(vals)) + for i, el := range vals { + outVals[i] = fromValue(el).ToCty() + } + return cty.ListVal(outVals) + case t.Is(tftypes.Set{}): + var vals []tftypes.Value + err := v.As(&vals) + if len(vals) == 0 { + return cty.SetValEmpty(fromType(t).ToCty()) + } + contract.AssertNoErrorf(err, "unexpected error converting set") + outVals := make([]cty.Value, len(vals)) + for i, el := range vals { + outVals[i] = fromValue(el).ToCty() + } + return cty.SetVal(outVals) + case t.Is(tftypes.Map{}): + var vals map[string]tftypes.Value + err := v.As(&vals) + if len(vals) == 0 { + return cty.MapValEmpty(fromType(t).ToCty()) + } + contract.AssertNoErrorf(err, "unexpected error converting map") + outVals := make(map[string]cty.Value, len(vals)) + for k, el := range vals { + outVals[k] = fromValue(el).ToCty() + } + return cty.MapVal(outVals) + case t.Is(tftypes.Object{}): + var vals map[string]tftypes.Value + err := v.As(&vals) + if len(vals) == 0 { + return cty.EmptyObjectVal + } + contract.AssertNoErrorf(err, "unexpected error converting object") + outVals := make(map[string]cty.Value, len(vals)) + for k, el := range vals { + outVals[k] = fromValue(el).ToCty() + } + return cty.ObjectVal(outVals) + default: + contract.Failf("unexpected type %v", t) + return cty.NilVal + } +} + +func fromValue(v tftypes.Value) *valueAdapter { + return &valueAdapter{v} +} diff --git a/pkg/tests/cross-tests/ci.go b/pkg/tests/cross-tests/ci.go new file mode 100644 index 000000000..2565e8cfa --- /dev/null +++ b/pkg/tests/cross-tests/ci.go @@ -0,0 +1,28 @@ +// Copyright 2016-2024, 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 + +// Helpers to disable failing CI runs. +package crosstests + +import ( + "os" + "runtime" + "strings" + "testing" +) + +func skipUnlessLinux(t *testing.T) { + if ci, ok := os.LookupEnv("CI"); ok && ci == "true" && !strings.Contains(strings.ToLower(runtime.GOOS), "linux") { + t.Skip("Skipping on non-Linux platforms as our CI does not yet install Terraform CLI required for these tests") + } +} diff --git a/pkg/tests/cross-tests/cross_test.go b/pkg/tests/cross-tests/cross_test.go index 55f2ceea5..a7f6bae46 100644 --- a/pkg/tests/cross-tests/cross_test.go +++ b/pkg/tests/cross-tests/cross_test.go @@ -1,281 +1,70 @@ +// Copyright 2016-2024, 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 + +// Various test cases for comparing bridged provider behavior against the equivalent TF provider. package crosstests import ( "bytes" "context" - "encoding/json" "fmt" "hash/crc32" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" "slices" "strings" "testing" - "github.com/hashicorp/go-plugin" - "github.com/hashicorp/terraform-plugin-go/tfprotov5" - "github.com/hashicorp/terraform-plugin-go/tfprotov5/tf5server" - "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/pulumi/pulumi/sdk/v3/go/auto" - "github.com/pulumi/pulumi/sdk/v3/go/common/apitype" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "google.golang.org/grpc" - "gopkg.in/yaml.v3" - - "github.com/pulumi/providertest/providers" - "github.com/pulumi/providertest/pulumitest" - "github.com/pulumi/providertest/pulumitest/opttest" - "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/convert" - "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfbridge" - "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfgen" - shim "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim" - sdkv2 "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim/sdk-v2" - shimv2 "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim/sdk-v2" - pulumidiag "github.com/pulumi/pulumi/sdk/v3/go/common/diag" - "github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors" - "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" - "github.com/pulumi/pulumi/sdk/v3/go/common/util/rpcutil" - pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go" ) -type diffTestCase struct { - // Schema for the resource to test diffing on. - Resource *schema.Resource - - // Two resource configurations. The representation assumes JSON Configuration Syntax - // accepted by TF, that is, these values when serialized with JSON should parse as .tf.json - // files. If Config1 is nil, assume a Create flow. If Config2 is nil, assume a Delete flow. - // Otherwise assume an Update flow for a resource. - // - // See https://developer.hashicorp.com/terraform/language/syntax/json - Config1, Config2 any - - // Bypass interacting with the bridged Pulumi provider. - SkipPulumi bool -} - -const ( - providerShortName = "crossprovider" - rtype = "crossprovider_testres" - rtok = "TestRes" - rtoken = providerShortName + ":index:" + rtok - providerName = "registry.terraform.io/hashicorp/" + providerShortName - providerVer = "0.0.1" -) - -func runDiffCheck(t *testing.T, tc diffTestCase) { - // ctx := context.Background() - tfwd := t.TempDir() - - reattachConfig := startTFProvider(t, tc) - - tfWriteJSON(t, tfwd, tc.Config1) - p1 := runTFPlan(t, tfwd, reattachConfig) - runTFApply(t, tfwd, reattachConfig, p1) - - tfWriteJSON(t, tfwd, tc.Config2) - p2 := runTFPlan(t, tfwd, reattachConfig) - runTFApply(t, tfwd, reattachConfig, p2) - - { - planBytes, err := json.MarshalIndent(p2.RawPlan, "", " ") - contract.AssertNoErrorf(err, "failed to marshal terraform plan") - t.Logf("TF plan: %v", string(planBytes)) - } - - if tc.SkipPulumi { - return - } - - puwd := t.TempDir() - pulumiWriteYaml(t, tc, puwd, tc.Config1) - - pt := pulumitest.NewPulumiTest(t, puwd, - // Needed while using Nix-built pulumi. - opttest.Env("PULUMI_AUTOMATION_API_SKIP_VERSION_CHECK", "true"), - opttest.TestInPlace(), - opttest.SkipInstall(), - opttest.AttachProvider( - providerShortName, - func(ctx context.Context, pt providers.PulumiTest) (providers.Port, error) { - handle, err := startPulumiProvider(ctx, tc) - require.NoError(t, err) - return providers.Port(handle.Port), nil - }, - ), - ) - - pt.Up() - - pulumiWriteYaml(t, tc, puwd, tc.Config2) - x := pt.Up() - - verifyBasicDiffAgreement(t, p2, x.Summary) -} - -func tfWriteJSON(t *testing.T, cwd string, rconfig any) { - config := map[string]any{ - "resource": map[string]any{ - rtype: map[string]any{ - "example": rconfig, - }, - }, - } - config1bytes, err := json.MarshalIndent(config, "", " ") - require.NoErrorf(t, err, "serializing test.tf.json") - err = os.WriteFile(filepath.Join(cwd, "test.tf.json"), config1bytes, 0600) - require.NoErrorf(t, err, "writing test.tf.json") -} - -type tfPlan struct { - PlanFile string - RawPlan any -} - -func (*tfPlan) OpType() *apitype.OpType { - return nil -} - -func runTFPlan(t *testing.T, cwd string, reattachConfig *plugin.ReattachConfig) tfPlan { - planFile := filepath.Join(cwd, "test.tfplan") - env := []string{formatReattachEnvVar(providerName, reattachConfig)} - execCmd(t, cwd, env, "terraform", "plan", "-refresh=false", "-out", planFile) - - cmd := execCmd(t, cwd, env, "terraform", "show", "-json", planFile) - tp := tfPlan{PlanFile: planFile} - err := json.Unmarshal(cmd.Stdout.(*bytes.Buffer).Bytes(), &tp.RawPlan) - contract.AssertNoErrorf(err, "failed to unmarshal terraform plan") - return tp -} - -func runTFApply(t *testing.T, cwd string, reattachConfig *plugin.ReattachConfig, p tfPlan) { - execCmd(t, cwd, []string{formatReattachEnvVar(providerName, reattachConfig)}, - "terraform", "apply", "-auto-approve", "-refresh=false", p.PlanFile) -} - -func toTFProvider(tc diffTestCase) *schema.Provider { - return &schema.Provider{ - ResourcesMap: map[string]*schema.Resource{ - rtype: tc.Resource, - }, - } -} - -func startTFProvider(t *testing.T, tc diffTestCase) *plugin.ReattachConfig { - tc.Resource.CustomizeDiff = func( - ctx context.Context, rd *schema.ResourceDiff, i interface{}, - ) error { - // fmt.Printf(`\n\n CustomizeDiff: rd.Get("set") ==> %#v\n\n\n`, rd.Get("set")) - // fmt.Println("\n\nGetRawPlan: ", rd.GetRawPlan().GoString()) - // fmt.Println("\n\nGetRawConfig: ", rd.GetRawConfig().GoString()) - // fmt.Println("\n\nGetRawState: ", rd.GetRawState().GoString()) - return nil - } - - if tc.Resource.DeleteContext == nil { - tc.Resource.DeleteContext = func( - ctx context.Context, rd *schema.ResourceData, i interface{}, - ) diag.Diagnostics { - return diag.Diagnostics{} - } - } - - if tc.Resource.CreateContext == nil { - tc.Resource.CreateContext = func( - ctx context.Context, rd *schema.ResourceData, i interface{}, - ) diag.Diagnostics { - rd.SetId("newid") - return diag.Diagnostics{} - } - } - - tc.Resource.UpdateContext = func( - ctx context.Context, rd *schema.ResourceData, i interface{}, - ) diag.Diagnostics { - //fmt.Printf(`\n\n Update: rd.Get("set") ==> %#v\n\n\n`, rd.Get("set")) - return diag.Diagnostics{} - } - - p := toTFProvider(tc) - - serverFactory := func() tfprotov5.ProviderServer { - return p.GRPCProvider() - } - - ctx := context.Background() - - reattachConfigCh := make(chan *plugin.ReattachConfig) - closeCh := make(chan struct{}) - - serveOpts := []tf5server.ServeOpt{ - tf5server.WithDebug(ctx, reattachConfigCh, closeCh), - tf5server.WithLoggingSink(t), - } - - go func() { - err := tf5server.Serve(providerName, serverFactory, serveOpts...) - require.NoError(t, err) - }() - - reattachConfig := <-reattachConfigCh - return reattachConfig -} - -func formatReattachEnvVar(name string, pluginReattachConfig *plugin.ReattachConfig) string { - type reattachConfigAddr struct { - Network string - String string - } - - type reattachConfig struct { - Protocol string - ProtocolVersion int - Pid int - Test bool - Addr reattachConfigAddr - } - - reattachBytes, err := json.Marshal(map[string]reattachConfig{ - name: { - Protocol: string(pluginReattachConfig.Protocol), - ProtocolVersion: pluginReattachConfig.ProtocolVersion, - Pid: pluginReattachConfig.Pid, - Test: pluginReattachConfig.Test, - Addr: reattachConfigAddr{ - Network: pluginReattachConfig.Addr.Network(), - String: pluginReattachConfig.Addr.String(), +func TestUnchangedBasicObject(t *testing.T) { + skipUnlessLinux(t) + cfg := map[string]any{"f0": []any{map[string]any{"x": "ok"}}} + runDiffCheck(t, diffTestCase{ + Resource: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "f0": { + Required: true, + Type: schema.TypeList, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "x": {Optional: true, Type: schema.TypeString}, + }, + }, + }, }, }, + Config1: cfg, + Config2: cfg, }) - - contract.AssertNoErrorf(err, "failed to build TF_REATTACH_PROVIDERS string") - return fmt.Sprintf("TF_REATTACH_PROVIDERS=%s", string(reattachBytes)) } func TestSimpleStringNoChange(t *testing.T) { skipUnlessLinux(t) + config := map[string]any{"name": "A"} runDiffCheck(t, diffTestCase{ Resource: &schema.Resource{ Schema: map[string]*schema.Schema{ "name": { - Type: schema.TypeString, Optional: true, }, }, }, - Config1: map[string]any{ - "name": "A", - }, - Config2: map[string]any{ - "name": "A", - }, + Config1: config, + Config2: config, }) } @@ -312,22 +101,47 @@ func TestSetReordering(t *testing.T) { }, }, }, - CreateContext: func( - ctx context.Context, rd *schema.ResourceData, i interface{}, - ) diag.Diagnostics { - rd.SetId("newid") - require.IsType(t, &schema.Set{}, rd.Get("set")) - return diag.Diagnostics{} - }, } runDiffCheck(t, diffTestCase{ Resource: resource, Config1: map[string]any{ - "set": []string{"A", "B"}, + "set": []any{"A", "B"}, }, Config2: map[string]any{ - "set": []string{"B", "A"}, + "set": []any{"B", "A"}, + }, + }) +} + +// If a list-nested block has Required set, it cannot be empty. TF emits an error. Pulumi currently panics. +// +// │ Error: Insufficient f0 blocks +// │ +// │ on test.tf line 1, in resource "crossprovider_testres" "example": +// │ 1: resource "crossprovider_testres" "example" { +func TestEmptyRequiredList(t *testing.T) { + t.Skip("TODO - fix panic and make a negative test here") + skipUnlessLinux(t) + resource := &schema.Resource{ + Schema: map[string]*schema.Schema{ + "f0": { + Type: schema.TypeList, + Required: true, + Elem: &schema.Resource{Schema: map[string]*schema.Schema{ + "f0": { + Type: schema.TypeString, + Required: true, + Sensitive: true, + }, + }}, + }, }, + } + + runDiffCheck(t, diffTestCase{ + Resource: resource, + Config1: map[string]any{"f0": []any{}}, + Config2: map[string]any{"f0": []any{}}, }) } @@ -405,7 +219,6 @@ func TestAws2442(t *testing.T) { // Now intentionally reorder parameters away from the canonical order. err := rd.Set("parameter", parameterList[0:3]) require.NoError(t, err) - fmt.Println("CREATE! set to 3") return make(diag.Diagnostics, 0) }, // UpdateContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics { @@ -562,8 +375,8 @@ func TestAws2442(t *testing.T) { }, } - jsonifyParameters := func(parameters []parameter) []map[string]interface{} { - var result []map[string]interface{} + jsonifyParameters := func(parameters []parameter) []interface{} { + var result []interface{} for _, p := range parameters { result = append(result, map[string]interface{}{ "name": p.name, @@ -590,176 +403,3 @@ func TestAws2442(t *testing.T) { Config2: cfg2, }) } - -func toPulumiProvider(tc diffTestCase) tfbridge.ProviderInfo { - return tfbridge.ProviderInfo{ - Name: providerShortName, - - P: shimv2.NewProvider(toTFProvider(tc), shimv2.WithPlanResourceChange( - func(tfResourceType string) bool { return true }, - )), - - Resources: map[string]*tfbridge.ResourceInfo{ - rtype: { - Tok: rtoken, - }, - }, - } -} - -func startPulumiProvider( - ctx context.Context, - tc diffTestCase, -) (*rpcutil.ServeHandle, error) { - info := toPulumiProvider(tc) - - sink := pulumidiag.DefaultSink(io.Discard, io.Discard, pulumidiag.FormatOptions{ - Color: colors.Never, - }) - - schema, err := tfgen.GenerateSchema(info, sink) - if err != nil { - return nil, fmt.Errorf("tfgen.GenerateSchema failed: %w", err) - } - - schemaBytes, err := json.MarshalIndent(schema, "", " ") - if err != nil { - return nil, fmt.Errorf("json.MarshalIndent(schema, ..) failed: %w", err) - } - - prov := tfbridge.NewProvider(ctx, nil, providerShortName, providerVer, info.P, info, schemaBytes) - - handle, err := rpcutil.ServeWithOptions(rpcutil.ServeOptions{ - Init: func(srv *grpc.Server) error { - pulumirpc.RegisterResourceProviderServer(srv, prov) - return nil - }, - }) - if err != nil { - return nil, fmt.Errorf("rpcutil.ServeWithOptions failed: %w", err) - } - - return &handle, nil -} - -func pulumiWriteYaml(t *testing.T, tc diffTestCase, puwd string, tfConfig any) { - schema := sdkv2.NewResource(tc.Resource).Schema() - pConfig, err := convertConfigToPulumi(schema, nil, tfConfig) - require.NoErrorf(t, err, "convertConfigToPulumi failed") - data := map[string]any{ - "name": "project", - "runtime": "yaml", - "resources": map[string]any{ - "example": map[string]any{ - "type": fmt.Sprintf("%s:index:%s", providerShortName, rtok), - "properties": pConfig, - }, - }, - "backend": map[string]any{ - "url": "file://./data", - }, - } - b, err := yaml.Marshal(data) - require.NoErrorf(t, err, "marshaling Pulumi.yaml") - p := filepath.Join(puwd, "Pulumi.yaml") - err = os.WriteFile(p, b, 0600) - require.NoErrorf(t, err, "writing Pulumi.yaml") -} - -func execCmd(t *testing.T, wdir string, environ []string, program string, args ...string) *exec.Cmd { - t.Logf("%s %s", program, strings.Join(args, " ")) - cmd := exec.Command(program, args...) - var stdout, stderr bytes.Buffer - cmd.Dir = wdir - cmd.Env = os.Environ() - cmd.Env = append(cmd.Env, environ...) - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err := cmd.Run() - require.NoError(t, err, "error from `%s %s`\n\nStdout:\n%s\n\nStderr:\n%s\n\n", - program, strings.Join(args, " "), stdout.String(), stderr.String()) - return cmd -} - -func convertConfigToPulumi( - schemaMap shim.SchemaMap, - schemaInfos map[string]*tfbridge.SchemaInfo, - tfConfig any, -) (any, error) { - objectType := convert.InferObjectType(schemaMap, nil) - bytes, err := json.Marshal(tfConfig) - if err != nil { - return nil, err - } - // Knowingly using a deprecated function so we can connect back up to tftypes.Value; if this disappears it - // should not be prohibitively difficult to rewrite or vendor. - // - //nolint:staticcheck - v, err := tftypes.ValueFromJSON(bytes, objectType) - if err != nil { - return nil, err - } - decoder, err := convert.NewObjectDecoder(convert.ObjectSchema{ - SchemaMap: schemaMap, - SchemaInfos: schemaInfos, - Object: &objectType, - }) - if err != nil { - return nil, err - } - pm, err := convert.DecodePropertyMap(decoder, v) - if err != nil { - return nil, err - } - return pm.Mappable(), nil -} - -// Still discovering the structure of JSON-serialized TF plans. The information required from these is, primarily, is -// whether the resource is staying unchanged, being updated or replaced. Secondarily, would be also great to know -// detailed paths of properties causing the change, though that is more difficult to cross-compare with Pulumi. -// -// For now this is code is similar to `jq .resource_changes[0].change.actions[0] plan.json`. -func parseChangesFromTFPlan(plan tfPlan) string { - type p struct { - ResourceChanges []struct { - Change struct { - Actions []string `json:"actions"` - } `json:"change"` - } `json:"resource_changes"` - } - jb, err := json.Marshal(plan.RawPlan) - contract.AssertNoErrorf(err, "failed to marshal terraform plan") - var pp p - err = json.Unmarshal(jb, &pp) - contract.AssertNoErrorf(err, "failed to unmarshal terraform plan") - contract.Assertf(len(pp.ResourceChanges) == 1, "expected exactly one resource change") - actions := pp.ResourceChanges[0].Change.Actions - contract.Assertf(len(actions) == 1, "expected exactly one action") - return actions[0] -} - -func verifyBasicDiffAgreement(t *testing.T, plan tfPlan, us auto.UpdateSummary) { - t.Logf("UpdateSummary.ResourceChanges: %#v", us.ResourceChanges) - tfAction := parseChangesFromTFPlan(plan) - switch tfAction { - case "update": - require.NotNilf(t, us.ResourceChanges, "UpdateSummary.ResourceChanges should not be nil") - rc := *us.ResourceChanges - assert.Equalf(t, 1, rc[string(apitype.OpSame)], "expected one resource to stay the same - the stack") - assert.Equalf(t, 1, rc[string(apitype.Update)], "expected the test resource to get an update plan") - assert.Equalf(t, 2, len(rc), "expected two entries in UpdateSummary.ResourceChanges") - case "no-op": - require.NotNilf(t, us.ResourceChanges, "UpdateSummary.ResourceChanges should not be nil") - rc := *us.ResourceChanges - assert.Equalf(t, 2, rc[string(apitype.OpSame)], "expected the test resource and the stack to stay the same") - assert.Equalf(t, 1, len(rc), "expected one entry in UpdateSummary.ResourceChanges") - default: - panic("TODO: do not understand this TF action yet: " + tfAction) - } -} - -func skipUnlessLinux(t *testing.T) { - if ci, ok := os.LookupEnv("CI"); ok && ci == "true" && !strings.Contains(strings.ToLower(runtime.GOOS), "linux") { - t.Skip("Skipping on non-Linux platforms as our CI does not yet install Terraform CLI required for these tests") - } -} diff --git a/pkg/tests/cross-tests/diff_check.go b/pkg/tests/cross-tests/diff_check.go new file mode 100644 index 000000000..4adfd8ef3 --- /dev/null +++ b/pkg/tests/cross-tests/diff_check.go @@ -0,0 +1,126 @@ +// Copyright 2016-2024, 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 + +// Compares the effect of transitioning between two randomly sampled resource configurations. +package crosstests + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/pulumi/providertest/providers" + "github.com/pulumi/providertest/pulumitest" + "github.com/pulumi/providertest/pulumitest/opttest" + shimv2 "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim/sdk-v2" + "github.com/pulumi/pulumi/sdk/v3/go/auto" + "github.com/pulumi/pulumi/sdk/v3/go/common/apitype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type diffTestCase struct { + // Schema for the resource to test diffing on. + Resource *schema.Resource + + // Two resource configurations to simulate an Update from the desired state of Config1 to Config2. + // + // Currently they need to be non-nil, but it would make sense to also test Create and Delete flows, especially + // Create, since there is the non-obvious detail that TF still takes Create calls through the diff logic code + // including diff customization and PlanResource change. + // + // Prefer passing [tftypes.Value] representations. + Config1, Config2 any + + // Optional object type for the resource. If left nil will be inferred from Resource schema. + ObjectType *tftypes.Object +} + +func runDiffCheck(t T, tc diffTestCase) { + var ( + providerShortName = "crossprovider" + rtype = "crossprovider_testres" + rtok = "TestRes" + rtoken = providerShortName + ":index:" + rtok + providerVer = "0.0.1" + ) + + tfwd := t.TempDir() + + tfd := newTfDriver(t, tfwd, providerShortName, rtype, tc.Resource) + _ = tfd.writePlanApply(t, tc.Resource.Schema, rtype, "example", tc.Config1) + tfDiffPlan := tfd.writePlanApply(t, tc.Resource.Schema, rtype, "example", tc.Config2) + + tfp := &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + rtype: tc.Resource, + }, + } + + shimProvider := shimv2.NewProvider(tfp, shimv2.WithPlanResourceChange( + func(tfResourceType string) bool { return true }, + )) + + pd := &pulumiDriver{ + name: providerShortName, + version: providerVer, + shimProvider: shimProvider, + pulumiResourceToken: rtoken, + tfResourceName: rtype, + objectType: nil, + } + + puwd := t.TempDir() + pd.writeYAML(t, puwd, tc.Config1) + + pt := pulumitest.NewPulumiTest(t, puwd, + opttest.TestInPlace(), + opttest.SkipInstall(), + opttest.AttachProvider( + providerShortName, + func(ctx context.Context, pt providers.PulumiTest) (providers.Port, error) { + handle, err := pd.startPulumiProvider(ctx) + require.NoError(t, err) + return providers.Port(handle.Port), nil + }, + ), + ) + + pt.Up() + + pd.writeYAML(t, puwd, tc.Config2) + x := pt.Up() + + tfAction := tfd.parseChangesFromTFPlan(*tfDiffPlan) + + tc.verifyBasicDiffAgreement(t, tfAction, x.Summary) +} + +func (tc *diffTestCase) verifyBasicDiffAgreement(t T, tfAction string, us auto.UpdateSummary) { + t.Logf("UpdateSummary.ResourceChanges: %#v", us.ResourceChanges) + switch tfAction { + case "update": + require.NotNilf(t, us.ResourceChanges, "UpdateSummary.ResourceChanges should not be nil") + rc := *us.ResourceChanges + assert.Equalf(t, 1, rc[string(apitype.OpSame)], "expected one resource to stay the same - the stack") + assert.Equalf(t, 1, rc[string(apitype.Update)], "expected the test resource to get an update plan") + assert.Equalf(t, 2, len(rc), "expected two entries in UpdateSummary.ResourceChanges") + case "no-op": + require.NotNilf(t, us.ResourceChanges, "UpdateSummary.ResourceChanges should not be nil") + rc := *us.ResourceChanges + assert.Equalf(t, 2, rc[string(apitype.OpSame)], "expected the test resource and stack to stay the same") + assert.Equalf(t, 1, len(rc), "expected one entry in UpdateSummary.ResourceChanges") + default: + panic("TODO: do not understand this TF action yet: " + tfAction) + } +} diff --git a/pkg/tests/cross-tests/exec.go b/pkg/tests/cross-tests/exec.go new file mode 100644 index 000000000..15af65b44 --- /dev/null +++ b/pkg/tests/cross-tests/exec.go @@ -0,0 +1,39 @@ +// Copyright 2016-2024, 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 + +// Helpers to execute OS commands. +package crosstests + +import ( + "bytes" + "os" + "os/exec" + "strings" + + "github.com/stretchr/testify/require" +) + +func execCmd(t T, wdir string, environ []string, program string, args ...string) *exec.Cmd { + t.Logf("%s %s", program, strings.Join(args, " ")) + cmd := exec.Command(program, args...) + var stdout, stderr bytes.Buffer + cmd.Dir = wdir + cmd.Env = os.Environ() + cmd.Env = append(cmd.Env, environ...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + require.NoError(t, err, "error from `%s %s`\n\nStdout:\n%s\n\nStderr:\n%s\n\n", + program, strings.Join(args, " "), stdout.String(), stderr.String()) + return cmd +} diff --git a/pkg/tests/cross-tests/pretty.go b/pkg/tests/cross-tests/pretty.go new file mode 100644 index 000000000..2042c0597 --- /dev/null +++ b/pkg/tests/cross-tests/pretty.go @@ -0,0 +1,246 @@ +// Copyright 2016-2024, 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. + +// Define pretty-printers to make test output easier to interpret. +package crosstests + +import ( + "bytes" + "fmt" + "io" + "math/big" + "slices" + "sort" + "strings" + + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hexops/valast" + "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" +) + +// Write out schema values using [valast.String]. This may break down once we start testing callbacks, but works for +// simple schemas and makes it easier to read test printout. +type prettySchemaWrapper struct { + sch schema.Schema +} + +func (psw prettySchemaWrapper) GoString() string { + return valast.String(psw.sch) +} + +// Large printouts of tftypes.Value are very difficult to read when debugging the tests, especially because of all the +// extraneous type information printed. This wrapper is a work in progress to implement better pretty-printing. +type prettyValueWrapper struct { + inner tftypes.Value +} + +func newPrettyValueWrapper(v tftypes.Value) prettyValueWrapper { + return prettyValueWrapper{v} +} + +func (s prettyValueWrapper) Value() tftypes.Value { + return s.inner +} + +func (s prettyValueWrapper) GoString() string { + tp := newPrettyPrinterForTypes(s.inner) + + var buf bytes.Buffer + fmt.Fprintf(&buf, "\n") + + for _, oT := range tp.DeclaredObjectTypes() { + fmt.Fprintf(&buf, "%s := ", tp.TypeLiteral(oT)) + tp.ObjectTypeDefinition(&buf, oT) + fmt.Fprintf(&buf, "\n") + } + + var walk func(level int, v tftypes.Value) + + walk = func(level int, v tftypes.Value) { + tL := tp.TypeReferenceString(v.Type()) + indent := strings.Repeat(" ", level) + switch { + case v.Type().Is(tftypes.Object{}): + fmt.Fprintf(&buf, `tftypes.NewValue(%s, map[string]tftypes.Value{`, tL) + var elements map[string]tftypes.Value + err := v.As(&elements) + contract.AssertNoErrorf(err, "this cast should always succeed") + keys := []string{} + for k := range elements { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(&buf, "\n%s %q: ", indent, k) + walk(level+1, elements[k]) + fmt.Fprintf(&buf, ",") + } + fmt.Fprintf(&buf, "\n%s})", indent) + case v.Type().Is(tftypes.List{}): + fmt.Fprintf(&buf, `tftypes.NewValue(%s, []tftypes.Value{`, tL) + var els []tftypes.Value + err := v.As(&els) + contract.AssertNoErrorf(err, "this cast should always succeed") + for _, el := range els { + fmt.Fprintf(&buf, "\n. %s", indent) + walk(level+1, el) + fmt.Fprintf(&buf, ",") + } + fmt.Fprintf(&buf, "\n%s})", indent) + case v.Type().Is(tftypes.Number): + var n big.Float + err := v.As(&n) + contract.AssertNoErrorf(err, "this cast should always succeed") + fmt.Fprintf(&buf, "tftypes.NewValue(tftypes.Number, %v)", n.String()) + case v.Type().Is(tftypes.Bool): + var b bool + err := v.As(&b) + contract.AssertNoErrorf(err, "this cast should always succeed") + fmt.Fprintf(&buf, "tftypes.NewValue(tftypes.Bool, %v)", b) + case v.Type().Is(tftypes.String): + var s string + err := v.As(&s) + contract.AssertNoErrorf(err, "this cast should always succeed") + fmt.Fprintf(&buf, "tftypes.NewValue(tftypes.String, %q)", s) + default: + panic(fmt.Sprintf("not supported yet: %v", v.Type().String())) + } + } + + walk(0, s.inner) + + return buf.String() +} + +// Assist [prettyValueWrapper] to write out types nicely. +type prettyPrinterForTypes struct { + objectTypes []tftypes.Object +} + +func newPrettyPrinterForTypes(v tftypes.Value) prettyPrinterForTypes { + objectTypes := []tftypes.Object{} + + var visitTypes func(t tftypes.Type, vis func(tftypes.Type)) + visitTypes = func(t tftypes.Type, vis func(tftypes.Type)) { + vis(t) + switch { + case t.Is(tftypes.Object{}): + for _, v := range t.(tftypes.Object).AttributeTypes { + visitTypes(v, vis) + } + case t.Is(tftypes.List{}): + visitTypes(t.(tftypes.List).ElementType, vis) + case t.Is(tftypes.Map{}): + visitTypes(t.(tftypes.Map).ElementType, vis) + case t.Is(tftypes.Set{}): + visitTypes(t.(tftypes.Set).ElementType, vis) + case t.Is(tftypes.Tuple{}): + for _, et := range t.(tftypes.Tuple).ElementTypes { + visitTypes(et, vis) + } + default: + return + } + } + + addObjectType := func(t tftypes.Type) { + oT, ok := t.(tftypes.Object) + if !ok { + return + } + for _, alt := range objectTypes { + if alt.Equal(oT) { + return + } + } + objectTypes = append(objectTypes, oT) + } + + _ = tftypes.Walk(v, func(ap *tftypes.AttributePath, v tftypes.Value) (bool, error) { + visitTypes(v.Type(), addObjectType) + return true, nil + }) + + return prettyPrinterForTypes{objectTypes: objectTypes} +} + +func (pp prettyPrinterForTypes) DeclaredObjectTypes() []tftypes.Object { + copy := slices.Clone(pp.objectTypes) + slices.Reverse(copy) + return copy +} + +func (pp prettyPrinterForTypes) TypeLiteral(t tftypes.Object) string { + for i, alt := range pp.objectTypes { + if alt.Equal(t) { + return fmt.Sprintf("t%d", i) + } + } + contract.Failf("improper use of the type pretty-printer: %v", t.String()) + return "" +} + +func (pp prettyPrinterForTypes) ObjectTypeDefinition(w io.Writer, ty tftypes.Object) { + if len(ty.AttributeTypes) == 0 { + fmt.Fprintf(w, "tftypes.Object{}") + return + } + fmt.Fprintf(w, "tftypes.Object{AttributeTypes: map[string]tftypes.Type{") + keys := []string{} + for k := range ty.AttributeTypes { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + t := ty.AttributeTypes[k] + fmt.Fprintf(w, "\n %q: ", k) + pp.TypeReference(w, t) + fmt.Fprintf(w, ",") + } + fmt.Fprintf(w, "\n}}") +} + +func (pp prettyPrinterForTypes) TypeReferenceString(t tftypes.Type) string { + var buf bytes.Buffer + pp.TypeReference(&buf, t) + return buf.String() +} + +func (pp prettyPrinterForTypes) TypeReference(w io.Writer, t tftypes.Type) { + switch { + case t.Is(tftypes.Object{}): + fmt.Fprintf(w, "%s", pp.TypeLiteral(t.(tftypes.Object))) + case t.Is(tftypes.List{}): + fmt.Fprintf(w, "tftypes.List{ElementType: ") + pp.TypeReference(w, t.(tftypes.List).ElementType) + fmt.Fprintf(w, "}") + case t.Is(tftypes.Set{}): + fmt.Fprintf(w, "tftypes.Set{ElementType: ") + pp.TypeReference(w, t.(tftypes.Set).ElementType) + fmt.Fprintf(w, "}") + case t.Is(tftypes.Map{}): + fmt.Fprintf(w, "tftypes.Map{ElementType: ") + pp.TypeReference(w, t.(tftypes.Map).ElementType) + fmt.Fprintf(w, "}") + case t.Is(tftypes.String): + fmt.Fprintf(w, "tftypes.String") + case t.Is(tftypes.Number): + fmt.Fprintf(w, "tftypes.Number") + case t.Is(tftypes.Bool): + fmt.Fprintf(w, "tftypes.Bool") + default: + contract.Failf("Not supported yet") + } +} diff --git a/pkg/tests/cross-tests/pretty_test.go b/pkg/tests/cross-tests/pretty_test.go new file mode 100644 index 000000000..5662b643f --- /dev/null +++ b/pkg/tests/cross-tests/pretty_test.go @@ -0,0 +1,92 @@ +// Copyright 2016-2024, 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 crosstests + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hexops/autogold/v2" +) + +func TestPrettyPrint(t *testing.T) { + type testCase struct { + v tftypes.Value + e autogold.Value + } + + testCases := []testCase{ + { + tftypes.NewValue(tftypes.Number, 42), + autogold.Expect("\ntftypes.NewValue(tftypes.Number, 42)"), + }, + { + tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "f1": tftypes.Bool, + "f0": tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "n": tftypes.Number, + }, + }, + }, + }, + }, map[string]tftypes.Value{ + "f1": tftypes.NewValue(tftypes.Bool, true), + "f0": tftypes.NewValue(tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "n": tftypes.Number, + }, + }, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "n": tftypes.Number, + }, + }, map[string]tftypes.Value{ + "n": tftypes.NewValue(tftypes.Number, 42), + }), + }), + }), + autogold.Expect(` +t1 := tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "n": tftypes.Number, +}} +t0 := tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "f0": tftypes.List{ElementType: t1}, + "f1": tftypes.Bool, +}} +tftypes.NewValue(t0, map[string]tftypes.Value{ + "f0": tftypes.NewValue(tftypes.List{ElementType: t1}, []tftypes.Value{ +. tftypes.NewValue(t1, map[string]tftypes.Value{ + "n": tftypes.NewValue(tftypes.Number, 42), + }), + }), + "f1": tftypes.NewValue(tftypes.Bool, true), +})`), + }, + } + + for i, tc := range testCases { + tc := tc + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + pw := prettyValueWrapper{tc.v} + tc.e.Equal(t, pw.GoString()) + }) + } +} diff --git a/pkg/tests/cross-tests/pu_driver.go b/pkg/tests/cross-tests/pu_driver.go new file mode 100644 index 000000000..23ef4b80d --- /dev/null +++ b/pkg/tests/cross-tests/pu_driver.go @@ -0,0 +1,186 @@ +// Copyright 2016-2024, 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 + +// Driver code for running tests against an in-process bridged provider under Pulumi CLI. +package crosstests + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/convert" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfbridge" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfgen" + shim "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim" + "github.com/pulumi/pulumi-terraform-bridge/v3/unstable/propertyvalue" + pulumidiag "github.com/pulumi/pulumi/sdk/v3/go/common/diag" + "github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors" + "github.com/pulumi/pulumi/sdk/v3/go/common/resource" + "github.com/pulumi/pulumi/sdk/v3/go/common/tokens" + "github.com/pulumi/pulumi/sdk/v3/go/common/util/rpcutil" + pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "gopkg.in/yaml.v3" +) + +type pulumiDriver struct { + name string + version string + shimProvider shim.Provider + pulumiResourceToken string + tfResourceName string + objectType *tftypes.Object +} + +func (pd *pulumiDriver) providerInfo() tfbridge.ProviderInfo { + return tfbridge.ProviderInfo{ + Name: pd.name, + P: pd.shimProvider, + + Resources: map[string]*tfbridge.ResourceInfo{ + pd.tfResourceName: { + Tok: tokens.Type(pd.pulumiResourceToken), + }, + }, + } +} + +func (pd *pulumiDriver) startPulumiProvider(ctx context.Context) (*rpcutil.ServeHandle, error) { + info := pd.providerInfo() + + sink := pulumidiag.DefaultSink(io.Discard, io.Discard, pulumidiag.FormatOptions{ + Color: colors.Never, + }) + + schema, err := tfgen.GenerateSchema(info, sink) + if err != nil { + return nil, fmt.Errorf("tfgen.GenerateSchema failed: %w", err) + } + + schemaBytes, err := json.MarshalIndent(schema, "", " ") + if err != nil { + return nil, fmt.Errorf("json.MarshalIndent(schema, ..) failed: %w", err) + } + + prov := tfbridge.NewProvider(ctx, nil, pd.name, pd.version, info.P, info, schemaBytes) + + handle, err := rpcutil.ServeWithOptions(rpcutil.ServeOptions{ + Init: func(srv *grpc.Server) error { + pulumirpc.RegisterResourceProviderServer(srv, prov) + return nil + }, + }) + if err != nil { + return nil, fmt.Errorf("rpcutil.ServeWithOptions failed: %w", err) + } + + return &handle, nil +} + +func (pd *pulumiDriver) writeYAML(t T, workdir string, tfConfig any) { + res := pd.shimProvider.ResourcesMap().Get(pd.tfResourceName) + schema := res.Schema() + pConfig, err := pd.convertConfigToPulumi(schema, nil, pd.objectType, tfConfig) + require.NoErrorf(t, err, "convertConfigToPulumi failed") + + // TODO[pulumi/pulumi-terraform-bridge#1864]: schema secrets may be set by convertConfigToPulumi. + pConfig = propertyvalue.RemoveSecrets(resource.NewObjectProperty(pConfig)).ObjectValue() + + // This is a bit of a leap of faith that serializing PropertyMap to YAML in this way will yield valid Pulumi + // YAML. This probably needs refinement. + yamlProperties := pConfig.Mappable() + + data := map[string]any{ + "name": "project", + "runtime": "yaml", + "resources": map[string]any{ + "example": map[string]any{ + "type": pd.pulumiResourceToken, + "properties": yamlProperties, + }, + }, + "backend": map[string]any{ + "url": "file://./data", + }, + } + b, err := yaml.Marshal(data) + require.NoErrorf(t, err, "marshaling Pulumi.yaml") + t.Logf("\n\n%s", b) + p := filepath.Join(workdir, "Pulumi.yaml") + err = os.WriteFile(p, b, 0600) + require.NoErrorf(t, err, "writing Pulumi.yaml") +} + +func (pd *pulumiDriver) convertConfigToPulumi( + schemaMap shim.SchemaMap, + schemaInfos map[string]*tfbridge.SchemaInfo, + objectType *tftypes.Object, + tfConfig any, +) (resource.PropertyMap, error) { + var v *tftypes.Value + + switch tfConfig := tfConfig.(type) { + case tftypes.Value: + v = &tfConfig + if objectType == nil { + ty := v.Type().(tftypes.Object) + objectType = &ty + } + case *tftypes.Value: + v = tfConfig + if objectType == nil { + ty := v.Type().(tftypes.Object) + objectType = &ty + } + default: + if objectType == nil { + t := convert.InferObjectType(schemaMap, nil) + objectType = &t + } + bytes, err := json.Marshal(tfConfig) + if err != nil { + return nil, err + } + // Knowingly using a deprecated function so we can connect back up to tftypes.Value; if this disappears + // it should not be prohibitively difficult to rewrite or vendor. + // + //nolint:staticcheck + value, err := tftypes.ValueFromJSON(bytes, *objectType) + if err != nil { + return nil, err + } + v = &value + } + + decoder, err := convert.NewObjectDecoder(convert.ObjectSchema{ + SchemaMap: schemaMap, + SchemaInfos: schemaInfos, + Object: objectType, + }) + if err != nil { + return nil, err + } + + // There is not yet a way to opt out of marking schema secrets, so the resulting map might have secrets marked. + pm, err := convert.DecodePropertyMap(decoder, *v) + if err != nil { + return nil, err + } + return pm, nil +} diff --git a/pkg/tests/cross-tests/rapid_test.go b/pkg/tests/cross-tests/rapid_test.go new file mode 100644 index 000000000..fcb729425 --- /dev/null +++ b/pkg/tests/cross-tests/rapid_test.go @@ -0,0 +1,80 @@ +// Copyright 2016-2024, 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. + +// Rapid-driven property-based tests. These allow randomized exploration of the schema space and locating +// counter-examples. +package crosstests + +import ( + "io" + "log" + "os" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "pgregory.net/rapid" +) + +func TestDiffConvergence(outerT *testing.T) { + _, ok := os.LookupEnv("PULUMI_EXPERIMENTAL") + if !ok { + outerT.Skip("TODO - we do not currently pass all cases; using this as an exploration tool") + } + outerT.Parallel() + + log.SetOutput(io.Discard) + tvg := &tvGen{} + + rapid.Check(outerT, func(t *rapid.T) { + outerT.Logf("Iterating..") + tv := tvg.GenBlockWithDepth(3).Draw(t, "tv") + + t.Logf("Schema:\n%v\n", (&prettySchemaWrapper{schema.Schema{Elem: &schema.Resource{ + Schema: tv.schemaMap, + }}}).GoString()) + + c1 := rapid.Map(tv.valueGen, newPrettyValueWrapper).Draw(t, "config1").Value() + c2 := rapid.Map(tv.valueGen, newPrettyValueWrapper).Draw(t, "config2").Value() + ty := tv.typ + + tc := diffTestCase{ + Resource: &schema.Resource{ + Schema: tv.schemaMap, + }, + Config1: c1, + Config2: c2, + ObjectType: &ty, + } + + runDiffCheck(&rapidTWithCleanup{t, outerT}, tc) + }) +} + +type rapidTWithCleanup struct { + *rapid.T + outerT *testing.T +} + +func (rtc *rapidTWithCleanup) TempDir() string { + return rtc.outerT.TempDir() +} + +func (*rapidTWithCleanup) Deadline() (time.Time, bool) { + return time.Time{}, false +} + +func (rtc *rapidTWithCleanup) Cleanup(work func()) { + rtc.outerT.Cleanup(work) +} diff --git a/pkg/tests/cross-tests/rapid_tv_gen.go b/pkg/tests/cross-tests/rapid_tv_gen.go new file mode 100644 index 000000000..80b6919b7 --- /dev/null +++ b/pkg/tests/cross-tests/rapid_tv_gen.go @@ -0,0 +1,421 @@ +// Copyright 2016-2024, 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. + +// Rapid generators for schema and value spaces. +package crosstests + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" + "pgregory.net/rapid" +) + +// Combines a TF value representing resource inputs with its schema. The value has to conform to the schema. The name +// "tv" stands for a typed value. +type tv struct { + schema schema.Schema + typ tftypes.Type + valueGen *rapid.Generator[tftypes.Value] +} + +// Here "tb" stands for a typed block. Like [tv] but for non-nil/unknown blocks. +type tb struct { + schemaMap map[string]*schema.Schema + typ tftypes.Object + valueGen *rapid.Generator[tftypes.Value] +} + +type schemaT func(schema.Schema) schema.Schema + +type attrKind int + +const ( + invalidAttr attrKind = iota + optionalAttr + requiredAttr + computedAttr + computedOptionalAttr +) + +type tvGen struct { + generateUnknowns bool +} + +func (tvg *tvGen) GenBlock() *rapid.Generator[tv] { + return rapid.Custom[tv](func(t *rapid.T) tv { + depth := rapid.IntRange(0, 3).Draw(t, "depth") + tv := tvg.GenBlockOrAttrWithDepth(depth).Draw(t, "tv") + return tv + }) +} + +func (tvg *tvGen) GenAttr() *rapid.Generator[tv] { + return rapid.Custom[tv](func(t *rapid.T) tv { + depth := rapid.IntRange(0, 3).Draw(t, "depth") + tv := tvg.GenAttrWithDepth(depth).Draw(t, "tv") + return tv + }) +} + +func (tvg *tvGen) GenBlockOrAttrWithDepth(depth int) *rapid.Generator[tv] { + opts := []*rapid.Generator[tv]{ + tvg.GenAttrWithDepth(depth), + } + if depth > 1 { + opts = append(opts, + tvg.GenSingleNestedBlock(depth-1), + tvg.GenListNestedBlock(depth-1), + tvg.GenSetNestedBlock(depth-1), + ) + } + return rapid.OneOf(opts...) +} + +func (tvg *tvGen) GenAttrWithDepth(depth int) *rapid.Generator[tv] { + opts := []*rapid.Generator[tv]{ + tvg.GenString(), + tvg.GenBool(), + tvg.GenInt(), + tvg.GenFloat(), + } + if depth > 1 { + opts = append(opts, + tvg.GenMapAttr(depth-1), + tvg.GenListAttr(depth-1), + tvg.GenSetAttr(depth-1), + ) + } + return rapid.OneOf(opts...) +} + +func (tvg *tvGen) GenMapAttr(depth int) *rapid.Generator[tv] { + ge := rapid.Custom[tv](func(t *rapid.T) tv { + // Only generating primitive types for the value of a Map. This is due to this limitation: + // https://github.com/hashicorp/terraform-plugin-sdk/issues/62 + // + // Ideally per pulumi/pulumi-terraform-bridge#1873 bridged providers should reject these at build time + // so the runtime tests should not concern themselves with these unsupported combinations. + zeroDepth := 0 + inner := tvg.GenAttrWithDepth(zeroDepth).Draw(t, "attrGen") + mapWrapType := tftypes.Map{ElementType: inner.typ} + mapWrap := func(vs map[string]tftypes.Value) tftypes.Value { + return tftypes.NewValue(mapWrapType, vs) + } + keyGen := rapid.SampledFrom([]string{"a", "b"}) + vg := rapid.Map(rapid.MapOf(keyGen, inner.valueGen), mapWrap) + return tv{ + schema: schema.Schema{ + Type: schema.TypeMap, + Elem: &inner.schema, + }, + typ: mapWrapType, + valueGen: vg, + } + }) + ge = tvg.WithSchemaTransform(ge) + ge = tvg.WithNullAndUnknown(ge) + return ge +} + +func (tvg *tvGen) GenListAttr(depth int) *rapid.Generator[tv] { + ge := rapid.Custom[tv](func(t *rapid.T) tv { + inner := tvg.GenAttrWithDepth(depth).Draw(t, "attrGen") + listWrapType := tftypes.List{ElementType: inner.typ} + listWrap := func(vs []tftypes.Value) tftypes.Value { + return tftypes.NewValue(listWrapType, vs) + } + vg := rapid.Map(rapid.SliceOfN(inner.valueGen, 0, 3), listWrap) + return tv{ + schema: schema.Schema{ + // TODO get creative with the hash function + Type: schema.TypeList, + Elem: &inner.schema, + }, + typ: listWrapType, + valueGen: vg, + } + }) + ge = tvg.WithSchemaTransform(ge) + ge = tvg.WithNullAndUnknown(ge) + return ge +} + +func (tvg *tvGen) GenSetAttr(depth int) *rapid.Generator[tv] { + ge := rapid.Custom[tv](func(t *rapid.T) tv { + inner := tvg.GenAttrWithDepth(depth).Draw(t, "attrGen") + setWrapType := tftypes.Set{ElementType: inner.typ} + setWrap := func(vs []tftypes.Value) tftypes.Value { + return tftypes.NewValue(setWrapType, vs) + } + vg := rapid.Map(rapid.SliceOfN(inner.valueGen, 0, 3), setWrap) + return tv{ + schema: schema.Schema{ + // TODO[pulumi/pulumi-terraform-bridge#1862 alternative hash functions + Type: schema.TypeSet, + Elem: &inner.schema, + }, + typ: setWrapType, + valueGen: vg, + } + }) + ge = tvg.WithSchemaTransform(ge) + ge = tvg.WithNullAndUnknown(ge) + return ge +} + +// TF blocks can be resource or datasource inputs, or nested blocks. +func (tvg *tvGen) GenBlockWithDepth(depth int) *rapid.Generator[tb] { + return rapid.Custom[tb](func(t *rapid.T) tb { + fieldSchemas := map[string]*schema.Schema{} + fieldTypes := map[string]tftypes.Type{} + fieldGenerators := map[string]*rapid.Generator[tftypes.Value]{} + nFields := rapid.IntRange(0, 3).Draw(t, "nFields") + for i := 0; i < nFields; i++ { + fieldName := fmt.Sprintf("f%d", i) + fieldTV := tvg.GenBlockOrAttrWithDepth(depth-1).Draw(t, fieldName) + fieldSchemas[fieldName] = &fieldTV.schema + fieldGenerators[fieldName] = fieldTV.valueGen + fieldTypes[fieldName] = fieldTV.typ + } + objType := tftypes.Object{AttributeTypes: fieldTypes} + var objGen *rapid.Generator[tftypes.Value] + if len(fieldGenerators) > 0 { + objGen = rapid.Custom[tftypes.Value](func(t *rapid.T) tftypes.Value { + fields := map[string]tftypes.Value{} + for f, fg := range fieldGenerators { + fv := tvg.drawValue(t, fg, f) + fields[f] = fv + } + return tftypes.NewValue(objType, fields) + }) + } else { + objGen = rapid.Just(tftypes.NewValue(objType, map[string]tftypes.Value{})) + } + err := schema.InternalMap(fieldSchemas).InternalValidate(nil) + contract.AssertNoErrorf(err, "rapid_tv_gen generated an invalid schema: please fix") + return tb{fieldSchemas, objType, objGen} + }) +} + +// Single-nested blocks represent object types. In schemav2 providers there is no natural encoding for these, so they +// are typically encoded as MaxItems=1 lists with a *Resource Elem. +// +// See https://developer.hashicorp.com/terraform/plugin/framework/handling-data/blocks/single-nested +func (tvg *tvGen) GenSingleNestedBlock(depth int) *rapid.Generator[tv] { + return rapid.Custom[tv](func(t *rapid.T) tv { + st := tvg.GenSchemaTransform().Draw(t, "schemaTransform") + bl := tvg.GenBlockWithDepth(depth).Draw(t, "block") + listWrapType := tftypes.List{ElementType: bl.typ} + listWrap := func(v tftypes.Value) tftypes.Value { + return tftypes.NewValue(listWrapType, []tftypes.Value{v}) + } + return tv{ + schema: st(schema.Schema{ + Type: schema.TypeList, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: bl.schemaMap, + }, + }), + typ: listWrapType, + // A few open questions here, can these values ever be unknown (likely yes) and how is that + // represented in TF? Also, can these values be null or this is just represented as an empty + // list? Should an empty list be part of the values here? + // + // This should also account for required schemas. + // + // valueGen: tvg.withNullAndUnknown(listWrapType, rapid.Map(bl.valueGen, listWrap)), + valueGen: rapid.Map(bl.valueGen, listWrap), + } + }) +} + +func (tvg *tvGen) GenListNestedBlock(depth int) *rapid.Generator[tv] { + ge := rapid.Custom[tv](func(t *rapid.T) tv { + bl := tvg.GenBlockWithDepth(depth).Draw(t, "block") + listWrapType := tftypes.List{ElementType: bl.typ} + listWrap := func(vs []tftypes.Value) tftypes.Value { + return tftypes.NewValue(listWrapType, vs) + } + vg := rapid.Map(rapid.SliceOfN(bl.valueGen, 0, 3), listWrap) + return tv{ + schema: schema.Schema{ + Type: schema.TypeList, + // TODO: randomly trigger MaxItems: 1 + Elem: &schema.Resource{ + Schema: bl.schemaMap, + }, + }, + typ: listWrapType, + valueGen: vg, + } + }) + ge = tvg.WithSchemaTransform(ge) + ge = tvg.WithNullAndUnknown(ge) + return ge +} + +func (tvg *tvGen) GenSetNestedBlock(depth int) *rapid.Generator[tv] { + ge := rapid.Custom[tv](func(t *rapid.T) tv { + bl := tvg.GenBlockWithDepth(depth).Draw(t, "block") + setWrapType := tftypes.Set{ElementType: bl.typ} + setWrap := func(vs []tftypes.Value) tftypes.Value { + return tftypes.NewValue(setWrapType, vs) + } + vg := rapid.Map(rapid.SliceOfN(bl.valueGen, 0, 3), setWrap) + return tv{ + schema: schema.Schema{ + Type: schema.TypeSet, + // TODO: randomly trigger MaxItems: 1 + // TODO: get a bit inventive with custom hash functions + Elem: &schema.Resource{ + Schema: bl.schemaMap, + }, + }, + typ: setWrapType, + valueGen: vg, + } + }) + ge = tvg.WithSchemaTransform(ge) + ge = tvg.WithNullAndUnknown(ge) + return ge +} + +func (tvg *tvGen) GenAttrKind() *rapid.Generator[attrKind] { + return rapid.SampledFrom([]attrKind{ + optionalAttr, + requiredAttr, + computedAttr, + computedOptionalAttr, + }) +} + +func (tvg *tvGen) GenString() *rapid.Generator[tv] { + return tvg.GenScalar(schema.TypeString, []tftypes.Value{ + tftypes.NewValue(tftypes.String, ""), + tftypes.NewValue(tftypes.String, "text"), + }) +} + +func (tvg *tvGen) GenBool() *rapid.Generator[tv] { + return tvg.GenScalar(schema.TypeBool, []tftypes.Value{ + tftypes.NewValue(tftypes.Bool, false), + tftypes.NewValue(tftypes.Bool, true), + }) +} + +func (tvg *tvGen) GenInt() *rapid.Generator[tv] { + return tvg.GenScalar(schema.TypeInt, []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 0), + tftypes.NewValue(tftypes.Number, -1), + tftypes.NewValue(tftypes.Number, 42), + }) +} + +func (tvg *tvGen) GenFloat() *rapid.Generator[tv] { + return tvg.GenScalar(schema.TypeInt, []tftypes.Value{ + tftypes.NewValue(tftypes.Number, float64(0.0)), + tftypes.NewValue(tftypes.Number, float64(-1.0)), + tftypes.NewValue(tftypes.Number, float64(42.0)), + }) +} + +func (tvg *tvGen) GenScalar(vt schema.ValueType, values []tftypes.Value) *rapid.Generator[tv] { + s := schema.Schema{ + Type: vt, + } + g := tv{ + schema: s, + typ: values[0].Type(), + valueGen: rapid.SampledFrom(values), + } + gen := tvg.WithSchemaTransform(rapid.Just(g)) + gen = tvg.WithNullAndUnknown(gen) + return gen +} + +func (tvg *tvGen) WithSchemaTransform(gen *rapid.Generator[tv]) *rapid.Generator[tv] { + return rapid.Custom[tv](func(t *rapid.T) tv { + tv0 := gen.Draw(t, "tv") + st := tvg.GenSchemaTransform().Draw(t, "schemaTransform") + return tv{ + schema: st(tv0.schema), + typ: tv0.typ, + valueGen: tv0.valueGen, + } + }) +} + +func (tvg *tvGen) WithNullAndUnknown(gen *rapid.Generator[tv]) *rapid.Generator[tv] { + return rapid.Custom[tv](func(t *rapid.T) tv { + tv0 := gen.Draw(t, "tv") + gen := tv0.valueGen + if tvg.generateUnknowns || tv0.schema.Required { + options := []*rapid.Generator[tftypes.Value]{gen} + if tvg.generateUnknowns { + unkGen := rapid.Just(tftypes.NewValue(tv0.typ, tftypes.UnknownValue)) + options = append(options, unkGen) + } + if !tv0.schema.Required { + nullGen := rapid.Just(tftypes.NewValue(tv0.typ, nil)) + options = append(options, nullGen) + } + gen = rapid.OneOf(options...) + } + return tv{ + schema: tv0.schema, + typ: tv0.typ, + valueGen: gen, + } + }) +} + +func (tvg *tvGen) GenSchemaTransform() *rapid.Generator[schemaT] { + return rapid.Custom[schemaT](func(t *rapid.T) schemaT { + attrKind := tvg.GenAttrKind().Draw(t, "attrKind") + secret := rapid.Bool().Draw(t, "secret") + forceNew := rapid.Bool().Draw(t, "forceNew") + + return func(s schema.Schema) schema.Schema { + switch attrKind { + case optionalAttr: + s.Optional = true + case requiredAttr: + s.Required = true + case computedAttr: + // TODO this currently triggers errors in the tests because the provider needs to be + // taught to polyfill computed values instead of passing them as inputs. + s.Optional = true + // s.Computed = true + case computedOptionalAttr: + s.Computed = true + s.Optional = true + } + if forceNew { + s.ForceNew = true + } + if secret { + s.Sensitive = true + } + return s + } + }) +} + +func (*tvGen) drawValue(t *rapid.T, g *rapid.Generator[tftypes.Value], label string) tftypes.Value { + return rapid.Map(g, newPrettyValueWrapper).Draw(t, label).Value() +} diff --git a/pkg/tests/cross-tests/t.go b/pkg/tests/cross-tests/t.go new file mode 100644 index 000000000..92dacbcaf --- /dev/null +++ b/pkg/tests/cross-tests/t.go @@ -0,0 +1,30 @@ +// Copyright 2016-2024, 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. + +// Abstractions to allow tests to work against both *testing.T and rapid.TB. +package crosstests + +import ( + "github.com/pulumi/providertest/pulumitest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type T interface { + Logf(string, ...any) + TempDir() string + require.TestingT + assert.TestingT + pulumitest.PT +} diff --git a/pkg/tests/cross-tests/tf_driver.go b/pkg/tests/cross-tests/tf_driver.go new file mode 100644 index 000000000..1d5c9eb57 --- /dev/null +++ b/pkg/tests/cross-tests/tf_driver.go @@ -0,0 +1,230 @@ +// Copyright 2016-2024, 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. + +// Helper code to drive Terraform CLI to run tests against an in-process provider. +package crosstests + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/go-plugin" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tfprotov5/tf5server" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/convert" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim/sdk-v2" + "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" + "github.com/stretchr/testify/require" +) + +type tfDriver struct { + cwd string + providerName string + reattachConfig *plugin.ReattachConfig + res *schema.Resource +} + +type tfPlan struct { + PlanFile string + RawPlan any +} + +func newTfDriver(t T, dir, providerName, resName string, res *schema.Resource) *tfDriver { + // Did not find a less intrusive way to disable annoying logging: + os.Setenv("TF_LOG_PROVIDER", "off") + os.Setenv("TF_LOG_SDK", "off") + os.Setenv("TF_LOG_SDK_PROTO", "off") + + if res.DeleteContext == nil { + res.DeleteContext = func( + ctx context.Context, rd *schema.ResourceData, i interface{}, + ) diag.Diagnostics { + return diag.Diagnostics{} + } + } + + if res.CreateContext == nil { + res.CreateContext = func( + ctx context.Context, rd *schema.ResourceData, i interface{}, + ) diag.Diagnostics { + rd.SetId("newid") + return diag.Diagnostics{} + } + } + + res.UpdateContext = func( + ctx context.Context, rd *schema.ResourceData, i interface{}, + ) diag.Diagnostics { + return diag.Diagnostics{} + } + + p := &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + resName: res, + }, + } + + serverFactory := func() tfprotov5.ProviderServer { + return p.GRPCProvider() + } + + ctx := context.Background() + + reattachConfigCh := make(chan *plugin.ReattachConfig) + closeCh := make(chan struct{}) + + serveOpts := []tf5server.ServeOpt{ + tf5server.WithGoPluginLogger(hclog.FromStandardLogger(log.New(io.Discard, "", 0), hclog.DefaultOptions)), + tf5server.WithDebug(ctx, reattachConfigCh, closeCh), + tf5server.WithoutLogStderrOverride(), + } + + go func() { + err := tf5server.Serve(providerName, serverFactory, serveOpts...) + require.NoError(t, err) + }() + + reattachConfig := <-reattachConfigCh + return &tfDriver{ + providerName: providerName, + cwd: dir, + reattachConfig: reattachConfig, + res: res, + } +} + +func (d *tfDriver) coalesce(t T, x any) *tftypes.Value { + if x == nil { + return nil + } + objectType := convert.InferObjectType(sdkv2.NewSchemaMap(d.res.Schema), nil) + t.Logf("infer object type: %v", objectType) + v := fromType(objectType).NewValue(x) + return &v +} + +func (d *tfDriver) writePlanApply( + t T, + resourceSchema map[string]*schema.Schema, + resourceType, resourceName string, + rawConfig any, +) *tfPlan { + config := d.coalesce(t, rawConfig) + if config != nil { + d.write(t, resourceSchema, resourceType, resourceName, *config) + } + plan := d.plan(t) + d.apply(t, plan) + return plan +} + +func (d *tfDriver) write( + t T, + resourceSchema map[string]*schema.Schema, + resourceType, resourceName string, + config tftypes.Value, +) { + var buf bytes.Buffer + err := WriteHCL(&buf, resourceSchema, resourceType, resourceName, fromValue(config).ToCty()) + require.NoError(t, err) + t.Logf("HCL: \n%s\n", buf.String()) + bytes := buf.Bytes() + err = os.WriteFile(filepath.Join(d.cwd, "test.tf"), bytes, 0600) + require.NoErrorf(t, err, "writing test.tf") +} + +func (d *tfDriver) plan(t T) *tfPlan { + planFile := filepath.Join(d.cwd, "test.tfplan") + env := []string{d.formatReattachEnvVar()} + execCmd(t, d.cwd, env, "terraform", "plan", "-refresh=false", "-out", planFile) + cmd := execCmd(t, d.cwd, env, "terraform", "show", "-json", planFile) + tp := tfPlan{PlanFile: planFile} + err := json.Unmarshal(cmd.Stdout.(*bytes.Buffer).Bytes(), &tp.RawPlan) + require.NoErrorf(t, err, "failed to unmarshal terraform plan") + return &tp +} + +func (d *tfDriver) apply(t T, plan *tfPlan) { + execCmd(t, d.cwd, []string{d.formatReattachEnvVar()}, + "terraform", "apply", "-auto-approve", "-refresh=false", plan.PlanFile) +} + +func (d *tfDriver) formatReattachEnvVar() string { + name := d.providerName + pluginReattachConfig := d.reattachConfig + + type reattachConfigAddr struct { + Network string + String string + } + + type reattachConfig struct { + Protocol string + ProtocolVersion int + Pid int + Test bool + Addr reattachConfigAddr + } + + reattachBytes, err := json.Marshal(map[string]reattachConfig{ + name: { + Protocol: string(pluginReattachConfig.Protocol), + ProtocolVersion: pluginReattachConfig.ProtocolVersion, + Pid: pluginReattachConfig.Pid, + Test: pluginReattachConfig.Test, + Addr: reattachConfigAddr{ + Network: pluginReattachConfig.Addr.Network(), + String: pluginReattachConfig.Addr.String(), + }, + }, + }) + + contract.AssertNoErrorf(err, "failed to build TF_REATTACH_PROVIDERS string") + return fmt.Sprintf("TF_REATTACH_PROVIDERS=%s", string(reattachBytes)) +} + +// Still discovering the structure of JSON-serialized TF plans. The information required from these is, primarily, is +// whether the resource is staying unchanged, being updated or replaced. Secondarily, would be also great to know +// detailed paths of properties causing the change, though that is more difficult to cross-compare with Pulumi. +// +// For now this is code is similar to `jq .resource_changes[0].change.actions[0] plan.json`. +func (*tfDriver) parseChangesFromTFPlan(plan tfPlan) string { + type p struct { + ResourceChanges []struct { + Change struct { + Actions []string `json:"actions"` + } `json:"change"` + } `json:"resource_changes"` + } + jb, err := json.Marshal(plan.RawPlan) + contract.AssertNoErrorf(err, "failed to marshal terraform plan") + var pp p + err = json.Unmarshal(jb, &pp) + contract.AssertNoErrorf(err, "failed to unmarshal terraform plan") + contract.Assertf(len(pp.ResourceChanges) == 1, "expected exactly one resource change") + actions := pp.ResourceChanges[0].Change.Actions + contract.Assertf(len(actions) == 1, "expected exactly one action, got %v", strings.Join(actions, ", ")) + return actions[0] +} diff --git a/pkg/tests/cross-tests/tfwrite.go b/pkg/tests/cross-tests/tfwrite.go new file mode 100644 index 000000000..eb9402ae2 --- /dev/null +++ b/pkg/tests/cross-tests/tfwrite.go @@ -0,0 +1,75 @@ +// Copyright 2016-2024, 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. + +// Helper code to emit Terraform HCL code to drive the Terraform CLI. +package crosstests + +import ( + "fmt" + "io" + "sort" + + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" + "github.com/zclconf/go-cty/cty" +) + +// Writes a resource delaration. Note that unknowns are not yet supported in cty.Value, it will error out if found. +func WriteHCL(out io.Writer, sch map[string]*schema.Schema, resourceType, resourceName string, config cty.Value) error { + if !config.IsWhollyKnown() { + return fmt.Errorf("WriteHCL cannot yet write unknowns") + } + f := hclwrite.NewEmptyFile() + block := f.Body().AppendNewBlock("resource", []string{resourceType, resourceName}) + writeBlock(block.Body(), sch, config.AsValueMap()) + _, err := f.WriteTo(out) + return err +} + +func writeBlock(body *hclwrite.Body, schemas map[string]*schema.Schema, values map[string]cty.Value) { + keys := make([]string, 0, len(schemas)) + for key := range schemas { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + sch := schemas[key] + value, ok := values[key] + if !ok { + continue + } + contract.Assertf(sch.ConfigMode == 0, "ConfigMode > 0 is not yet supported: %v", sch.ConfigMode) + switch elem := sch.Elem.(type) { + case *schema.Resource: + if sch.Type == schema.TypeMap { + body.SetAttributeValue(key, value) + } else if sch.Type == schema.TypeSet { + for _, v := range value.AsValueSet().Values() { + newBlock := body.AppendNewBlock(key, nil) + writeBlock(newBlock.Body(), elem.Schema, v.AsValueMap()) + } + } else if sch.Type == schema.TypeList { + for _, v := range value.AsValueSlice() { + newBlock := body.AppendNewBlock(key, nil) + writeBlock(newBlock.Body(), elem.Schema, v.AsValueMap()) + } + } else { + contract.Failf("unexpected schema type %v", sch.Type) + } + default: + body.SetAttributeValue(key, value) + } + } +} diff --git a/pkg/tests/cross-tests/tfwrite_test.go b/pkg/tests/cross-tests/tfwrite_test.go new file mode 100644 index 000000000..e433c4922 --- /dev/null +++ b/pkg/tests/cross-tests/tfwrite_test.go @@ -0,0 +1,182 @@ +// Copyright 2016-2024, 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 crosstests + +import ( + "bytes" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hexops/autogold/v2" + "github.com/stretchr/testify/require" + "github.com/zclconf/go-cty/cty" +) + +func TestWriteHCL(t *testing.T) { + type testCase struct { + name string + value cty.Value + schema map[string]*schema.Schema + expect autogold.Value + } + + testCases := []testCase{ + { + "simple", + cty.ObjectVal(map[string]cty.Value{"x": cty.StringVal("OK")}), + map[string]*schema.Schema{"x": { + Type: schema.TypeString, + Optional: true, + }}, + autogold.Expect(` +resource "res" "ex" { + x = "OK" +} +`), + }, + { + "simple-null", + cty.ObjectVal(map[string]cty.Value{"x": cty.NullVal(cty.String)}), + map[string]*schema.Schema{"x": { + Type: schema.TypeString, + Optional: true, + }}, + autogold.Expect(` +resource "res" "ex" { + x = null +} +`), + }, + { + "simple-missing", + cty.ObjectVal(map[string]cty.Value{}), + map[string]*schema.Schema{"x": { + Type: schema.TypeString, + Optional: true, + }}, + autogold.Expect(` +resource "res" "ex" { +} +`), + }, + { + "single-nested-block", + cty.ObjectVal(map[string]cty.Value{ + "x": cty.StringVal("OK"), + "y": cty.ObjectVal(map[string]cty.Value{ + "foo": cty.NumberIntVal(42), + }), + }), + map[string]*schema.Schema{ + "x": { + Type: schema.TypeString, + Optional: true, + }, + "y": { + Type: schema.TypeMap, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "foo": {Type: schema.TypeInt, Required: true}, + }, + }, + }, + }, + autogold.Expect(` +resource "res" "ex" { + x = "OK" + y = { + foo = 42 + } +} +`), + }, + { + "list-nested-block", + cty.ObjectVal(map[string]cty.Value{ + "blk": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.NumberIntVal(1), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.NumberIntVal(2), + }), + }), + }), + map[string]*schema.Schema{ + "blk": { + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "foo": {Type: schema.TypeInt, Required: true}, + }, + }, + }, + }, + autogold.Expect(` +resource "res" "ex" { + blk { + foo = 1 + } + blk { + foo = 2 + } +} +`), + }, + { + "set-nested-block", + cty.ObjectVal(map[string]cty.Value{ + "blk": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.NumberIntVal(1), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.NumberIntVal(2), + }), + }), + }), + map[string]*schema.Schema{ + "blk": { + Type: schema.TypeSet, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "foo": {Type: schema.TypeInt, Required: true}, + }, + }, + }, + }, + autogold.Expect(` +resource "res" "ex" { + blk { + foo = 1 + } + blk { + foo = 2 + } +} +`), + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + var out bytes.Buffer + err := WriteHCL(&out, tc.schema, "res", "ex", tc.value) + require.NoError(t, err) + tc.expect.Equal(t, "\n"+out.String()) + }) + } +} diff --git a/pkg/tests/go.mod b/pkg/tests/go.mod index 95d2a5100..8c2ed9aaf 100644 --- a/pkg/tests/go.mod +++ b/pkg/tests/go.mod @@ -9,9 +9,12 @@ replace ( require ( github.com/hashicorp/terraform-plugin-sdk/v2 v2.33.0 - github.com/pulumi/providertest v0.0.11 + github.com/hexops/autogold/v2 v2.2.1 + github.com/hexops/valast v1.4.4 + github.com/pulumi/providertest v0.0.12 github.com/pulumi/pulumi-terraform-bridge/v3 v3.80.0 github.com/stretchr/testify v1.8.4 + pgregory.net/rapid v0.6.1 ) require ( @@ -51,6 +54,7 @@ require ( github.com/hashicorp/hil v0.0.0-20190212132231-97b3a9cdfa93 // indirect github.com/hashicorp/terraform-registry-address v0.2.3 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect + github.com/hexops/gotextdiff v1.0.3 // indirect github.com/huandu/xstrings v1.3.3 // indirect github.com/iancoleman/strcase v0.2.0 // indirect github.com/imdario/mergo v0.3.15 // indirect @@ -63,6 +67,7 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.2 // indirect + github.com/nightlyone/lockfile v1.0.0 // indirect github.com/nxadm/tail v1.4.11 // indirect github.com/pgavlin/fx v0.1.6 // indirect github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect @@ -84,6 +89,7 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect + mvdan.cc/gofumpt v0.5.0 // indirect ) require ( @@ -144,7 +150,7 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 // indirect - github.com/hashicorp/go-hclog v1.5.0 // indirect + github.com/hashicorp/go-hclog v1.5.0 github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-plugin v1.6.0 @@ -158,7 +164,7 @@ require ( github.com/hashicorp/go-version v1.6.0 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect - github.com/hashicorp/hcl/v2 v2.19.1 // indirect + github.com/hashicorp/hcl/v2 v2.19.1 github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/terraform-plugin-go v0.22.0 github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect @@ -217,7 +223,7 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect - github.com/zclconf/go-cty v1.14.2 // indirect + github.com/zclconf/go-cty v1.14.2 go.opencensus.io v0.24.0 // indirect go.uber.org/atomic v1.9.0 // indirect gocloud.dev v0.36.0 // indirect diff --git a/pkg/tests/go.sum b/pkg/tests/go.sum index f42e954a0..f806de677 100644 --- a/pkg/tests/go.sum +++ b/pkg/tests/go.sum @@ -1739,6 +1739,7 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= @@ -1756,8 +1757,9 @@ github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2 github.com/frankban/quicktest v1.10.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= github.com/frankban/quicktest v1.13.0/go.mod h1:qLE0fzW0VuyUAJgPU19zByoIr0HtCHN/r/VLSOOIySU= -github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= +github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= @@ -2258,6 +2260,7 @@ github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= github.com/hetznercloud/hcloud-go v1.33.1/go.mod h1:XX/TQub3ge0yWR2yHWmnDVIrB+MQbda1pHxkUmDlUME= github.com/hetznercloud/hcloud-go v1.35.0/go.mod h1:mepQwR6va27S3UQthaEPGS86jtzSY9xWL1e9dyxXpgA= +github.com/hexops/autogold v0.8.1/go.mod h1:97HLDXyG23akzAoRYJh/2OBs3kd80eHyKPvZw0S5ZBY= github.com/hexops/autogold v1.3.0 h1:IEtGNPxBeBu8RMn8eKWh/Ll9dVNgSnJ7bp/qHgMQ14o= github.com/hexops/autogold v1.3.0/go.mod h1:d4hwi2rid66Sag+BVuHgwakW/EmaFr8vdTSbWDbrDRI= github.com/hexops/autogold/v2 v2.2.1 h1:JPUXuZQGkcQMv7eeDXuNMovjfoRYaa0yVcm+F3voaGY= @@ -2460,6 +2463,7 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -2788,8 +2792,8 @@ github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231 h1:vkHw5I/plNdTr435 github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231/go.mod h1:murToZ2N9hNJzewjHBgfFdXhZKjY3z5cYC1VXk+lbFE= github.com/pulumi/esc v0.6.2 h1:+z+l8cuwIauLSwXQS0uoI3rqB+YG4SzsZYtHfNoXBvw= github.com/pulumi/esc v0.6.2/go.mod h1:jNnYNjzsOgVTjCp0LL24NsCk8ZJxq4IoLQdCT0X7l8k= -github.com/pulumi/providertest v0.0.11 h1:mg8MQ7Cq7+9XlHIkBD+aCqQO4mwAJEISngZgVdnQUe8= -github.com/pulumi/providertest v0.0.11/go.mod h1:HsxjVsytcMIuNj19w1lT2W0QXY0oReXl1+h6eD2JXP8= +github.com/pulumi/providertest v0.0.12 h1:UjcFQHHs4AGJyJqxhvC2q8yVQ7Li+UyCyP95HZcK03U= +github.com/pulumi/providertest v0.0.12/go.mod h1:REAoaN+hGOtdWJGirfWYqcSjCejlbGfzyVTUuemJTuE= github.com/pulumi/pulumi-java/pkg v0.10.0 h1:D1i5MiiNrxYr2uJ1szcj1aQwF9DYv7TTsPmajB9dKSw= github.com/pulumi/pulumi-java/pkg v0.10.0/go.mod h1:xu6UgYtQm+xXOo1/DZNa2CWVPytu+RMkZVTtI7w7ffY= github.com/pulumi/pulumi-yaml v1.6.0 h1:mb/QkebWXTa1fR+P3ZkCCHGXOYC6iTN8X8By9eNz8xM= @@ -2820,6 +2824,7 @@ github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/rs/cors v1.8.2/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= @@ -3766,6 +3771,7 @@ golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM= +golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8= golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= @@ -4343,6 +4349,7 @@ modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8= +mvdan.cc/gofumpt v0.4.0/go.mod h1:PljLOHDeZqgS8opHRKLzp2It2VBuSdteAgqUfzMTxlQ= mvdan.cc/gofumpt v0.5.0 h1:0EQ+Z56k8tXjj/6TQD25BFNKQXpCvT0rnansIc7Ug5E= mvdan.cc/gofumpt v0.5.0/go.mod h1:HBeVDtMKRZpXyxFciAirzdKklDlGu8aAy1wEbH5Y9js= nhooyr.io/websocket v1.8.6/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= diff --git a/pkg/tfshim/sdk-v2/cty.go b/pkg/tfshim/sdk-v2/cty.go index bfc93c922..1338d516a 100644 --- a/pkg/tfshim/sdk-v2/cty.go +++ b/pkg/tfshim/sdk-v2/cty.go @@ -119,7 +119,7 @@ func recoverCtyValue(dT cty.Type, value interface{}) (cty.Value, error) { case dT.IsObjectType(): return recoverCtyValueOfObjectType(dT, value) default: - return cty.NilVal, fmt.Errorf("Cannot reconcile map %v to %v", value, dT) + return cty.NilVal, fmt.Errorf("Cannot reconcile map %v to %#v", value, dT) } case []interface{}: switch { @@ -130,7 +130,7 @@ func recoverCtyValue(dT cty.Type, value interface{}) (cty.Value, error) { case dT.IsListType(): return recoverCtyValueOfListType(dT, value) default: - return cty.NilVal, fmt.Errorf("Cannot reconcile slice %v to %v", value, dT) + return cty.NilVal, fmt.Errorf("Cannot reconcile slice %v to %#v", value, dT) } default: v, err := recoverScalarCtyValue(dT, value)