mirror of https://github.com/hashicorp/terraform
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
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…
Reference in new issue