diff --git a/internal/helm/convert.go b/internal/helm/convert.go index 9365d9a..72ff2ca 100644 --- a/internal/helm/convert.go +++ b/internal/helm/convert.go @@ -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{} { @@ -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 { @@ -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}) } @@ -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, diff --git a/internal/helm/templates.go b/internal/helm/templates.go new file mode 100644 index 0000000..87d24ef --- /dev/null +++ b/internal/helm/templates.go @@ -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 "" +} diff --git a/internal/helm/templates_test.go b/internal/helm/templates_test.go new file mode 100644 index 0000000..38101c7 --- /dev/null +++ b/internal/helm/templates_test.go @@ -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}")) +}