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

Commit

Permalink
feat: added metadata properties substitutions support (#27)
Browse files Browse the repository at this point in the history
Signed-off-by: Eugene Yarshevich <yarshevich@gmail.com>
  • Loading branch information
ghen authored Dec 22, 2022
1 parent 5ed0a9b commit cc9f731
Show file tree
Hide file tree
Showing 4 changed files with 319 additions and 186 deletions.
94 changes: 9 additions & 85 deletions internal/humanitec/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ package humanitec

import (
"fmt"
"log"
"os"
"strings"

mergo "github.com/imdario/mergo"
Expand All @@ -19,80 +17,6 @@ import (
humanitec "github.com/score-spec/score-humanitec/internal/humanitec_go/types"
)

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

// mapVar maps resources and their properties references.
// When used with os.Expand(..):
// - Resource reference, such as "${resources.dns}", is expanded as "externals.dns" (an example).
// - Resource property reference, such as "${resources.dns.domain}", is expanded as "${externals.dns.domain}" (an example).
// - Returns an original string if the reference can't be resolved, e.g. "${some.other.reference}" is expanded as "${some.other.reference}".
// - Escaped sequences left as-is, e.g. "$${values.DEBUG}" is expanded as "${values.DEBUG}".
func (r resourcesMap) mapVar(ref string) string {
if ref == "$" {
return ref
}

var segments = strings.SplitN(ref, ".", 3)
if segments[0] != "resources" || len(segments) < 2 {
return fmt.Sprintf("${%s}", ref)
}

var resName = segments[1]
res, ok := r.Spec[resName]
if !ok {
log.Printf("Warning: Can not resolve '%s'. Resource '%s' is not declared.", ref, resName)
return fmt.Sprintf("${%s}", ref)
}

var source string
switch res.Type {
case "environment":
source = "values"
case "workload":
source = fmt.Sprintf("modules.%s", resName)
default:
if meta, exists := r.Meta[resName]; exists && meta.Scope == "shared" {
source = fmt.Sprintf("shared.%s", resName)
} else {
source = fmt.Sprintf("externals.%s", resName)
}
}

if len(segments) == 2 {
return source
}

var propName = segments[2]
if _, ok := res.Properties[propName]; !ok {
log.Printf("Warning: Can not resolve '%s'. Property '%s' is not declared for '%s'.", ref, propName, resName)
return fmt.Sprintf("${%s}", ref)
}

return fmt.Sprintf("${%s.%s}", source, propName)
}

// mapAllVars maps resources properties references in map keys and string values recursively.
func (r resourcesMap) mapAllVars(src map[string]interface{}) map[string]interface{} {
var dst = make(map[string]interface{}, 0)

for key, val := range src {
key = os.Expand(key, r.mapVar)
switch v := val.(type) {
case string:
val = os.Expand(v, r.mapVar)
case map[string]interface{}:
val = r.mapAllVars(v)
}
dst[key] = val
}

return dst
}

// 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 All @@ -118,7 +42,7 @@ func getProbeDetails(probe *score.ContainerProbeSpec) map[string]interface{} {
}

// convertContainerSpec extracts a container details from the source spec.
func convertContainerSpec(name string, spec *score.ContainerSpec, resourcesSpec *resourcesMap) (map[string]interface{}, error) {
func convertContainerSpec(name string, spec *score.ContainerSpec, context *templatesContext) (map[string]interface{}, error) {
var containerSpec = map[string]interface{}{
"id": name,
}
Expand All @@ -134,7 +58,7 @@ func convertContainerSpec(name string, spec *score.ContainerSpec, resourcesSpec
if len(spec.Variables) > 0 {
var envVars = make(map[string]interface{}, len(spec.Variables))
for key, val := range spec.Variables {
envVars[key] = os.Expand(val, resourcesSpec.mapVar)
envVars[key] = context.Substitute(val)
}
containerSpec["variables"] = envVars
}
Expand All @@ -155,7 +79,7 @@ func convertContainerSpec(name string, spec *score.ContainerSpec, resourcesSpec
for _, f := range spec.Files {
files[f.Target] = map[string]interface{}{
"mode": f.Mode,
"value": os.Expand(strings.Join(f.Content, "\n"), resourcesSpec.mapVar),
"value": context.Substitute(strings.Join(f.Content, "\n")),
}
}
containerSpec["files"] = files
Expand All @@ -164,7 +88,7 @@ func convertContainerSpec(name string, spec *score.ContainerSpec, resourcesSpec
var volumes = map[string]interface{}{}
for _, vol := range spec.Volumes {
volumes[vol.Target] = map[string]interface{}{
"id": os.Expand(vol.Source, resourcesSpec.mapVar),
"id": context.Substitute(vol.Source),
"sub_path": vol.Path,
"read_only": vol.ReadOnly,
}
Expand All @@ -177,14 +101,14 @@ func convertContainerSpec(name string, spec *score.ContainerSpec, resourcesSpec

// ConvertSpec converts SCORE specification into Humanitec deployment delta.
func ConvertSpec(name, envID string, spec *score.WorkloadSpec, ext *extensions.HumanitecExtensionsSpec) (*humanitec.CreateDeploymentDeltaRequest, error) {
var resourcesSpec = resourcesMap{
Spec: spec.Resources,
Meta: ext.Resources,
context, err := buildContext(spec.Metadata, spec.Resources, ext.Resources)
if err != nil {
return nil, fmt.Errorf("preparing context: %w", err)
}

var containers = make(map[string]interface{}, len(spec.Containers))
for cName, cSpec := range spec.Containers {
if container, err := convertContainerSpec(cName, &cSpec, &resourcesSpec); err == nil {
if container, err := convertContainerSpec(cName, &cSpec, &context); err == nil {
containers[cName] = container
} else {
return nil, fmt.Errorf("processing container specification for '%s': %w", cName, err)
Expand Down Expand Up @@ -217,7 +141,7 @@ func ConvertSpec(name, envID string, spec *score.WorkloadSpec, ext *extensions.H
}

if ext != nil && len(ext.Spec) > 0 {
var features = resourcesSpec.mapAllVars(ext.Spec)
var features = context.SubstituteAll(ext.Spec)
if err := mergo.Merge(&workloadSpec, features); err != nil {
return nil, fmt.Errorf("applying workload profile features: %w", err)
}
Expand Down
101 changes: 0 additions & 101 deletions internal/humanitec/convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ The Apache Software Foundation (http://www.apache.org/).
package humanitec

import (
"os"
"testing"

score "github.com/score-spec/score-go/types"
Expand All @@ -17,106 +16,6 @@ import (
"github.com/stretchr/testify/assert"
)

func TestMapVar(t *testing.T) {
var res = resourcesMap{
Spec: map[string]score.ResourceSpec{
"env": {
Type: "environment",
Properties: map[string]score.ResourcePropertySpec{
"DEBUG": {},
},
},
"db": {
Type: "posgres",
Properties: map[string]score.ResourcePropertySpec{
"name": {},
},
},
"dns": {
Type: "dns",
Properties: map[string]score.ResourcePropertySpec{
"domain": {},
},
},
"service-b": {
Type: "workload",
Properties: map[string]score.ResourcePropertySpec{
"name": {},
},
},
},
Meta: extensions.HumanitecResourcesSpecs{
"dns": extensions.HumanitecResourceSpec{Scope: "shared"},
},
}

assert.Equal(t, "", os.Expand("", res.mapVar))
assert.Equal(t, "${bad.reference}", os.Expand("${bad.reference}", res.mapVar))
assert.Equal(t, "${escaped.sequence}", os.Expand("$${escaped.sequence}", res.mapVar))

assert.Equal(t, "${values.DEBUG}", os.Expand("${resources.env.DEBUG}", res.mapVar))
assert.Equal(t, "shared.dns", os.Expand("${resources.dns}", res.mapVar))
assert.Equal(t, "${externals.db.name}", os.Expand("${resources.db.name}", res.mapVar))
assert.Equal(t, "${shared.dns.domain}", os.Expand("${resources.dns.domain}", res.mapVar))
assert.Equal(t, "${modules.service-b.name}", os.Expand("${resources.service-b.name}", res.mapVar))
}

func TestMapAllVars(t *testing.T) {
var res = resourcesMap{
Spec: map[string]score.ResourceSpec{
"env": {
Type: "environment",
Properties: map[string]score.ResourcePropertySpec{
"DEBUG": {},
},
},
"db": {
Type: "posgres",
Properties: map[string]score.ResourcePropertySpec{
"name": {},
},
},
"dns": {
Type: "dns",
Properties: map[string]score.ResourcePropertySpec{
"domain": {},
},
},
"service-b": {
Type: "workload",
Properties: map[string]score.ResourcePropertySpec{
"name": {},
},
},
},
Meta: extensions.HumanitecResourcesSpecs{
"dns": extensions.HumanitecResourceSpec{Scope: "shared"},
},
}

var source = map[string]interface{}{
"api": map[string]interface{}{
"${resources.service-b.name}": map[string]interface{}{
"url": "http://${resources.dns.domain}",
"port": 80,
},
},
"DEBUG": "${resources.env.DEBUG}",
}

var expected = map[string]interface{}{
"api": map[string]interface{}{
"${modules.service-b.name}": map[string]interface{}{
"url": "http://${shared.dns.domain}",
"port": 80,
},
},
"DEBUG": "${values.DEBUG}",
}

assert.Equal(t, expected, res.mapAllVars(source))
}

func TestScoreConvert(t *testing.T) {
const (
envID = "test"
Expand Down
118 changes: 118 additions & 0 deletions internal/humanitec/templates.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
Apache Score
Copyright 2022 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
*/
package humanitec

import (
"fmt"
"log"
"os"

"github.com/mitchellh/mapstructure"

score "github.com/score-spec/score-go/types"
extensions "github.com/score-spec/score-humanitec/internal/humanitec/extensions"
)

// 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, ext extensions.HumanitecResourcesSpecs) (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 {
var source string
switch res.Type {
case "environment":
source = "values"
case "workload":
source = fmt.Sprintf("modules.%s", resName)
default:
if resExt, exists := ext[resName]; exists && resExt.Scope == "shared" {
source = fmt.Sprintf("shared.%s", resName)
} else {
source = fmt.Sprintf("externals.%s", resName)
}
}
ctx[fmt.Sprintf("resources.%s", resName)] = source

for propName := 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)
}
ctx[ref] = fmt.Sprintf("${%s.%s}", source, propName)
}
}

return ctx, nil
}

// SubstituteAll replaces all matching '${...}' templates in map keys and string values recursively.
func (context templatesContext) SubstituteAll(src map[string]interface{}) map[string]interface{} {
var dst = make(map[string]interface{}, 0)

for key, val := range src {
key = context.Substitute(key)
switch v := val.(type) {
case string:
val = context.Substitute(v)
case map[string]interface{}:
val = context.SubstituteAll(v)
}
dst[key] = val
}

return dst
}

// 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 fmt.Sprintf("${%s}", ref)
}
Loading

0 comments on commit cc9f731

Please sign in to comment.