Skip to content

Commit

Permalink
feat(PL-2679): add support for environment-level values (#170)
Browse files Browse the repository at this point in the history
Co-authored-by: Mathieu Frenette <[email protected]>
  • Loading branch information
silphid and silphid authored May 6, 2024
1 parent b2ea9cb commit bd42031
Show file tree
Hide file tree
Showing 6 changed files with 451 additions and 47 deletions.
6 changes: 5 additions & 1 deletion api/v1alpha1/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ type EnvironmentSpec struct {
Namespace string `yaml:"namespace,omitempty" json:"namespace,omitempty"`

// ChartVersions allows the environment to override the given version of the catalog's chart references.
// This allows for environments to rollout new versions of chart references.
// This allows for environments to roll out new versions of chart references.
ChartVersions map[string]string `yaml:"chartVersions,omitempty" json:"chartVersions,omitempty"`

// Owners is the list of identifiers of owners of the environment.
Expand All @@ -54,6 +54,10 @@ type EnvironmentSpec struct {
// SealedSecretsCert is the public certificate of the Sealed Secrets controller for this environment
// that can be used to encrypt secrets targeted to this environment using the `joy secret seal` command.
SealedSecretsCert string `yaml:"sealedSecretsCert,omitempty" json:"sealedSecretsCert,omitempty"`

// Values are the environment-level values that can optionally be injected into releases' values during rendering
// via the `$ref(.Environment.Spec.Values.someKey)` or `$spread(...)` template expressions.
Values map[string]any `yaml:"values,omitempty" json:"values,omitempty"`
}

type Environment struct {
Expand Down
1 change: 1 addition & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
134 changes: 127 additions & 7 deletions internal/release/render/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import (
"fmt"
"html/template"
"io"
"maps"
"regexp"
"slices"
"strings"

"github.com/Masterminds/sprig/v3"

"github.com/TwiN/go-color"
"github.com/nestoca/survey/v2"
"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -71,9 +73,7 @@ type RenderReleaseParams struct {
func RenderRelease(ctx context.Context, params RenderReleaseParams) error {
values, err := HydrateValues(params.Release, params.ValueMapping)
if err != nil {
fmt.Fprintln(params.IO.Out, "error hydrating values:", err)
fmt.Fprintln(params.IO.Out, "fallback to raw release.spec.values")
values = params.Release.Spec.Values
return fmt.Errorf("hydrating values: %w", err)
}

dst := params.IO.Out
Expand Down Expand Up @@ -167,7 +167,13 @@ func HydrateValues(release *v1alpha1.Release, mappings *config.ValueMapping) (ma
release.Environment,
}

values := maps.Clone(release.Spec.Values)
// The following call has the side effect of making a deep copy of the values, which is necessary
// for subsequent step to mutate the copy without affecting the original values.
values, err := hydrateObjectValues(release.Spec.Values, params.Environment.Spec.Values)
if err != nil {
return nil, fmt.Errorf("hydrating object values: %w", err)
}

if mappings != nil && !slices.Contains(mappings.ReleaseIgnoreList, release.Name) {
for mapping, value := range mappings.Mappings {
setInMap(values, splitIntoPathSegments(mapping), value)
Expand All @@ -179,7 +185,7 @@ func HydrateValues(release *v1alpha1.Release, mappings *config.ValueMapping) (ma
return nil, err
}

tmpl, err := template.New("").Parse(string(data))
tmpl, err := template.New("").Funcs(sprig.FuncMap()).Parse(string(data))
if err != nil {
return nil, err
}
Expand All @@ -197,6 +203,120 @@ func HydrateValues(release *v1alpha1.Release, mappings *config.ValueMapping) (ma
return result, nil
}

var objectValuesRegex = regexp.MustCompile(`^\s*\$(\w+)\(\s*((\.\w+)+)\s*\)\s*$`)

const objectValuesSupportedPrefix = ".Environment.Spec.Values."

func hydrateObjectValues(values map[string]any, envValues map[string]any) (map[string]any, error) {
resolvedValue, err := hydrateObjectValue(values, envValues)
if err != nil {
return nil, err
}
return resolvedValue.(map[string]any), err
}

func hydrateObjectValue(value any, envValues map[string]any) (any, error) {
switch val := value.(type) {
case string:
operator, resolvedValue, err := resolveOperatorAndValue(val, envValues)
if err != nil {
return nil, err
}
if operator != "" && operator != "ref" {
return nil, fmt.Errorf("only $ref() operator supported within object: %s", val)
}
return resolvedValue, nil
case map[string]any:
result := map[string]any{}
for key, subValue := range val {
resolvedValue, err := hydrateObjectValue(subValue, envValues)
if err != nil {
return nil, err
}
result[key] = resolvedValue
}
return result, nil
case map[any]any:
result := map[string]any{}
for key, subValue := range val {
resolvedValue, err := hydrateObjectValue(subValue, envValues)
if err != nil {
return nil, err
}
result[fmt.Sprint(key)] = resolvedValue
}
return result, nil
case []any:
var values []any
for _, subValue := range val {
switch subVal := subValue.(type) {
case string:
operator, resolvedValue, err := resolveOperatorAndValue(subVal, envValues)
if err != nil {
return nil, err
}
if operator == "spread" {
resolvedSlice, ok := resolvedValue.([]any)
if !ok {
return nil, fmt.Errorf("$spread() operator must resolve to an array, but got: %T", resolvedValue)
}
values = append(values, resolvedSlice...)
} else {
values = append(values, resolvedValue)
}
default:
resolvedValue, err := hydrateObjectValue(subVal, envValues)
if err != nil {
return nil, err
}
values = append(values, resolvedValue)
}
}
return values, nil
default:
return value, nil
}
}

func resolveOperatorAndValue(value string, envValues map[string]any) (string, any, error) {
matches := objectValuesRegex.FindStringSubmatch(value)
if len(matches) == 0 {
return "", value, nil
}

operator := matches[1]
if operator != "spread" && operator != "ref" {
return "", nil, fmt.Errorf("unsupported object interpolation operator %q in expression: %s", operator, value)
}

fullPath := matches[2]
if !strings.HasPrefix(fullPath, objectValuesSupportedPrefix) {
return "", nil, fmt.Errorf("only %q prefix is supported for object interpolation, but found: %s", objectValuesSupportedPrefix, fullPath)
}
valuesPath := strings.Split(strings.TrimPrefix(fullPath, objectValuesSupportedPrefix), ".")
resolvedValue, err := resolveObjectValue(envValues, valuesPath)
if err != nil {
return "", nil, fmt.Errorf("resolving object value for path %q: %w", fullPath, err)
}
return operator, resolvedValue, nil
}

func resolveObjectValue(values map[string]any, path []string) (any, error) {
key := path[0]
value, ok := values[key]
if !ok {
return nil, fmt.Errorf("key %q not found in values", key)
}
if len(path) == 1 {
return value, nil
}
mapValue, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("value for key %q is not a map", key)
}
return resolveObjectValue(mapValue, path[1:])
}

// ManifestColorWriter colorizes helm manifest by searching for document breaks
// and source comments. The implementation is naive and depends on the write buffer
// not breaking lines. In theory this means colorization can fail, however in practice
Expand All @@ -222,7 +342,7 @@ func (w ManifestColorWriter) Write(data []byte) (int, error) {

// setInMap modifies the map by adding the value to the path defined by segments.
// If the path defined by segments already exists, even if it points to a falsy value, this function does nothing.
// It will not overwite any existing key/value pairs.
// It will not overwrite any existing key/value pairs.
func setInMap(mapping map[string]any, segments []string, value any) {
for i, key := range segments {
if i == len(segments)-1 {
Expand Down
Loading

0 comments on commit bd42031

Please sign in to comment.