Skip to content

Commit

Permalink
feat: added metadata properties substitutions support (#16)
Browse files Browse the repository at this point in the history
Signed-off-by: Eugene Yarshevich <yarshevich@gmail.com>
  • Loading branch information
ghen committed Dec 22, 2022
1 parent fac1994 commit b4612b6
Show file tree
Hide file tree
Showing 3 changed files with 243 additions and 51 deletions.
56 changes: 5 additions & 51 deletions internal/helm/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,57 +9,11 @@ package helm

import (
"fmt"
"os"
"regexp"
"sort"
"strings"

score "github.com/score-spec/score-go/types"
)

// resourceRefRegex extracts the resource ID from the resource reference: '${resources.RESOURCE_ID}'
var resourceRefRegex = regexp.MustCompile(`\${resources\.(.+)}`)

// resourcesMap is an internal utility type to group some helper methods.
type resourcesMap struct {
Spec map[string]score.ResourceSpec
Values map[string]interface{}
}

// mapResourceVar maps resources properties references.
// Returns an empty string if the reference can't be resolved.
func (r resourcesMap) mapVar(ref string) string {
if ref == "$" {
return ref
}

var segments = strings.SplitN(ref, ".", 3)
if segments[0] != "resources" || len(segments) != 3 {
return ""
}

var resName = segments[1]
var propName = segments[2]
if res, ok := r.Spec[resName]; ok {
if prop, ok := res.Properties[propName]; ok {

// Look-up the value for the property
if src, ok := r.Values[resName]; ok {
if srcMap, ok := src.(map[string]interface{}); ok {
if val, ok := srcMap[propName]; ok {
return fmt.Sprintf("%v", val)
}
}
}

// Use the default value provided (if any)
return fmt.Sprintf("%v", prop.Default)
}
}

return ""
}

// getProbeDetails extracts an httpGet probe details from the source spec.
// Returns nil if the source spec is empty.
func getProbeDetails(probe *score.ContainerProbeSpec) map[string]interface{} {
Expand Down Expand Up @@ -89,9 +43,9 @@ func ConvertSpec(dest map[string]interface{}, spec *score.WorkloadSpec, values m
if values == nil {
values = make(map[string]interface{})
}
var resourcesSpec = resourcesMap{
Spec: spec.Resources,
Values: values,
context, err := buildContext(spec.Metadata, spec.Resources, values)
if err != nil {
return fmt.Errorf("preparing context: %w", err)
}

if len(spec.Service.Ports) > 0 {
Expand Down Expand Up @@ -138,7 +92,7 @@ func ConvertSpec(dest map[string]interface{}, spec *score.WorkloadSpec, values m
if len(cSpec.Variables) > 0 {
var env = make([]interface{}, 0, len(cSpec.Variables))
for key, val := range cSpec.Variables {
val = os.Expand(val, resourcesSpec.mapVar)
val = context.Substitute(val)
env = append(env, map[string]interface{}{"name": key, "value": val})
}

Expand All @@ -153,7 +107,7 @@ func ConvertSpec(dest map[string]interface{}, spec *score.WorkloadSpec, values m
if len(cSpec.Volumes) > 0 {
var volumes = make([]interface{}, 0, len(cSpec.Volumes))
for _, vol := range cSpec.Volumes {
var source = resourceRefRegex.ReplaceAllString(vol.Source, "$1")
var source = context.Substitute(vol.Source)
var vVals = map[string]interface{}{
"name": source,
"subPath": vol.Path,
Expand Down
99 changes: 99 additions & 0 deletions internal/helm/templates.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
Apache Score
Copyright 2022 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
*/
package helm

import (
"fmt"
"log"
"os"

"github.com/mitchellh/mapstructure"

score "github.com/score-spec/score-go/types"
)

// templatesContext ia an utility type that provides a context for '${...}' templates substitution
type templatesContext map[string]string

// buildContext initializes a new templatesContext instance
func buildContext(metadata score.WorkloadMeta, resources score.ResourcesSpecs, values map[string]interface{}) (templatesContext, error) {
var ctx = make(map[string]string)

var metadataMap = make(map[string]interface{})
if decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
TagName: "json",
Result: &metadataMap,
}); err != nil {
return nil, err
} else {
decoder.Decode(metadata)
for key, val := range metadataMap {
var ref = fmt.Sprintf("metadata.%s", key)
if _, exists := ctx[ref]; exists {
return nil, fmt.Errorf("ambiguous property reference '%s'", ref)
}
ctx[ref] = fmt.Sprintf("%v", val)
}
}

for resName, res := range resources {
ctx[fmt.Sprintf("resources.%s", resName)] = resName

for propName, prop := range res.Properties {
var ref = fmt.Sprintf("resources.%s.%s", resName, propName)
if _, exists := ctx[ref]; exists {
return nil, fmt.Errorf("ambiguous property reference '%s'", ref)
}

// Use the default value provided (if any)
var val = fmt.Sprintf("%v", prop.Default)

// Override the default value for the property (if provided)
if src, ok := values[resName]; ok {
if srcMap, ok := src.(map[string]interface{}); ok {
if v, ok := srcMap[propName]; ok {
val = fmt.Sprintf("%v", v)
}
}
}

ctx[ref] = val
}
}

return ctx, nil
}

// Substitute replaces all matching '${...}' templates in a source string
func (context templatesContext) Substitute(src string) string {
return os.Expand(src, context.mapVar)
}

// MapVar replaces objects and properties references with corresponding values
// Returns an empty string if the reference can't be resolved
func (context templatesContext) mapVar(ref string) string {
if ref == "" {
return ""
}

// NOTE: os.Expand(..) would invoke a callback function with "$" as an argument for escaped sequences.
// "$${abc}" is treated as "$$" pattern and "{abc}" static text.
// The first segment (pattern) would trigger a callback function call.
// By returning "$" value we would ensure that escaped sequences would remain in the source text.
// For example "$${abc}" would result in "${abc}" after os.Expand(..) call.
if ref == "$" {
return ref
}

if res, ok := context[ref]; ok {
return res
}

log.Printf("Warning: Can not resolve '%s'. Resource or property is not declared.", ref)
return ""
}
139 changes: 139 additions & 0 deletions internal/helm/templates_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
Apache Score
Copyright 2022 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
*/
package helm

import (
"testing"

score "github.com/score-spec/score-go/types"
assert "github.com/stretchr/testify/assert"
)

func TestBuildContext(t *testing.T) {
var meta = score.WorkloadMeta{
Name: "test-name",
}

var resources = score.ResourcesSpecs{
"env": score.ResourceSpec{
Type: "environment",
Properties: map[string]score.ResourcePropertySpec{
"DEBUG": {Required: false, Default: true},
},
},
"db": score.ResourceSpec{
Type: "postgres",
Properties: map[string]score.ResourcePropertySpec{
"host": {Required: true, Default: "."},
"port": {Required: true, Default: "5342"},
"name": {Required: true},
},
},
"dns": score.ResourceSpec{
Type: "dns",
Properties: map[string]score.ResourcePropertySpec{
"domain": {},
},
},
}

var values = map[string]interface{}{
"db": map[string]interface{}{
"host": "localhost",
"name": "test-db",
},
"dns": map[string]interface{}{
"domain": "test.domain.name",
},
}

context, err := buildContext(meta, resources, values)
assert.NoError(t, err)

assert.Equal(t, templatesContext{
"metadata.name": "test-name",

"resources.env": "env",
"resources.env.DEBUG": "true",

"resources.db": "db",
"resources.db.host": "localhost",
"resources.db.port": "5342",
"resources.db.name": "test-db",

"resources.dns": "dns",
"resources.dns.domain": "test.domain.name",
}, context)
}

func TestMapVar(t *testing.T) {
var context = templatesContext{
"metadata.name": "test-name",

"resources.env": "env",
"resources.env.DEBUG": "true",

"resources.db": "db",
"resources.db.host": "localhost",
"resources.db.port": "5342",
"resources.db.name": "test-db",

"resources.dns": "shared.dns",
"resources.dns.domain": "test.domain.name",
}

assert.Equal(t, "", context.mapVar(""))
assert.Equal(t, "$", context.mapVar("$"))

assert.Equal(t, "test-name", context.mapVar("metadata.name"))
assert.Equal(t, "", context.mapVar("metadata.name.nil"))
assert.Equal(t, "", context.mapVar("metadata.nil"))

assert.Equal(t, "true", context.mapVar("resources.env.DEBUG"))

assert.Equal(t, "db", context.mapVar("resources.db"))
assert.Equal(t, "localhost", context.mapVar("resources.db.host"))
assert.Equal(t, "5342", context.mapVar("resources.db.port"))
assert.Equal(t, "test-db", context.mapVar("resources.db.name"))
assert.Equal(t, "", context.mapVar("resources.db.name.nil"))
assert.Equal(t, "", context.mapVar("resources.db.nil"))
assert.Equal(t, "", context.mapVar("resources.nil"))
assert.Equal(t, "", context.mapVar("nil.db.name"))
}

func TestSubstitute(t *testing.T) {
var context = templatesContext{
"metadata.name": "test-name",

"resources.env": "env",
"resources.env.DEBUG": "true",

"resources.db": "db",
"resources.db.host": "localhost",
"resources.db.port": "5342",
"resources.db.name": "test-db",

"resources.dns": "dns",
"resources.dns.domain": "test.domain.name",
}

assert.Equal(t, "", context.Substitute(""))
assert.Equal(t, "abc", context.Substitute("abc"))
assert.Equal(t, "abc $ abc", context.Substitute("abc $$ abc"))
assert.Equal(t, "${abc}", context.Substitute("$${abc}"))

assert.Equal(t, "The name is 'test-name'", context.Substitute("The name is '${metadata.name}'"))
assert.Equal(t, "The name is ''", context.Substitute("The name is '${metadata.nil}'"))

assert.Equal(t, "resources.env.DEBUG", context.Substitute("resources.env.DEBUG"))

assert.Equal(t, "db", context.Substitute("${resources.db}"))
assert.Equal(t,
"postgresql://:@localhost:5342/test-db",
context.Substitute("postgresql://${resources.db.user}:${resources.db.password}@${resources.db.host}:${resources.db.port}/${resources.db.name}"))
}

0 comments on commit b4612b6

Please sign in to comment.