From f30872194a8d2c54e2ce436daa606bdc384fc735 Mon Sep 17 00:00:00 2001 From: Danil-Grigorev Date: Tue, 28 Jan 2025 13:57:19 +0100 Subject: [PATCH] Add templateValues to HelmApp, Bundle, etc Signed-off-by: Danil-Grigorev --- charts/fleet-crd/templates/crds.yaml | 108 ++++++++++++++++++ internal/cmd/controller/options/calculate.go | 6 + internal/cmd/controller/target/builder.go | 55 ++++++++- internal/cmd/controller/target/target_test.go | 97 +++++++++++++++- .../v1alpha1/bundledeployment_types.go | 8 ++ .../v1alpha1/zz_generated.deepcopy.go | 7 ++ 6 files changed, 275 insertions(+), 6 deletions(-) diff --git a/charts/fleet-crd/templates/crds.yaml b/charts/fleet-crd/templates/crds.yaml index a6d752fe99..a15cb88d9b 100644 --- a/charts/fleet-crd/templates/crds.yaml +++ b/charts/fleet-crd/templates/crds.yaml @@ -349,6 +349,24 @@ spec: description: TakeOwnership makes helm skip the check for its own annotations type: boolean + templateValues: + additionalProperties: + type: string + description: 'Template Values passed to Helm. It is possible + to specify the keys and values + + as go template strings. Unlike .values, content of each + key will be templated + + first, before serializing to yaml. This allows to template + complex values, + + like ranges and maps. + + templateValues keys have precedence over values keys in + case of conflict.' + nullable: true + type: object timeoutSeconds: description: TimeoutSeconds is the time to wait for Helm operations. @@ -695,6 +713,24 @@ spec: description: TakeOwnership makes helm skip the check for its own annotations type: boolean + templateValues: + additionalProperties: + type: string + description: 'Template Values passed to Helm. It is possible + to specify the keys and values + + as go template strings. Unlike .values, content of each + key will be templated + + first, before serializing to yaml. This allows to template + complex values, + + like ranges and maps. + + templateValues keys have precedence over values keys in + case of conflict.' + nullable: true + type: object timeoutSeconds: description: TimeoutSeconds is the time to wait for Helm operations. @@ -1568,6 +1604,24 @@ spec: description: TakeOwnership makes helm skip the check for its own annotations type: boolean + templateValues: + additionalProperties: + type: string + description: 'Template Values passed to Helm. It is possible + to specify the keys and values + + as go template strings. Unlike .values, content of each key + will be templated + + first, before serializing to yaml. This allows to template + complex values, + + like ranges and maps. + + templateValues keys have precedence over values keys in case + of conflict.' + nullable: true + type: object timeoutSeconds: description: TimeoutSeconds is the time to wait for Helm operations. type: integer @@ -2433,6 +2487,24 @@ spec: description: TakeOwnership makes helm skip the check for its own annotations type: boolean + templateValues: + additionalProperties: + type: string + description: 'Template Values passed to Helm. It is possible + to specify the keys and values + + as go template strings. Unlike .values, content of each + key will be templated + + first, before serializing to yaml. This allows to template + complex values, + + like ranges and maps. + + templateValues keys have precedence over values keys + in case of conflict.' + nullable: true + type: object timeoutSeconds: description: TimeoutSeconds is the time to wait for Helm operations. @@ -7307,6 +7379,24 @@ spec: description: TakeOwnership makes helm skip the check for its own annotations type: boolean + templateValues: + additionalProperties: + type: string + description: 'Template Values passed to Helm. It is possible + to specify the keys and values + + as go template strings. Unlike .values, content of each key + will be templated + + first, before serializing to yaml. This allows to template + complex values, + + like ranges and maps. + + templateValues keys have precedence over values keys in case + of conflict.' + nullable: true + type: object timeoutSeconds: description: TimeoutSeconds is the time to wait for Helm operations. type: integer @@ -8191,6 +8281,24 @@ spec: description: TakeOwnership makes helm skip the check for its own annotations type: boolean + templateValues: + additionalProperties: + type: string + description: 'Template Values passed to Helm. It is possible + to specify the keys and values + + as go template strings. Unlike .values, content of each + key will be templated + + first, before serializing to yaml. This allows to template + complex values, + + like ranges and maps. + + templateValues keys have precedence over values keys + in case of conflict.' + nullable: true + type: object timeoutSeconds: description: TimeoutSeconds is the time to wait for Helm operations. diff --git a/internal/cmd/controller/options/calculate.go b/internal/cmd/controller/options/calculate.go index 2f240cf3e1..3c18277783 100644 --- a/internal/cmd/controller/options/calculate.go +++ b/internal/cmd/controller/options/calculate.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "maps" fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1" "github.com/rancher/wrangler/v3/pkg/data" @@ -52,6 +53,11 @@ func Merge(base, custom fleet.BundleDeploymentOptions) fleet.BundleDeploymentOpt } else if custom.Helm.Values != nil { result.Helm.Values.Data = data.MergeMaps(result.Helm.Values.Data, custom.Helm.Values.Data) } + if result.Helm.TemplateValues == nil { + result.Helm.TemplateValues = custom.Helm.TemplateValues + } else if custom.Helm.TemplateValues != nil { + maps.Copy(result.Helm.TemplateValues, custom.Helm.TemplateValues) + } if custom.Helm.ValuesFrom != nil { result.Helm.ValuesFrom = append(result.Helm.ValuesFrom, custom.Helm.ValuesFrom...) } diff --git a/internal/cmd/controller/target/builder.go b/internal/cmd/controller/target/builder.go index 27f529809d..27e8e98a91 100644 --- a/internal/cmd/controller/target/builder.go +++ b/internal/cmd/controller/target/builder.go @@ -2,8 +2,10 @@ package target import ( "bytes" + "cmp" "context" "fmt" + "maps" "sort" "strings" "text/template" @@ -182,10 +184,15 @@ func preprocessHelmValues(logger logr.Logger, opts *fleet.BundleDeploymentOption } opts.Helm = opts.Helm.DeepCopy() - if opts.Helm.Values == nil || opts.Helm.Values.Data == nil { - opts.Helm.Values = &fleet.GenericMap{ - Data: map[string]interface{}{}, - } + opts.Helm.Values = cmp.Or(opts.Helm.Values, &fleet.GenericMap{ + Data: map[string]interface{}{}, + }) + + if opts.Helm.Values.Data == nil { + opts.Helm.Values.Data = map[string]interface{}{} + } + + if opts.Helm.TemplateValues == nil && len(opts.Helm.Values.Data) == 0 { return nil } @@ -211,6 +218,14 @@ func preprocessHelmValues(logger logr.Logger, opts *fleet.BundleDeploymentOption if err != nil { return err } + + templatedData, err := processTemplateValuesData(opts.Helm.TemplateValues, values) + if err != nil { + return err + } + + maps.Copy(opts.Helm.Values.Data, templatedData) + logger.V(4).Info("preProcess completed", "releaseName", opts.Helm.ReleaseName) } @@ -278,6 +293,38 @@ func tplFuncMap() template.FuncMap { return f } +func processTemplateValuesData(helmTemplateData map[string]string, templateContext map[string]interface{}) (map[string]interface{}, error) { + renderedValues := make(map[string]interface{}, len(helmTemplateData)) + + for k, v := range helmTemplateData { + // fleet.yaml must be valid yaml, however '{}[]' are YAML control + // characters and will be interpreted as JSON data structures. This + // causes issues when parsing the fleet.yaml so we change the delims + // for templating to '${ }' + tmpl := template.New("values").Funcs(tplFuncMap()).Option("missingkey=error").Delims("${", "}") + tmpl, err := tmpl.Parse(v) + if err != nil { + return nil, fmt.Errorf("failed to parse helm values template: %w", err) + } + + var b bytes.Buffer + err = tmpl.Execute(&b, templateContext) + if err != nil { + return nil, fmt.Errorf("failed to render helm values template: %w", err) + } + + var value interface{} + err = kyaml.Unmarshal(b.Bytes(), &value) + if err != nil { + return nil, fmt.Errorf("failed to interpret rendered template as helm values: %s, %v", b.String(), err) + } + + renderedValues[k] = value + } + + return renderedValues, nil +} + func processTemplateValues(helmValues map[string]interface{}, templateContext map[string]interface{}) (map[string]interface{}, error) { data, err := kyaml.Marshal(helmValues) if err != nil { diff --git a/internal/cmd/controller/target/target_test.go b/internal/cmd/controller/target/target_test.go index da084c7838..03c9639fde 100644 --- a/internal/cmd/controller/target/target_test.go +++ b/internal/cmd/controller/target/target_test.go @@ -112,6 +112,17 @@ func TestProcessLabelValues(t *testing.T) { const bundleYamlWithTemplate = `namespace: default helm: releaseName: labels + templateValues: + mapData: | + ${- range $key := .ClusterValues.items } + "${ $key }": + nested: "true" + ${- end} + listData: | + ${- range $key := .ClusterValues.items } + - "${ $key }": + nested: "true" + ${- end} values: clusterName: "${ .ClusterLabels.name }" fromAnnotation: "${ .ClusterAnnotations.testAnnotation }" @@ -154,6 +165,10 @@ func TestProcessTemplateValues(t *testing.T) { "thirdTier": "bar", }, }, + "items": []string{ + "one", + "two", + }, "list": []string{ "alpha", "beta", @@ -187,7 +202,7 @@ func TestProcessTemplateValues(t *testing.T) { templatedValues, err := processTemplateValues(bundle.Helm.Values.Data, values) if err != nil { - t.Fatalf("error during label processing %v", err) + t.Fatalf("error during templated values processing %v", err) } clusterName, ok := templatedValues["clusterName"] @@ -314,6 +329,70 @@ func TestProcessTemplateValues(t *testing.T) { t.Fatal("join func was not right") } + templatedValuesData, err := processTemplateValuesData(bundle.Helm.TemplateValues, values) + if err != nil { + t.Fatalf("error during templated values processing %v", err) + } + + mapData, ok := templatedValuesData["mapData"].(map[string]interface{}) + if !ok { + t.Fatal("mapData not found") + } + + one, ok := mapData["one"] + if !ok { + t.Fatal("unable to find key one") + } + + oneData, ok := one.(map[string]interface{}) + if !ok { + t.Fatal("one key was not right") + } + + if oneData["nested"].(string) != "true" { + t.Fatal("one value was not right") + } + + two, ok := mapData["two"] + if !ok { + t.Fatal("unable to find key two") + } + + twoData, ok := two.(map[string]interface{}) + if !ok { + t.Fatal("two key was not right") + } + + if twoData["nested"].(string) != "true" { + t.Fatal("two value was not right") + } + + listData, ok := templatedValuesData["listData"].([]interface{}) + if !ok { + t.Fatal("listData not found") + } + + if len(listData) != 2 { + t.Fatal("unable to find all listData keys") + } + + oneListData, ok := listData[0].(map[string]interface{}) + if !ok { + t.Fatal("oneListData key is not right") + } + + if oneListData["nested"] != "true" { + t.Fatal("oneListData item is missing") + } + + twoListData, ok := listData[1].(map[string]interface{}) + if !ok { + t.Fatal("twoListData key is not right") + } + + if twoListData["nested"] != "true" { + t.Fatal("twoListData item is missing") + } } const clusterYamlWithTemplateValues = `apiVersion: fleet.cattle.io/v1alpha1 @@ -393,7 +472,7 @@ func TestDisablePreProcessFlagEnabled(t *testing.T) { t.Fatalf("key %s not found", testCase.Key) } else { if field != testCase.ExpectedValue { - t.Fatalf("key %s was not the expected value. Expected: '%s' Actual: '%s'", testCase.Key, field, testCase.ExpectedValue) + t.Fatalf("key %s was not the expected value. Expected: '%s' Actual: '%s'", testCase.Key, testCase.ExpectedValue, field) } } @@ -405,8 +484,11 @@ const bundleYamlWithDisablePreProcessDisabled = `namespace: default helm: disablePreprocess: false releaseName: labels + templateValues: + overriden: "something_templated" values: clusterName: "${ .ClusterName }" + overriden: "" ` func TestDisablePreProcessFlagDisabled(t *testing.T) { @@ -433,6 +515,17 @@ func TestDisablePreProcessFlagDisabled(t *testing.T) { } } + key = "overriden" + expectedValue = "something_templated" + + if field, ok := valuesObj[key]; !ok { + t.Fatalf("key %s not found", key) + } else { + if field != expectedValue { + t.Fatalf("key %s was not the expected value. Expected: '%s' Actual: '%s'", key, field, expectedValue) + } + } + } const bundleYamlWithDisablePreProcessMissing = `namespace: default diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/bundledeployment_types.go b/pkg/apis/fleet.cattle.io/v1alpha1/bundledeployment_types.go index ecfb9f2806..26af4d07d3 100644 --- a/pkg/apis/fleet.cattle.io/v1alpha1/bundledeployment_types.go +++ b/pkg/apis/fleet.cattle.io/v1alpha1/bundledeployment_types.go @@ -211,6 +211,14 @@ type HelmOptions struct { // +kubebuilder:validation:XPreserveUnknownFields Values *GenericMap `json:"values,omitempty"` + // Template Values passed to Helm. It is possible to specify the keys and values + // as go template strings. Unlike .values, content of each key will be templated + // first, before serializing to yaml. This allows to template complex values, + // like ranges and maps. + // templateValues keys have precedence over values keys in case of conflict. + // +nullable + TemplateValues map[string]string `json:"templateValues,omitempty"` + // +nullable // ValuesFrom loads the values from configmaps and secrets. ValuesFrom []ValuesFrom `json:"valuesFrom,omitempty"` diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go index 78dd5f628a..d78d397a6c 100644 --- a/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go @@ -1681,6 +1681,13 @@ func (in *HelmOptions) DeepCopyInto(out *HelmOptions) { in, out := &in.Values, &out.Values *out = (*in).DeepCopy() } + if in.TemplateValues != nil { + in, out := &in.TemplateValues, &out.TemplateValues + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } if in.ValuesFrom != nil { in, out := &in.ValuesFrom, &out.ValuesFrom *out = make([]ValuesFrom, len(*in))