Skip to content
This repository has been archived by the owner on Jan 8, 2024. It is now read-only.

waypoint.hcl: "labels" variable and selector functions #2065

Merged
merged 12 commits into from
Aug 23, 2021
8 changes: 8 additions & 0 deletions .changelog/2065.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
```release-note:feature
config: `labels` variable for accessing the label set of an operation
```

```release-note:feature
config: new functions `selectormatch` and `selectorlookup` for working with
label selectors
```
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ require (
github.com/hashicorp/aws-sdk-go-base v0.7.0
github.com/hashicorp/cap v0.1.1
github.com/hashicorp/go-argmapper v0.2.1
github.com/hashicorp/go-bexpr v0.1.7
github.com/hashicorp/go-bexpr v0.1.10
github.com/hashicorp/go-cleanhttp v0.5.2
github.com/hashicorp/go-gcp-common v0.6.0
github.com/hashicorp/go-getter v1.4.1
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -817,8 +817,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-argmapper v0.2.1 h1:692fiW17bQvRrdLHMyxiYg8BjwbfNjZ9/fmYY3UXtaE=
github.com/hashicorp/go-argmapper v0.2.1/go.mod h1:WA3PocIo+40wf4ko3dRdL3DEgxIQB4qaqp+jVccLV1I=
github.com/hashicorp/go-bexpr v0.1.7 h1:z48qzCgJkvdnMO/LDy3XHNyCyxnHiFGx9uTKLv0jW2Y=
github.com/hashicorp/go-bexpr v0.1.7/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0=
github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE=
github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0=
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
Expand Down
77 changes: 77 additions & 0 deletions internal/config/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ type hclApp struct {
Remain hcl.Body `hcl:",remain"`
}

// hclLabeled is used to partially decode only the labels from a
// structure that supports it.
type hclLabeled struct {
Labels map[string]string `hcl:"labels,optional"`
Remain hcl.Body `hcl:",remain"`
}

// Apps returns the names of all the apps.
func (c *Config) Apps() []string {
var result []string
Expand Down Expand Up @@ -256,3 +263,73 @@ func (c *App) ReleaseUse() string {

return c.ReleaseRaw.Use.Type
}

// BuildLabels returns the labels for this stage.
func (c *App) BuildLabels(ctx *hcl.EvalContext) (map[string]string, error) {
if c.BuildRaw == nil {
return nil, nil
}

ctx = appendContext(c.ctx, ctx)
return labels(ctx, c.BuildRaw.Body)
}

// RegistryLabels returns the labels for this stage.
func (c *App) RegistryLabels(ctx *hcl.EvalContext) (map[string]string, error) {
if c.BuildRaw == nil || c.BuildRaw.Registry == nil {
return nil, nil
}

ctx = appendContext(c.ctx, ctx)

// Get both build and registry labels
allLabels, err := labels(ctx, c.BuildRaw.Body)
if err != nil {
return nil, err
}
registryLabels, err := labels(ctx, c.BuildRaw.Registry.Body)
if err != nil {
return nil, err
}

// Merge em
for k, v := range registryLabels {
allLabels[k] = v
}

return allLabels, nil
}

// DeployLabels returns the labels for this stage.
func (c *App) DeployLabels(ctx *hcl.EvalContext) (map[string]string, error) {
if c.DeployRaw == nil {
return nil, nil
}

ctx = appendContext(c.ctx, ctx)
return labels(ctx, c.DeployRaw.Body)
}

// ReleaseLabels returns the labels for this stage.
func (c *App) ReleaseLabels(ctx *hcl.EvalContext) (map[string]string, error) {
if c.ReleaseRaw == nil {
return nil, nil
}

ctx = appendContext(c.ctx, ctx)
return labels(ctx, c.ReleaseRaw.Body)
}

// labels reads the labels from the given body (if they are available),
// merges them using lm, and returns the final merged set of labels. This
// also returns a new EvalContext that has the `labels` HCL variable set.
func labels(ctx *hcl.EvalContext, body hcl.Body) (map[string]string, error) {
// First decode into our structure that only reads labels.
var labeled hclLabeled
if diag := gohcl.DecodeBody(body, finalizeContext(ctx), &labeled); diag.HasErrors() {
return nil, diag
}

// Merge em
return labeled.Labels, nil
}
1 change: 1 addition & 0 deletions internal/config/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ func TestAppValidate(t *testing.T) {
type testPluginBuildConfig struct {
config struct {
Foo string `hcl:"foo,attr"`
Bar string `hcl:"bar,optional"`
}
}

Expand Down
2 changes: 2 additions & 0 deletions internal/config/funcs/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ func Docs() map[string]string {
"regex": "match the given string against the given regular expression pattern, returning captures if defined",
"regexall": "same as regex but look for all matches of the given pattern rather than just the first",
"reverse": "reverse the order of the elements in the list",
"selectormatch": "applies a selector to a map and returns true if the selector matches",
"selectorlookup": "applies a map of selectors to a map of labels. The value for the first matching selector is returned. If none match, the default is returned.",
"setintersection": "takes multiple sets and produces a single set containing only the elements that all of the given sets have in common. In other words, it computes the intersection of the sets",
"setproduct": "finds all of the possible combinations of elements from all of the given sets by computing the Cartesian product",
"setsubtract": "returns a new set containing the elements from the first set that are not present in the second set. In other words, it computes the relative complement of the first set in the second set",
Expand Down
119 changes: 119 additions & 0 deletions internal/config/funcs/selector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package funcs

import (
"errors"

"github.com/hashicorp/go-bexpr"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
"github.com/zclconf/go-cty/cty/gocty"
)

func Selector() map[string]function.Function {
return map[string]function.Function{
"selectormatch": SelectorMatchFunc,
"selectorlookup": SelectorLookupFunc,
}
}

// SelectorMatchFunc constructs a function that applies a label selector
// to a map and returns true/false if there is a match.
var SelectorMatchFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "map",
Type: cty.Map(cty.String),
},
{
Name: "selector",
Type: cty.String,
},
},
Type: function.StaticReturnType(cty.Bool),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
s := args[1].AsString()
eval, err := bexpr.CreateEvaluator(s)
if err != nil {
return cty.UnknownVal(cty.String), err
}

var m map[string]string
if err := gocty.FromCtyValue(args[0], &m); err != nil {
return cty.UnknownVal(cty.String), err
}

result, err := eval.Evaluate(m)
if err != nil {
return cty.UnknownVal(cty.String), err
}

return cty.BoolVal(result), nil
},
})

// SelectorLookupFunc constructs a function that applies a label selector
// to a map and returns true/false if there is a match.
var SelectorLookupFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "map",
Type: cty.Map(cty.String),
},
{
Name: "selectormap",
Type: cty.Map(cty.DynamicPseudoType),
},
{
Name: "default",
Type: cty.DynamicPseudoType,
},
},
Type: func(args []cty.Value) (ret cty.Type, err error) {
expected := args[1].Type().ElementType()
if !args[2].Type().Equals(expected) {
return cty.NilType, errors.New("default value type must match types of selector map")
}

return expected, nil
},
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
var m map[string]string
if err := gocty.FromCtyValue(args[0], &m); err != nil {
return cty.UnknownVal(cty.String), err
}

// Go through the selector map and find one that matches.
for it := args[1].ElementIterator(); it.Next(); {
key, val := it.Element()

s := key.AsString()
eval, err := bexpr.CreateEvaluator(s)
if err != nil {
return cty.UnknownVal(cty.String), err
}

result, err := eval.Evaluate(m)
if err != nil {
return cty.UnknownVal(cty.String), err
}

if result {
return val, nil
}
}

return args[2], nil
},
})

// SelectorMatch applies a selector to a map and returns true if the selector
// matches. The selector should be in go-bexpr format.
func SelectorMatch(m, selector cty.Value) (cty.Value, error) {
return SelectorMatchFunc.Call([]cty.Value{m, selector})
}

// SelectorLookup applies a selector to a map and returns true if the selector
// matches. The selector should be in go-bexpr format.
func SelectorLookup(m, selector, def cty.Value) (cty.Value, error) {
return SelectorLookupFunc.Call([]cty.Value{m, selector, def})
}
135 changes: 135 additions & 0 deletions internal/config/funcs/selector_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package funcs

import (
"fmt"
"testing"

"github.com/zclconf/go-cty/cty"
)

func TestSelectorMatch(t *testing.T) {
tests := []struct {
Map map[string]string
Selector string
Want cty.Value
Err bool
}{
{
map[string]string{"env": "production"},
"env == production",
cty.BoolVal(true),
false,
},

{
map[string]string{"env": "production"},
"env != production",
cty.BoolVal(false),
false,
},

{
map[string]string{"waypoint/workspace": "foo"},
"waypoint/workspace == foo",
cty.BoolVal(true),
false,
},
}

for _, test := range tests {
t.Run(fmt.Sprintf("selectormatch(%#v, %#v)", test.Map, test.Selector), func(t *testing.T) {
// Build our map val
mapValues := map[string]cty.Value{}
for k, v := range test.Map {
mapValues[k] = cty.StringVal(v)
}

got, err := SelectorMatch(cty.MapVal(mapValues), cty.StringVal(test.Selector))

if test.Err {
if err == nil {
t.Fatal("succeeded; want error")
}
return
} else if err != nil {
t.Fatalf("unexpected error: %s", err)
}

if !got.RawEquals(test.Want) {
t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want)
}
})
}
}

func TestSelectorLookup(t *testing.T) {
tests := []struct {
Map map[string]string
SelectorMap map[string]cty.Value
Default cty.Value
Want cty.Value
Err bool
}{
{
map[string]string{"env": "production"},
map[string]cty.Value{
"env == production": cty.StringVal("prod"),
"env == staging": cty.StringVal("staging"),
},
cty.StringVal("unknown"),
cty.StringVal("prod"),
false,
},

{
map[string]string{"env": "other"},
map[string]cty.Value{
"env == production": cty.StringVal("prod"),
"env == staging": cty.StringVal("staging"),
},
cty.StringVal("unknown"),
cty.StringVal("unknown"),
false,
},

{
map[string]string{"env": "production"},
map[string]cty.Value{
"env == production": cty.StringVal("prod"),
"env == staging": cty.StringVal("staging"),
},
cty.BoolVal(false),
cty.StringVal("prod"),
true,
},
}

for _, test := range tests {
t.Run(fmt.Sprintf("selectormatch(%#v, %#v)", test.Map, test.SelectorMap), func(t *testing.T) {
// Build our map val
mapValues := map[string]cty.Value{}
for k, v := range test.Map {
mapValues[k] = cty.StringVal(v)
}

got, err := SelectorLookup(
cty.MapVal(mapValues),
cty.MapVal(test.SelectorMap),
test.Default,
)

if test.Err {
if err == nil {
t.Fatal("succeeded; want error")
}
return
} else if err != nil {
t.Fatalf("unexpected error: %s", err)
}

if !got.RawEquals(test.Want) {
t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want)
}
})
}
}
Loading