diff --git a/docs/book/src/tasks/experimental-features/cluster-class/write-clusterclass.md b/docs/book/src/tasks/experimental-features/cluster-class/write-clusterclass.md index 347ad38f85e2..9f95800a9884 100644 --- a/docs/book/src/tasks/experimental-features/cluster-class/write-clusterclass.md +++ b/docs/book/src/tasks/experimental-features/cluster-class/write-clusterclass.md @@ -1,5 +1,5 @@ -# Writing a ClusterClass +# Writing a ClusterClass A ClusterClass becomes more useful and valuable when it can be used to create many Cluster of a similar shape. The goal of this document is to explain how ClusterClasses can be written in a way that they are @@ -7,21 +7,25 @@ flexible enough to be used in as many Clusters as possible by supporting variant **Table of Contents** -* [Basic ClusterClass](#basic-clusterclass) -* [ClusterClass with MachineHealthChecks](#clusterclass-with-machinehealthchecks) -* [ClusterClass with patches](#clusterclass-with-patches) -* [ClusterClass with custom naming strategies](#clusterclass-with-custom-naming-strategies) - * [Defining a custom naming strategy for ControlPlane objects](#defining-a-custom-naming-strategy-for-controlplane-objects) - * [Defining a custom naming strategy for MachineDeployment objects](#defining-a-custom-naming-strategy-for-machinedeployment-objects) - * [Defining a custom naming strategy for MachinePool objects](#defining-a-custom-naming-strategy-for-machinepool-objects) -* [Advanced features of ClusterClass with patches](#advanced-features-of-clusterclass-with-patches) - * [MachineDeployment variable overrides](#machinedeployment-and-machinepool-variable-overrides) - * [Builtin variables](#builtin-variables) - * [Complex variable types](#complex-variable-types) - * [Using variable values in JSON patches](#using-variable-values-in-json-patches) - * [Optional patches](#optional-patches) - * [Version-aware patches](#version-aware-patches) -* [JSON patches tips & tricks](#json-patches-tips--tricks) +- [Basic ClusterClass](#basic-clusterclass) +- [ClusterClass with MachinePools](#clusterclass-with-machinepools) +- [ClusterClass with MachineHealthChecks](#clusterclass-with-machinehealthchecks) +- [ClusterClass with patches](#clusterclass-with-patches) +- [ClusterClass with custom naming strategies](#clusterclass-with-custom-naming-strategies) + - [Defining a custom naming strategy for ControlPlane objects](#defining-a-custom-naming-strategy-for-controlplane-objects) + - [Defining a custom naming strategy for MachineDeployment objects](#defining-a-custom-naming-strategy-for-machinedeployment-objects) + - [Defining a custom naming strategy for MachinePool objects](#defining-a-custom-naming-strategy-for-machinepool-objects) + - [Defining a custom namespace for ClusterClass object](#defining-a-custom-namespace-for-clusterclass-object) + - [Securing cross-namespace reference to the ClusterClass](#securing-cross-namespace-reference-to-the-clusterclass) +- [Advanced features of ClusterClass with patches](#advanced-features-of-clusterclass-with-patches) + - [MachineDeployment and MachinePool variable overrides](#machinedeployment-and-machinepool-variable-overrides) + - [Builtin variables](#builtin-variables) + - [Complex variable types](#complex-variable-types) + - [Using variable values in JSON patches](#using-variable-values-in-json-patches) + - [Optional patches](#optional-patches) + - [Version-aware patches](#version-aware-patches) + - [tpl and include functions](#tpl-and-include-functions) +- [JSON patches tips \& tricks](#json-patches-tips--tricks) ## Basic ClusterClass @@ -972,6 +976,87 @@ A simple approach to solve this problem is to define a map of version-aware vari being the Kubernetes version. Patch could then use the proper builtin variables as a lookup entry to fetch the corresponding values for the Kubernetes version in use by each object. +### tpl and include functions + +The `tpl` function allows to evaluate strings as templates inside a template. This is useful to pass a template string as a variable. + +First argument is the template string, second argument is the context. + +```yaml +apiVersion: cluster.x-k8s.io/v1beta2 +kind: ClusterClass +metadata: + name: docker-clusterclass-v0.1.0 +spec: + ... + variables: + - name: customImage + required: true + schema: + openAPIV3Schema: + type: string + description: Custom Image is the container registry to pull images from. + default: kindest/node:{{ .builtin.machineDeployment.version }} + example: kindest/node:{{ .builtin.machineDeployment.version }} + ... + patches: + - name: customImage + description: "Sets the container image that is used for running dockerMachines." + definitions: + - selector: + apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 + kind: DockerMachineTemplate + matchResources: + machineDeploymentClass: + names: + - default-worker + jsonPatches: + - op: add + path: /spec/template/spec/customImage + valueFrom: + template: | + {{ tpl .customImage . }} +``` + +For complex use-cases, you can use the `include` with template definitions: + +```yaml +apiVersion: cluster.x-k8s.io/v1beta2 +kind: ClusterClass +metadata: + name: docker-clusterclass-v0.1.0 +spec: + ... + variables: + - name: customImage + required: true + schema: + openAPIV3Schema: + type: string + description: Custom Image is the container registry to pull images from. + default: kindest/node:{{ include "default-version". }} + example: kindest/node:{{ include "default-version". }} + ... + patches: + - name: customImage + description: "Sets the container image that is used for running dockerMachines." + definitions: + - selector: + apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 + kind: DockerMachineTemplate + matchResources: + machineDeploymentClass: + names: + - default-worker + jsonPatches: + - op: add + path: /spec/template/spec/customImage + valueFrom: + template: | + {{- define "default-version" }}{{ .builtin.machineDeployment.version }}{{- end -}} + {{ tpl .customImage . }} +``` + ## JSON patches tips & tricks JSON patches specification [RFC6902] requires that the target of diff --git a/internal/controllers/topology/cluster/patches/inline/json_patch_generator.go b/internal/controllers/topology/cluster/patches/inline/json_patch_generator.go index 9fccf0a9ba19..ead8c4e8b9dd 100644 --- a/internal/controllers/topology/cluster/patches/inline/json_patch_generator.go +++ b/internal/controllers/topology/cluster/patches/inline/json_patch_generator.go @@ -25,7 +25,6 @@ import ( "strings" "text/template" - "github.com/Masterminds/sprig/v3" "github.com/pkg/errors" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" kerrors "k8s.io/apimachinery/pkg/util/errors" @@ -39,6 +38,7 @@ import ( "sigs.k8s.io/cluster-api/internal/contract" "sigs.k8s.io/cluster-api/internal/controllers/topology/cluster/patches/api" patchvariables "sigs.k8s.io/cluster-api/internal/controllers/topology/cluster/patches/variables" + "sigs.k8s.io/cluster-api/internal/topology/templates" ) // jsonPatchGenerator generates JSON patches for a GeneratePatchesRequest based on a ClusterClassPatch. @@ -319,7 +319,9 @@ func calculateValue(patch clusterv1.JSONPatch, variables map[string]apiextension // renderValueTemplate renders a template with the given variables as data. func renderValueTemplate(valueTemplate string, variables map[string]apiextensionsv1.JSON) (*apiextensionsv1.JSON, error) { // Parse the template. - tpl, err := template.New("tpl").Funcs(sprig.HermeticTxtFuncMap()).Parse(valueTemplate) + tpl := template.New("tpl") + tpl.Funcs(templates.TemplateFunctions(tpl)) + _, err := tpl.Parse(valueTemplate) if err != nil { return nil, errors.Wrapf(err, "failed to parse template: %q", valueTemplate) } diff --git a/internal/controllers/topology/cluster/patches/inline/json_patch_generator_test.go b/internal/controllers/topology/cluster/patches/inline/json_patch_generator_test.go index 1570b838cefe..b004f59adb35 100644 --- a/internal/controllers/topology/cluster/patches/inline/json_patch_generator_test.go +++ b/internal/controllers/topology/cluster/patches/inline/json_patch_generator_test.go @@ -111,6 +111,14 @@ func TestGenerate(t *testing.T) { Template: `[{"contentFrom":{"secret":{"key":"control-plane-azure.json","name":"{{ .builtin.controlPlane.machineTemplate.infrastructureRef.name }}-azure-json"}}}]`, }, }, + // tpl function is available. + { + Op: "replace", + Path: "/spec/tplFunction", + ValueFrom: &clusterv1.JSONPatchValue{ + Template: `{{ tpl .variableABC . }}`, + }, + }, }, }, }, @@ -133,6 +141,10 @@ func TestGenerate(t *testing.T) { Name: "variableC", Value: apiextensionsv1.JSON{Raw: []byte(`"C"`)}, }, + { + Name: "variableABC", + Value: apiextensionsv1.JSON{Raw: []byte(`"{{ .variableA }}-{{ .variableB }}-{{ .variableC }}"`)}, + }, }, Items: []runtimehooksv1.GeneratePatchesRequestItem{ { @@ -183,7 +195,9 @@ func TestGenerate(t *testing.T) { "name":"controlPlaneInfrastructureMachineTemplate1-azure-json" } } -}]}]`), +}]}, +{"op":"replace","path":"/spec/tplFunction","value":"A-B-C-template"} +]`), PatchType: runtimehooksv1.JSONPatchType, }, }, @@ -417,8 +431,8 @@ func TestGenerate(t *testing.T) { got, err := NewGenerator(tt.patch).Generate(context.Background(), &clusterv1.Cluster{ObjectMeta: metav1.ObjectMeta{Namespace: "default"}}, tt.req) - g.Expect(got).To(BeComparableTo(tt.want)) g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got).To(BeComparableTo(tt.want)) }) } } diff --git a/internal/topology/templates/template_functions.go b/internal/topology/templates/template_functions.go new file mode 100644 index 000000000000..30afdc7aef22 --- /dev/null +++ b/internal/topology/templates/template_functions.go @@ -0,0 +1,107 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package templates + +import ( + "errors" + "fmt" + "strings" + "text/template" + + "github.com/Masterminds/sprig/v3" +) + +const recursionMaxNums = 1000 + +// Copied from Helm +// https://github.com/helm/helm/blob/f05c21b6b6380d923696a7684c80a5e0847fbd7b/pkg/engine/engine.go#L132 +// 'include' needs to be defined in the scope of a 'tpl' template as +// well as regular file-loaded templates. +func includeFun(t *template.Template, includedNames map[string]int) func(string, interface{}) (string, error) { + return func(name string, data interface{}) (string, error) { + var buf strings.Builder + if v, ok := includedNames[name]; ok { + if v > recursionMaxNums { + return "", fmt.Errorf( + "rendering template has a nested reference name: %s: %w", + name, errors.New("unable to execute template")) + } + includedNames[name]++ + } else { + includedNames[name] = 1 + } + err := t.ExecuteTemplate(&buf, name, data) + includedNames[name]-- + return buf.String(), err + } +} + +// Copied from Helm +// https://github.com/helm/helm/blob/f05c21b6b6380d923696a7684c80a5e0847fbd7b/pkg/engine/engine.go#L153 +// As does 'tpl', so that nested calls to 'tpl' see the templates +// defined by their enclosing contexts. +func tplFun(parent *template.Template, includedNames map[string]int, strict bool) func(string, interface{}) (string, error) { + return func(tpl string, vals interface{}) (string, error) { + t, err := parent.Clone() + if err != nil { + return "", fmt.Errorf("cannot clone template: %w", err) + } + + // Re-inject the missingkey option, see text/template issue https://github.com/golang/go/issues/43022 + // We have to go by strict from our engine configuration, as the option fields are private in Template. + // TODO: Remove workaround (and the strict parameter) once we build only with golang versions with a fix. + if strict { + t.Option("missingkey=error") + } else { + t.Option("missingkey=zero") + } + + // Re-inject 'include' so that it can close over our clone of t; + // this lets any 'define's inside tpl be 'include'd. + t.Funcs(template.FuncMap{ + "include": includeFun(t, includedNames), + "tpl": tplFun(t, includedNames, strict), + }) + + // We need a .New template, as template text which is just blanks + // or comments after parsing out defines just adds new named + // template definitions without changing the main template. + // https://pkg.go.dev/text/template#Template.Parse + // Use the parent's name for lack of a better way to identify the tpl + // text string. (Maybe we could use a hash appended to the name?) + t, err = t.New(parent.Name()).Parse(tpl) + if err != nil { + return "", fmt.Errorf("cannot parse template %q: %w", tpl, err) + } + + var buf strings.Builder + if err := t.Execute(&buf, vals); err != nil { + return "", fmt.Errorf("error during tpl function execution for %q: %w", tpl, err) + } + + // See comment in renderWithReferences explaining the hack. + return strings.ReplaceAll(buf.String(), "", ""), nil + } +} + +func TemplateFunctions(t *template.Template) template.FuncMap { + funcs := sprig.HermeticTxtFuncMap() + includedNames := make(map[string]int) + funcs["include"] = includeFun(t, includedNames) + funcs["tpl"] = tplFun(t, includedNames, true) + return funcs +} diff --git a/internal/topology/templates/template_functions_test.go b/internal/topology/templates/template_functions_test.go new file mode 100644 index 000000000000..6476d8d1e31f --- /dev/null +++ b/internal/topology/templates/template_functions_test.go @@ -0,0 +1,101 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package templates + +import ( + "bytes" + "testing" + "text/template" + + . "github.com/onsi/gomega" +) + +func Test_TemplateFunctions(t *testing.T) { + tests := []struct { + name string + valueTemplate string + data map[string]interface{} + want string + }{ + { + "no template", + "a b c", + nil, + "a b c", + }, + { + "simple template", + "{{ tpl .variableABC . }}", + map[string]interface{}{ + "variableA": "a", + "variableB": "b", + "variableC": "c", + "variableABC": "{{ .variableA }}-{{ .variableB }}-{{ .variableC }}", + }, + "a-b-c", + }, + { + "nested template", + "{{ tpl .variableABC . }}", + map[string]interface{}{ + "variableA": "{{ `A` }}", + "variableB": "b", + "variableC": "c", + "variableABC": "{{ tpl .variableA . }}-{{ .variableB }}-{{ .variableC }}", + }, + "A-b-c", + }, + { + "include", + `{{- define "my-definition" }}{{ .variableZ }}{{- end }}{{ include "my-definition" .variableB }}`, + map[string]interface{}{ + "variableA": "{{ `A` }}", + "variableB": map[string]interface{}{ + "variableZ": "ZinB", + }, + }, + "ZinB", + }, + { + "include and template", + `{{- define "my-definition" }}{{ .variableZ }}{{- end }}{{ include (tpl .definitionName .) .variableB }}`, + map[string]interface{}{ + "variableA": "{{ `A` }}", + "variableB": map[string]interface{}{ + "variableZ": "ZinB", + }, + "definitionName": "my-definition", + }, + "ZinB", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + tpl := template.New("tpl") + tpl.Funcs(TemplateFunctions(tpl)) + _, err := tpl.Parse(tt.valueTemplate) + g.Expect(err).ToNot(HaveOccurred()) + var buf bytes.Buffer + err = tpl.Execute(&buf, tt.data) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(buf.String()).To(Equal(tt.want)) + }) + } + +} diff --git a/internal/webhooks/patch_validation.go b/internal/webhooks/patch_validation.go index 56af76c6d410..075105b3cf05 100644 --- a/internal/webhooks/patch_validation.go +++ b/internal/webhooks/patch_validation.go @@ -23,7 +23,6 @@ import ( "strings" "text/template" - "github.com/Masterminds/sprig/v3" "github.com/pkg/errors" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/validation" @@ -32,6 +31,7 @@ import ( clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" "sigs.k8s.io/cluster-api/feature" + "sigs.k8s.io/cluster-api/internal/topology/templates" ) // validatePatches returns errors if the Patches in the ClusterClass violate any validation rules. @@ -146,7 +146,9 @@ func validateEnabledIf(enabledIf string, path *field.Path) field.ErrorList { if enabledIf != "" { // Error if template can not be parsed. - _, err := template.New("enabledIf").Funcs(sprig.HermeticTxtFuncMap()).Parse(enabledIf) + tpl := template.New("enabledIf") + tpl.Funcs(templates.TemplateFunctions(tpl)) + _, err := tpl.Parse(enabledIf) if err != nil { allErrs = append(allErrs, field.Invalid( @@ -410,8 +412,10 @@ func validateJSONPatchValues(jsonPatch clusterv1.JSONPatch, variableSet map[stri } if jsonPatch.ValueFrom != nil && jsonPatch.ValueFrom.Template != "" { + tpl := template.New("valueFrom.template") + tpl.Funcs(templates.TemplateFunctions(tpl)) // Error if template can not be parsed. - _, err := template.New("valueFrom.template").Funcs(sprig.HermeticTxtFuncMap()).Parse(jsonPatch.ValueFrom.Template) + _, err := tpl.Parse(jsonPatch.ValueFrom.Template) if err != nil { allErrs = append(allErrs, field.Invalid(