backendbase: "SDK-like" helpers

A lot of the remote state backends' current Configure functions
unfortunately rely quite heavily on the "do the best you can" API of the
legacy SDK, which makes questionable shortcuts like treating null as
equivalent to the Go zero value of a type, but in return makes it
convenient to assign into other data structures that happen to make
similar assumptions. And of course that means that the backends' other
data structures were built to make those similar assumptions!

This new set of utilities therefore aims to provide a bare-minimum subset
of "SDK-like" functionality to ease the initial migration away from the
legacy SDK. It isn't exactly equivalent an in particular focuses only on
the small subset of legacy SDK functionality that most existing backends
rely on, but this should at least make the initial adaptations a little
more mechanical so that we can continue to defer the full modernization
of these backends for some undetermined future time without having to
continue retaining the huge legacy SDK snapshot in this codebase.
pull/34814/head
Martin Atkins 2 years ago
parent a5d63a23ab
commit b16cb49fb6

@ -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
}

@ -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)
}
})
}
Loading…
Cancel
Save