diff --git a/internal/backend/backendbase/sdklike.go b/internal/backend/backendbase/sdklike.go new file mode 100644 index 0000000000..dea5e36edc --- /dev/null +++ b/internal/backend/backendbase/sdklike.go @@ -0,0 +1,158 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package backendbase + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" +) + +// SDKLikeData offers an approximation of the legack SDK "ResourceData" API +// as a stopgap measure to help migrate all of the remote state backend +// implementations away from the legacy SDK. +// +// It's designed to wrap an object returned by [Base.PrepareConfig] which +// should therefore already have a fixed, known data type. Therefore the +// methods assume that the caller already knows what type each attribute +// should have and will panic if a caller asks for an incompatible type. +type SDKLikeData struct { + v cty.Value +} + +func NewSDKLikeData(v cty.Value) SDKLikeData { + return SDKLikeData{v} +} + +// String extracts a string attribute from a configuration object +// in a similar way to how the legacy SDK would interpret an attribute +// of type schema.TypeString, or panics if the wrapped object isn't of a +// suitable type. +func (d SDKLikeData) String(attrPath string) string { + v := d.GetAttr(attrPath, cty.String) + if v.IsNull() { + return "" + } + return v.AsString() +} + +// Int extracts a string attribute from a configuration object +// in a similar way to how the legacy SDK would interpret an attribute +// of type schema.TypeInt, or panics if the wrapped object isn't of a +// suitable type. +// +// Since the Terraform language does not have an integers-only type, this +// can fail dynamically (returning an error) if the given value has a +// fractional component. +func (d SDKLikeData) Int64(attrPath string) (int64, error) { + // Legacy SDK used strconv.ParseInt to interpret values, so we'll + // follow its lead here for maximal compatibility. + v := d.GetAttr(attrPath, cty.String) + if v.IsNull() { + return 0, nil + } + return strconv.ParseInt(v.AsString(), 0, 0) +} + +// Bool extracts a string attribute from a configuration object +// in a similar way to how the legacy SDK would interpret an attribute +// of type schema.TypeBool, or panics if the wrapped object isn't of a +// suitable type. +func (d SDKLikeData) Bool(attrPath string) bool { + // Legacy SDK used strconv.ParseBool to interpret values, but it + // did so only after the configuration was interpreted by HCL and + // thus HCL's more constrained definition of bool still "won", + // and we follow that tradition here. + v := d.GetAttr(attrPath, cty.Bool) + if v.IsNull() { + return false + } + return v.True() +} + +// GetAttr is just a thin wrapper around [cty.Path.Apply] that accepts +// a legacy-SDK-like dot-separated string as attribute path, instead of +// a [cty.Path] directly. +// +// It uses [SDKLikePath] to interpret the given path, and so the limitations +// of that function apply equally to this function. +// +// This function will panic if asked to extract a path that isn't compatible +// with the object type of the enclosed value. +func (d SDKLikeData) GetAttr(attrPath string, wantType cty.Type) cty.Value { + path := SDKLikePath(attrPath) + v, err := path.Apply(d.v) + if err != nil { + panic("invalid attribute path: " + err.Error()) + } + v, err = convert.Convert(v, wantType) + if err != nil { + panic("incorrect attribute type: " + err.Error()) + } + return v +} + +// SDKLikePath interprets a subset of the legacy SDK attribute path syntax -- +// identifiers separated by dots -- into a cty.Path. +// +// This is designed only for migrating historical remote system backends that +// were originally written using the SDK, and so it's limited only to the +// simple cases they use. It's not suitable for the more complex legacy SDK +// uses made by Terraform providers. +func SDKLikePath(rawPath string) cty.Path { + var ret cty.Path + remain := rawPath + for { + dot := strings.IndexByte(remain, '.') + last := false + if dot == -1 { + dot = len(remain) + last = true + } + + attrName := remain[:dot] + ret = append(ret, cty.GetAttrStep{Name: attrName}) + if last { + return ret + } + remain = remain[dot+1:] + } +} + +// SDKLikeEnvDefault emulates an SDK-style "EnvDefaultFunc" by taking the +// result of [SDKLikeData.String] and a series of environment variable names. +// +// If the given string is already non-empty then it just returns it directly. +// Otherwise it returns the value of the first environment variable that has +// a non-empty value. If everything turns out empty, the result is an empty +// string. +func SDKLikeEnvDefault(v string, envNames ...string) string { + if v == "" { + for _, envName := range envNames { + v = os.Getenv(envName) + if v != "" { + return v + } + } + } + return v +} + +// SDKLikeRequiredWithEnvDefault is a convenience wrapper around +// [SDKLikeEnvDefault] which returns an error if the result is still the +// empty string even after trying all of the fallback environment variables. +// +// This wrapper requires an additional argument specifying the attribute name +// just because that becomes part of the returned error message. +func SDKLikeRequiredWithEnvDefault(attrPath string, v string, envNames ...string) (string, error) { + ret := SDKLikeEnvDefault(v, envNames...) + if ret == "" { + return "", fmt.Errorf("attribute %q is required", attrPath) + } + return ret, nil +} diff --git a/internal/backend/backendbase/sdklike_test.go b/internal/backend/backendbase/sdklike_test.go new file mode 100644 index 0000000000..5d88e1fb05 --- /dev/null +++ b/internal/backend/backendbase/sdklike_test.go @@ -0,0 +1,204 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package backendbase + +import ( + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestSDKLikePath(t *testing.T) { + tests := []struct { + Input string + Want cty.Path + }{ + { + "foo", + cty.GetAttrPath("foo"), + }, + { + "foo.bar", + cty.GetAttrPath("foo").GetAttr("bar"), + }, + { + "foo.bar.baz", + cty.GetAttrPath("foo").GetAttr("bar").GetAttr("baz"), + }, + } + + for _, test := range tests { + t.Run(test.Input, func(t *testing.T) { + got := SDKLikePath(test.Input) + if !test.Want.Equals(got) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} + +func TestSDKLikeEnvDefault(t *testing.T) { + t.Setenv("FALLBACK_A", "fallback a") + t.Setenv("FALLBACK_B", "fallback b") + t.Setenv("FALLBACK_UNSET", "") + t.Setenv("FALLBACK_UNSET_1", "") + t.Setenv("FALLBACK_UNSET_2", "") + + tests := map[string]struct { + Value string + EnvNames []string + Want string + }{ + "value is set": { + "hello", + []string{"FALLBACK_A", "FALLBACK_B"}, + "hello", + }, + "value is not set, but both fallbacks are": { + "", + []string{"FALLBACK_A", "FALLBACK_B"}, + "fallback a", + }, + "value is not set, and first callback isn't set": { + "", + []string{"FALLBACK_UNSET", "FALLBACK_B"}, + "fallback b", + }, + "value is not set, and second callback isn't set": { + "", + []string{"FALLBACK_A", "FALLBACK_UNSET"}, + "fallback a", + }, + "nothing is set": { + "", + []string{"FALLBACK_UNSET_1", "FALLBACK_UNSET_2"}, + "", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + got := SDKLikeEnvDefault(test.Value, test.EnvNames...) + if got != test.Want { + t.Errorf("wrong result\nvalue: %s\nenvs: %s\n\ngot: %s\nwant: %s", test.Value, test.EnvNames, got, test.Want) + } + }) + } +} + +func TestSDKLikeRequiredWithEnvDefault(t *testing.T) { + // This intentionally doesn't duplicate all of the test cases from + // TestSDKLikeEnvDefault, since SDKLikeRequiredWithEnvDefault is + // just a thin wrapper which adds an error check. + + t.Setenv("FALLBACK_UNSET", "") + _, err := SDKLikeRequiredWithEnvDefault("attr_name", "", "FALLBACK_UNSET") + if err == nil { + t.Fatalf("unexpected success; want error") + } + if got, want := err.Error(), `attribute "attr_name" is required`; got != want { + t.Errorf("wrong error\ngot: %s\nwant: %s", got, want) + } +} + +func TestSDKLikeData(t *testing.T) { + d := NewSDKLikeData(cty.ObjectVal(map[string]cty.Value{ + "string": cty.StringVal("hello"), + "int": cty.NumberIntVal(5), + "float": cty.NumberFloatVal(0.5), + "bool": cty.True, + + "null_string": cty.NullVal(cty.String), + "null_number": cty.NullVal(cty.Number), + "null_bool": cty.NullVal(cty.Bool), + })) + + t.Run("string", func(t *testing.T) { + got := d.String("string") + want := "hello" + if got != want { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, want) + } + }) + t.Run("null string", func(t *testing.T) { + got := d.String("null_string") + want := "" + if got != want { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, want) + } + }) + t.Run("int as string", func(t *testing.T) { + // This is allowed as a convenience for backends that want to + // allow environment-based default values for integer values, + // since environment variables are always strings and so they'd + // need to do their own parsing afterwards anyway. + got := d.String("int") + want := "5" + if got != want { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, want) + } + }) + t.Run("bool as string", func(t *testing.T) { + // This is allowed as a convenience for backends that want to + // allow environment-based default values for bool values, + // since environment variables are always strings and so they'd + // need to do their own parsing afterwards anyway. + got := d.String("bool") + want := "true" + if got != want { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, want) + } + }) + + t.Run("int", func(t *testing.T) { + got, err := d.Int64("int") + want := int64(5) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if got != want { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, want) + } + }) + t.Run("int with fractional part", func(t *testing.T) { + got, err := d.Int64("float") + if err == nil { + t.Fatalf("unexpected success; want error\ngot: %#v", got) + } + // Legacy SDK exposed the strconv.ParseInt implementation detail in + // its error message, and so for now we do the same. Maybe we'll + // improve this later, but it would probably be better to wean + // the backends off using the "SDKLike" helper altogether instead. + if got, want := err.Error(), `strconv.ParseInt: parsing "0.5": invalid syntax`; got != want { + t.Errorf("wrong error\ngot: %s\nwant: %s", got, want) + } + }) + t.Run("null number as int", func(t *testing.T) { + got, err := d.Int64("null_number") + want := int64(0) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if got != want { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, want) + } + }) + + t.Run("bool", func(t *testing.T) { + got := d.Bool("bool") + want := true + if got != want { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, want) + } + }) + t.Run("null bool", func(t *testing.T) { + // Assuming false for a null is quite questionable, but it's what + // the legacy SDK did and so we'll follow its lead. + got := d.Bool("null_bool") + want := false + if got != want { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, want) + } + }) +}