Parameterize interpolation function (#117)

By specifying a function typed `LookupFunction` the caller can customize
which path expressions to interpolate and which ones to skip. When we
express dependencies between resources their values are known by
Terraform at deploy time. Therefore, we have to skip interpolation for
`${resources.jobs.my_job.id}` and instead rewrite it to
`${databricks_job.my_job.id}` before passing it along to Terraform.
This commit is contained in:
Pieter Noordhuis 2022-12-01 22:38:49 +01:00 committed by GitHub
parent 487bf6fd5c
commit cdc776d89e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 135 additions and 33 deletions

View File

@ -10,46 +10,48 @@ import (
"github.com/databricks/bricks/bundle" "github.com/databricks/bricks/bundle"
) )
const Delimiter = "."
var re = regexp.MustCompile(`\$\{(\w+(\.\w+)*)\}`) var re = regexp.MustCompile(`\$\{(\w+(\.\w+)*)\}`)
type stringField struct { type stringField struct {
rv reflect.Value path string
s setter
getter
setter
} }
func newStringField(path string, rv reflect.Value, s setter) *stringField { func newStringField(path string, g getter, s setter) *stringField {
return &stringField{ return &stringField{
rv: rv, path: path,
s: s,
}
}
func (s *stringField) String() string { getter: g,
return s.rv.String() setter: s,
}
} }
func (s *stringField) dependsOn() []string { func (s *stringField) dependsOn() []string {
var out []string var out []string
m := re.FindAllStringSubmatch(s.String(), -1) m := re.FindAllStringSubmatch(s.Get(), -1)
for i := range m { for i := range m {
out = append(out, m[i][1]) out = append(out, m[i][1])
} }
return out return out
} }
func (s *stringField) interpolate(lookup map[string]string) { func (s *stringField) interpolate(fn LookupFunction, lookup map[string]string) {
out := re.ReplaceAllStringFunc(s.String(), func(s string) string { out := re.ReplaceAllStringFunc(s.Get(), func(s string) string {
// Turn the whole match into the submatch. // Turn the whole match into the submatch.
match := re.FindStringSubmatch(s) match := re.FindStringSubmatch(s)
path := match[1] v, err := fn(match[1], lookup)
v, ok := lookup[path] if err != nil {
if !ok { panic(err)
panic(fmt.Sprintf("expected to find value for path: %s", path))
} }
return v return v
}) })
s.s.Set(out) s.Set(out)
} }
type accumulator struct { type accumulator struct {
@ -105,8 +107,8 @@ func (a *accumulator) walk(scope []string, rv reflect.Value, s setter) {
switch rv.Type().Kind() { switch rv.Type().Kind() {
case reflect.String: case reflect.String:
path := strings.Join(scope, ".") path := strings.Join(scope, Delimiter)
a.strings[path] = newStringField(path, rv, s) a.strings[path] = newStringField(path, anyGetter{rv}, s)
case reflect.Struct: case reflect.Struct:
a.walkStruct(scope, rv) a.walkStruct(scope, rv)
case reflect.Map: case reflect.Map:
@ -142,12 +144,12 @@ func (a *accumulator) gather(paths []string) (map[string]string, error) {
if len(deps) > 0 { if len(deps) > 0 {
return nil, fmt.Errorf("%s depends on %s", path, strings.Join(deps, ", ")) return nil, fmt.Errorf("%s depends on %s", path, strings.Join(deps, ", "))
} }
out[path] = f.rv.String() out[path] = f.Get()
} }
return out, nil return out, nil
} }
func expand(v any) error { func (a *accumulator) start(v any) {
rv := reflect.ValueOf(v) rv := reflect.ValueOf(v)
if rv.Type().Kind() != reflect.Pointer { if rv.Type().Kind() != reflect.Pointer {
panic("expect pointer") panic("expect pointer")
@ -156,33 +158,42 @@ func expand(v any) error {
if rv.Type().Kind() != reflect.Struct { if rv.Type().Kind() != reflect.Struct {
panic("expect struct") panic("expect struct")
} }
acc := &accumulator{
strings: make(map[string]*stringField),
}
acc.walk([]string{}, rv, nilSetter{})
for path, v := range acc.strings { a.strings = make(map[string]*stringField)
a.walk([]string{}, rv, nilSetter{})
}
func (a *accumulator) expand(fn LookupFunction) error {
for path, v := range a.strings {
ds := v.dependsOn() ds := v.dependsOn()
if len(ds) == 0 { if len(ds) == 0 {
continue continue
} }
// Create map to be used for interpolation // Create map to be used for interpolation
m, err := acc.gather(ds) m, err := a.gather(ds)
if err != nil { if err != nil {
return fmt.Errorf("cannot interpolate %s: %w", path, err) return fmt.Errorf("cannot interpolate %s: %w", path, err)
} }
v.interpolate(m) v.interpolate(fn, m)
} }
return nil return nil
} }
type interpolate struct{} type interpolate struct {
fn LookupFunction
}
func Interpolate() bundle.Mutator { func (m *interpolate) expand(v any) error {
return &interpolate{} a := accumulator{}
a.start(v)
return a.expand(m.fn)
}
func Interpolate(fn LookupFunction) bundle.Mutator {
return &interpolate{fn: fn}
} }
func (m *interpolate) Name() string { func (m *interpolate) Name() string {
@ -190,6 +201,5 @@ func (m *interpolate) Name() string {
} }
func (m *interpolate) Apply(_ context.Context, b *bundle.Bundle) ([]bundle.Mutator, error) { func (m *interpolate) Apply(_ context.Context, b *bundle.Bundle) ([]bundle.Mutator, error) {
err := expand(&b.Config) return nil, m.expand(&b.Config)
return nil, err
} }

View File

@ -28,6 +28,12 @@ type foo struct {
F map[string]string `json:"f"` F map[string]string `json:"f"`
} }
func expand(v any) error {
a := accumulator{}
a.start(v)
return a.expand(DefaultLookup)
}
func TestInterpolationVariables(t *testing.T) { func TestInterpolationVariables(t *testing.T) {
f := foo{ f := foo{
A: "a", A: "a",

View File

@ -0,0 +1,35 @@
package interpolation
import (
"fmt"
"strings"
"golang.org/x/exp/slices"
)
// LookupFunction returns the value to rewrite a path expression to.
type LookupFunction func(path string, depends map[string]string) (string, error)
// DefaultLookup looks up the specified path in the map.
// It returns an error if it doesn't exist.
func DefaultLookup(path string, lookup map[string]string) (string, error) {
v, ok := lookup[path]
if !ok {
return "", fmt.Errorf("expected to find value for path: %s", path)
}
return v, nil
}
// ExcludeLookupsInPath is a lookup function that skips lookups for the specified path.
func ExcludeLookupsInPath(exclude ...string) LookupFunction {
return func(path string, lookup map[string]string) (string, error) {
parts := strings.Split(path, Delimiter)
// Skip interpolation of this path.
if len(parts) >= len(exclude) && slices.Compare(exclude, parts[0:len(exclude)]) == 0 {
return fmt.Sprintf("${%s}", path), nil
}
return DefaultLookup(path, lookup)
}
}

View File

@ -0,0 +1,39 @@
package interpolation
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExcludePath(t *testing.T) {
tmp := struct {
A map[string]string `json:"a"`
B map[string]string `json:"b"`
C map[string]string `json:"c"`
}{
A: map[string]string{
"x": "1",
},
B: map[string]string{
"x": "2",
},
C: map[string]string{
"ax": "${a.x}",
"bx": "${b.x}",
},
}
m := interpolate{
fn: ExcludeLookupsInPath("a"),
}
err := m.expand(&tmp)
require.NoError(t, err)
assert.Equal(t, "1", tmp.A["x"])
assert.Equal(t, "2", tmp.B["x"])
assert.Equal(t, "${a.x}", tmp.C["ax"])
assert.Equal(t, "2", tmp.C["bx"])
}

View File

@ -34,3 +34,15 @@ type mapSetter struct {
func (s mapSetter) Set(str string) { func (s mapSetter) Set(str string) {
s.m.SetMapIndex(s.k, reflect.ValueOf(str)) s.m.SetMapIndex(s.k, reflect.ValueOf(str))
} }
type getter interface {
Get() string
}
type anyGetter struct {
rv reflect.Value
}
func (g anyGetter) Get() string {
return g.rv.String()
}