Skip to content

Commit

Permalink
feat: allow basic templating in tag and context configuration
Browse files Browse the repository at this point in the history
  • Loading branch information
edaniszewski committed Mar 30, 2020
1 parent 6174cd1 commit 200b84b
Show file tree
Hide file tree
Showing 5 changed files with 316 additions and 2 deletions.
45 changes: 44 additions & 1 deletion sdk/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ type Device struct {
}

// NewDeviceFromConfig creates a new instance of a Device from its device prototype
// and device instance configuration.
// and device instance configuration. This is the primary and recommended way of
// building devices.
//
// These configuration components are loaded from config file.
//
Expand Down Expand Up @@ -273,6 +274,11 @@ func NewDeviceFromConfig(
writeTimeout = defaultWriteTimeout
}

// Render any templates which may exist in the context.
if err := parseContext(context); err != nil {
return nil, err
}

d := &Device{
Type: deviceType,
Tags: deviceTags,
Expand All @@ -299,6 +305,43 @@ func NewDeviceFromConfig(
return d, nil
}

// parseContext is a utility function to parse the context map and render any templates
// which may exist in the context values.
func parseContext(ctx map[string]string) error {
ctxTmpl := template.New("ctx").Funcs(template.FuncMap{
"env": os.Getenv,
})

for k := range ctx {
val := ctx[k]
if val == "" {
continue
}

tmpl, err := ctxTmpl.Parse(val)
if err != nil {
return err
}
buf := bytes.Buffer{}
if err := tmpl.Execute(&buf, val); err != nil {
return err
}

// NOTE: The SDK does not currently verify that the specified environment
// variable was actually set. This is left to the configurer. Is this an
// okay assumption/design choice?
newVal := buf.String()
if newVal == "" {
log.WithFields(log.Fields{
"key": k,
"value": val,
}).Warn("[device] template detected in device context, but no value rendered for parsed template")
}
ctx[k] = newVal
}
return nil
}

// AliasContext is the context that is used to render alias templates.
type AliasContext struct {
Meta *PluginMetadata
Expand Down
2 changes: 1 addition & 1 deletion sdk/device_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ func (manager *deviceManager) loadDynamicConfig() error {
if err != nil {
switch manager.policies.DynamicDeviceConfig {
case policy.Optional:
log.Info("[device manager] failed dynamic device config; skipping since its optional")
log.WithField("err", err).Info("[device manager] failed dynamic device config; skipping since its optional")
continue
case policy.Required:
log.Error("[device manager] failed dynamic device config; erroring since its required")
Expand Down
148 changes: 148 additions & 0 deletions sdk/device_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package sdk

import (
"fmt"
"os"
"testing"
"time"

Expand Down Expand Up @@ -700,6 +701,78 @@ func TestNewDeviceFromConfig14(t *testing.T) {
assert.Contains(t, err.Error(), "unknown handler specified")
}

func TestNewDeviceFromConfig15(t *testing.T) {
// Tests creating a device with tags and context including templates
proto := &config.DeviceProto{
Type: "type1",
Data: map[string]interface{}{
"port": 5000,
},
Context: map[string]string{
"foo": "bar",
"123": "456",
},
Tags: []string{`default/{{ env "FOO" }}`},
Handler: "testhandler",
WriteTimeout: 3 * time.Second,
}
instance := &config.DeviceInstance{
Type: "type2",
Info: "testdata",
Data: map[string]interface{}{
"address": "localhost",
},
Context: map[string]string{
"123": "abc",
"xyz": `{{ env "BAR" }}`,
},
Output: "temperature",
SortIndex: 1,
Handler: "testhandler2",
Alias: &config.DeviceAlias{
Name: "foo",
},
ScalingFactor: "2",
WriteTimeout: 5 * time.Second,
DisableInheritance: false,
}

// Set ENV vars for the test case.
testEnv := map[string]string{
"FOO": "foo",
"BAR": "bar",
}
// Setup the environment for the test case.
for k, v := range testEnv {
err := os.Setenv(k, v)
assert.NoError(t, err)
}
defer func() {
for k := range testEnv {
err := os.Unsetenv(k)
assert.NoError(t, err)
}
}()

t1, _ := NewTag("default/foo")

device, err := NewDeviceFromConfig(proto, instance, testHandlers)
assert.NoError(t, err)
assert.Equal(t, "type2", device.Type)
assert.Equal(t, "testdata", device.Info)
assert.Equal(t, 1, len(device.Tags))
assert.Equal(t, t1, device.Tags[0])
assert.Equal(t, map[string]interface{}{"address": "localhost", "port": 5000}, device.Data)
assert.Equal(t, map[string]string{"foo": "bar", "123": "abc", "xyz": "bar"}, device.Context)
assert.Equal(t, "testhandler2", device.Handler)
assert.Equal(t, int32(1), device.SortIndex)
assert.Equal(t, "foo", device.Alias)
assert.Equal(t, float64(2), device.ScalingFactor)
assert.Equal(t, 5*time.Second, device.WriteTimeout)
assert.Equal(t, "temperature", device.Output)
assert.Equal(t, 0, len(device.fns))
}

func TestDevice_setAlias_noConf(t *testing.T) {
device := Device{}

Expand Down Expand Up @@ -1095,3 +1168,78 @@ func TestDevice_encode_3(t *testing.T) {
assert.Equal(t, 0, len(encoded.Outputs))
assert.Equal(t, int32(1), encoded.SortIndex)
}

func TestDevice_parseContext(t *testing.T) {
tests := []struct {
name string
ctx map[string]string
expected map[string]string
}{
{
name: "no template",
ctx: map[string]string{"foo": "bar", "abc": "123"},
expected: map[string]string{"foo": "bar", "abc": "123"},
},
{
name: "template whole value",
ctx: map[string]string{"foo": `{{ env "BAR" }}`},
expected: map[string]string{"foo": "bar"},
},
{
name: "template part of value",
ctx: map[string]string{"foo": `value-{{ env "TEST_ENV_VAL_1" }}`},
expected: map[string]string{"foo": "value-1"},
},
{
name: "multiple env template",
ctx: map[string]string{
"first": `{{env "FOO"}}`,
"second": `val-{{env "FOO"}}-{{ env "BAR" }}`,
"third": `{{ env "FOO" }}.{{ env "TEST_ENV_VAL_1" }}`,
},
expected: map[string]string{
"first": "foo",
"second": "val-foo-bar",
"third": "foo.1",
},
},
{
name: "no env set",
ctx: map[string]string{"foo": `{{ env "ENV_VALUE_NOT_SET" }}`},
expected: map[string]string{"foo": ""},
},
}

testEnv := map[string]string{
"FOO": "foo",
"BAR": "bar",
"TEST_ENV_VAL_1": "1",
}
// Setup the environment for the test case.
for k, v := range testEnv {
err := os.Setenv(k, v)
assert.NoError(t, err)
}
defer func() {
for k := range testEnv {
err := os.Unsetenv(k)
assert.NoError(t, err)
}
}()

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := parseContext(test.ctx)
assert.NoError(t, err, test.name)
assert.Equal(t, test.expected, test.ctx, test.name)
})
}
}

func TestDevice_parseContextError(t *testing.T) {
ctx := map[string]string{
"foo": `{{ foobar "ENV_VALUE_NOT_SET" }}`, // no such function
}
err := parseContext(ctx)
assert.Error(t, err)
}
25 changes: 25 additions & 0 deletions sdk/tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@
package sdk

import (
"bytes"
"fmt"
"os"
"regexp"
"strings"
"text/template"

log "github.com/sirupsen/logrus"
synse "github.com/vapor-ware/synse-server-grpc/go"
Expand All @@ -39,6 +42,12 @@ const (
TagLabelAll = "**"
)

var (
tagsTmpl = template.New("tags").Funcs(template.FuncMap{
"env": os.Getenv,
})
)

// Tag represents a group identifier which a Synse device can belong to.
type Tag struct {
Namespace string
Expand All @@ -50,6 +59,22 @@ type Tag struct {

// NewTag creates a new Tag from a tag string.
func NewTag(tag string) (*Tag, error) {
if tag == "" {
return nil, fmt.Errorf("cannot create tag from empty string")
}

// First, attempt to parse the tag string as if it were a template - this could be
// the case if part of the tag is templated out in config, e.g. "foo/bar:{{ env BAZ }}".
tmpl, err := tagsTmpl.Parse(tag)
if err != nil {
return nil, err
}
buf := bytes.Buffer{}
if err := tmpl.Execute(&buf, tag); err != nil {
return nil, err
}
tag = buf.String()

tag = strings.TrimSpace(tag)
if strings.Contains(tag, " ") {
log.WithField("tag", tag).Error("[tag] invalid: tag must not contain spaces")
Expand Down
Loading

0 comments on commit 200b84b

Please sign in to comment.