diff --git a/blueprints/aws-alb-crossplane/gatewayclassblueprint-aws-alb-crossplane.yaml b/blueprints/aws-alb-crossplane/gatewayclassblueprint-aws-alb-crossplane.yaml index 82edb5b9..8622fcc5 100644 --- a/blueprints/aws-alb-crossplane/gatewayclassblueprint-aws-alb-crossplane.yaml +++ b/blueprints/aws-alb-crossplane/gatewayclassblueprint-aws-alb-crossplane.yaml @@ -33,7 +33,7 @@ spec: template: | addresses: - type: Hostname - value: {{ .Resources.LB.status.atProvider.dnsName }} + value: {{ (index .Resources.LB 0).status.atProvider.dnsName }} resourceTemplates: childGateway: | apiVersion: gateway.networking.k8s.io/v1beta1 @@ -153,7 +153,7 @@ spec: {{- toYaml .Values.tags | nindent 4 }} {{ end }} spec: - targetGroupARN: {{ .Resources.LBTargetGroup.status.atProvider.arn }} + targetGroupARN: {{ (index .Resources.LBTargetGroup 0).status.atProvider.arn }} targetType: ip serviceRef: name: {{ .Gateway.metadata.name }}-child diff --git a/blueprints/contour-istio/gatewayclassblueprint-contour-istio-cert.yaml b/blueprints/contour-istio/gatewayclassblueprint-contour-istio-cert.yaml index 76311044..d7f5342a 100644 --- a/blueprints/contour-istio/gatewayclassblueprint-contour-istio-cert.yaml +++ b/blueprints/contour-istio/gatewayclassblueprint-contour-istio-cert.yaml @@ -9,7 +9,7 @@ spec: status: template: | addresses: - {{ range .Resources.loadBalancer.status.loadBalancer.ingress }} + {{ range (index .Resources.loadBalancer 0).status.loadBalancer.ingress }} - type: IPAddress value: {{ .ip }} {{ end }} diff --git a/controllers/common_test.go b/controllers/common_test.go index 09575f41..01f29b73 100644 --- a/controllers/common_test.go +++ b/controllers/common_test.go @@ -173,7 +173,7 @@ spec: name: default ` -var _ = Describe("Common functions", func() { +var _ = Describe("Attached policies and value precedence", func() { const ( timeout = time.Second * 10 diff --git a/controllers/gateway_controller.go b/controllers/gateway_controller.go index 6822da66..5d39bbc1 100644 --- a/controllers/gateway_controller.go +++ b/controllers/gateway_controller.go @@ -186,13 +186,13 @@ func (r *GatewayReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct if tmpl, errs := parseSingleTemplate("status", tmplStr); errs != nil { logger.Info("unable to parse status template", "temporary error", errs) } else { - if statusMap, errs := template2map(tmpl, &templateValues); errs != nil { + if statusMap, errs := template2maps(tmpl, &templateValues); errs != nil { logger.Info("unable to render status template", "temporary error", errs, "template", tmplStr, "values", templateValues) } else { gw.Status.Addresses = []gatewayapi.GatewayAddress{} - _, found := statusMap["addresses"] + _, found := statusMap[0]["addresses"] // FIXME, more addresses? if found { - addresses := statusMap["addresses"] + addresses := statusMap[0]["addresses"] if errs := mapstructure.Decode(addresses, &gw.Status.Addresses); errs != nil { // This is probably not a temporary error logger.Error(errs, "unable to decode status data") diff --git a/controllers/gateway_controller_test.go b/controllers/gateway_controller_test.go index 5d6e26c6..c171e347 100644 --- a/controllers/gateway_controller_test.go +++ b/controllers/gateway_controller_test.go @@ -81,11 +81,17 @@ kind: GatewayClassBlueprint metadata: name: default-gateway-class spec: + values: + default: + configmap2SuffixData: + - one + - two + - three gatewayTemplate: status: template: | addresses: - {{ toYaml .Resources.childGateway.status.addresses | nindent 2}} + {{ toYaml (index .Resources.childGateway 0).status.addresses | nindent 2}} resourceTemplates: childGateway: | apiVersion: gateway.networking.k8s.io/v1beta1 @@ -109,14 +115,25 @@ spec: data: valueToRead1: Hello valueToRead2: World - configMapTestIntermediate: | + configMapTestIntermediate1: | apiVersion: v1 kind: ConfigMap metadata: - name: intermediate-configmap + name: intermediate1-configmap namespace: {{ .Gateway.metadata.namespace }} data: - valueIntermediate: {{ .Resources.configMapTestSource.data.valueToRead1 }} + valueIntermediate: {{ (index .Resources.configMapTestSource 0).data.valueToRead1 }} + configMapTestIntermediate2: | + {{ range $idx,$suffix := .Values.configmap2SuffixData }} + apiVersion: v1 + kind: ConfigMap + metadata: + name: intermediate2-configmap-{{ $idx }} + namespace: {{ $.Gateway.metadata.namespace }} + data: + valueIntermediate: {{ (index $.Resources.configMapTestSource 0).data.valueToRead1 }}-{{ $suffix }} + --- + {{ end }} # Use references to multiple resources coupled with template pipeline and functions configMapTestDestination: | apiVersion: v1 @@ -125,7 +142,8 @@ spec: name: dst-configmap namespace: {{ .Gateway.metadata.namespace }} data: - valueRead: {{ printf "%s, %s" .Resources.configMapTestIntermediate.data.valueIntermediate .Resources.configMapTestSource.data.valueToRead2 | upper }} + valueRead: {{ printf "%s, %s" (index .Resources.configMapTestIntermediate1 0).data.valueIntermediate (index .Resources.configMapTestSource 0).data.valueToRead2 | upper }} + valueRead2: {{ printf "Testing, one two %s" (index .Resources.configMapTestIntermediate2 2).data.valueIntermediate | upper }} httpRouteTemplate: resourceTemplates: shadowHttproute: | @@ -270,6 +288,7 @@ var _ = Describe("Gateway controller", func() { By("Setting the content of the destination configmap") Expect(cm.Data["valueRead"]).To(Equal("HELLO, WORLD")) + Expect(cm.Data["valueRead2"]).To(Equal("TESTING, ONE TWO HELLO-THREE")) }) }) }) diff --git a/controllers/statuscheck.go b/controllers/statuscheck.go index 8e8f530f..d29dd239 100644 --- a/controllers/statuscheck.go +++ b/controllers/statuscheck.go @@ -32,34 +32,40 @@ limitations under the License. package controllers import ( + "fmt" + "sigs.k8s.io/cli-utils/pkg/kstatus/status" ) // Given a slice of template states, compute the overall // health/readiness status. The general approach is to test for a // `Ready` status condition, which is implemented through kstatus. -func statusIsReady(templates []*TemplateResource) (bool, error) { - for _, tmplRes := range templates { - if tmplRes.Current == nil { - return false, nil - } - res, err := status.Compute(tmplRes.Current) - if err != nil { - return false, err - } - if res.Status != status.CurrentStatus { - return false, nil +func statusIsReady(templates []*ResourceTemplateState) (bool, error) { + for _, tmpl := range templates { + for _, res := range tmpl.NewResources { + if res.Current == nil { + return false, nil + } + res, err := status.Compute(res.Current) + if err != nil { + return false, err + } + if res.Status != status.CurrentStatus { + return false, nil + } } } return true, nil } // Build a list of template names which are not yet reconciled. Useful for status reporting -func statusExistingTemplates(templates []*TemplateResource) []string { +func statusExistingTemplates(templates []*ResourceTemplateState) []string { var missing []string - for _, tmplRes := range templates { - if tmplRes.Current == nil { - missing = append(missing, tmplRes.TemplateName) + for _, tmpl := range templates { + for resIdx, res := range tmpl.NewResources { + if res.Current == nil { + missing = append(missing, fmt.Sprintf("%s[%d]", tmpl.TemplateName, resIdx)) + } } } return missing diff --git a/controllers/templating.go b/controllers/templating.go index c09ed496..539135c1 100644 --- a/controllers/templating.go +++ b/controllers/templating.go @@ -36,6 +36,7 @@ import ( "context" "fmt" "io" + "sort" "strings" "text/template" @@ -51,15 +52,10 @@ import ( sigsyaml "sigs.k8s.io/yaml" ) -// Rendering and applying templates is a multi-stage process. This -// structure holds information about a rendered template between these -// two stages -type TemplateResource struct { - // Compiled template - Template *template.Template - +// Information about a resource, rendered format as well as actual in API server +type ResourceComposite struct { // The rendered resource - Resource *unstructured.Unstructured + Rendered *unstructured.Unstructured // GVR for resource GVR *schema.GroupVersionResource @@ -67,14 +63,24 @@ type TemplateResource struct { // Current resource fetch from API-server (or as close as our local caching allows) Current *unstructured.Unstructured - // Name of rendered resource (from template key in GatewayClassBlueprint, not Kubernetes resource name) + // Whether resource is namespaced or not + IsNamespaced bool +} + +// Rendering and applying templates is a multi-stage process. This +// structure holds information about a template between stages +type ResourceTemplateState struct { + // Compiled template + Template *template.Template + + // Name of template (from template key in GatewayClassBlueprint, not Kubernetes resource name) TemplateName string - // Raw template for resource + // Raw template StringTemplate string - // Whether resource is namespaced or not - IsNamespaced bool + // Resource information, rendered and current + NewResources []ResourceComposite // FIXME, refactoring temp name } // Parameters used when rendering templates @@ -113,23 +119,27 @@ func parseSingleTemplate(tmplKey, tmpl string) (*template.Template, error) { return template.New(tmplKey).Option("missingkey=error").Funcs(sprig.TxtFuncMap()).Funcs(funcs).Parse(tmpl) } -// Initialize TemplateResource slice by parsing templates -func parseTemplates(resourceTemplates map[string]string) ([]*TemplateResource, error) { +// Initialize ResourceTemplateState slice by parsing templates +func parseTemplates(resourceTemplates map[string]string) ([]*ResourceTemplateState, error) { var err error - templates := make([]*TemplateResource, 0, len(resourceTemplates)) + templates := make([]*ResourceTemplateState, 0, len(resourceTemplates)) for tmplKey, tmpl := range resourceTemplates { - r := TemplateResource{} + r := ResourceTemplateState{} r.TemplateName = tmplKey r.StringTemplate = tmpl r.Template, err = parseSingleTemplate(tmplKey, tmpl) if err != nil { return nil, fmt.Errorf("cannot parse template %q: %w", tmplKey, err) } + r.NewResources = make([]ResourceComposite, 0) templates = append(templates, &r) } + // Sort to increase predictability + sort.SliceStable(templates, func(i, j int) bool { return templates[i].TemplateName < templates[j].TemplateName }) + return templates, nil } @@ -139,48 +149,45 @@ func parseTemplates(resourceTemplates map[string]string) ([]*TemplateResource, e // render the template first. Rendering errors on final attempt are // logged as errors. func renderTemplates(ctx context.Context, r ControllerDynClient, parent metav1.Object, - templates []*TemplateResource, values *TemplateValues, isFinalAttempt bool) (rendered, exists int) { + templates []*ResourceTemplateState, values *TemplateValues, isFinalAttempt bool) (rendered, exists int) { var err error logger := log.FromContext(ctx) ns := parent.GetNamespace() - for _, tmplRes := range templates { - if tmplRes.Resource == nil { - tmplRes.Resource, err = template2Unstructured(tmplRes.Template, values) + for tIdx := range templates { + tmpl := templates[tIdx] + if len(tmpl.NewResources) == 0 { + tmpl.NewResources, err = template2Composite(r, tmpl.Template, values) if err != nil { if isFinalAttempt { - logger.Error(err, "cannot render template", "templateName", tmplRes.TemplateName) + logger.Error(err, "cannot render template", "templateName", tmpl.TemplateName) // FIXME: These are convenient, but we should have a better logging design, i.e. it should be possible to enable rendering errors only - fmt.Printf("Template:\n%s\n", tmplRes.StringTemplate) + fmt.Printf("Template:\n%s\n", tmpl.StringTemplate) fmt.Printf("Template values:\n%+v\n", values) } continue } } - if tmplRes.GVR == nil { - tmplRes.GVR, tmplRes.IsNamespaced, err = unstructuredToGVR(r, tmplRes.Resource) - if err != nil { - logger.Error(err, "cannot detect GVR for resource", "templateName", tmplRes.TemplateName) - continue - } - } rendered++ - if tmplRes.Current == nil { - var dynamicClient dynamic.ResourceInterface - if tmplRes.IsNamespaced { - dynamicClient = r.DynamicClient().Resource(*tmplRes.GVR).Namespace(ns) + for resIdx := range tmpl.NewResources { + res := &tmpl.NewResources[resIdx] + if res.Current == nil { + var dynamicClient dynamic.ResourceInterface + if res.IsNamespaced { + dynamicClient = r.DynamicClient().Resource(*res.GVR).Namespace(ns) + } else { + dynamicClient = r.DynamicClient().Resource(*res.GVR) + } + res.Current, err = dynamicClient.Get(ctx, res.Rendered.GetName(), metav1.GetOptions{}) + if err != nil { + logger.Error(err, "cannot get current resource", "templateName", tmpl.TemplateName, "resIdx", resIdx) + continue + } + logger.Info("update current", "templatename", tmpl.TemplateName, "idx", resIdx, "current", res.Current) } else { - dynamicClient = r.DynamicClient().Resource(*tmplRes.GVR) + logger.Info("already have update current", "templatename", tmpl.TemplateName, "idx", resIdx, "current", res.Current) } - tmplRes.Current, err = dynamicClient.Get(ctx, tmplRes.Resource.GetName(), metav1.GetOptions{}) - if err != nil { - logger.Error(err, "cannot get current resource", "templateName", tmplRes.TemplateName) - continue - } - logger.Info("update current", "templatename", tmplRes.TemplateName, "current", tmplRes.Current) - } else { - logger.Info("already have update current", "templatename", tmplRes.TemplateName, "current", tmplRes.Current) } exists++ } @@ -190,50 +197,56 @@ func renderTemplates(ctx context.Context, r ControllerDynClient, parent metav1.O // Build a map of values from current resources. Useful for // referencing values between resources, e.g. a status field from one // resource may be used to template another resource -func buildResourceValues(templates []*TemplateResource) map[string]any { - resources := map[string]any{} - - for _, tmplRes := range templates { - if tmplRes.Current != nil { - resources[tmplRes.TemplateName] = tmplRes.Current.UnstructuredContent() +func buildResourceValues(templates []*ResourceTemplateState) map[string]any { + resourceValues := map[string]any{} + + for _, tmpl := range templates { + resSlice := make([]map[string]any, 0) + for _, res := range tmpl.NewResources { + if res.Current != nil { + resSlice = append(resSlice, res.Current.UnstructuredContent()) + } } + resourceValues[tmpl.TemplateName] = resSlice } - return resources + return resourceValues } // Apply a list of pre-rendered templates and set owner reference for // namespaced resources -func applyTemplates(ctx context.Context, r ControllerDynClient, parent metav1.Object, templates []*TemplateResource) error { +func applyTemplates(ctx context.Context, r ControllerDynClient, parent metav1.Object, templates []*ResourceTemplateState) error { var err error var errorCnt = 0 logger := log.FromContext(ctx) - for _, tmplRes := range templates { - if tmplRes.Resource == nil || tmplRes.GVR == nil { - // We do not yet have enough information to render/apply this resource - continue - } - if tmplRes.IsNamespaced { - // Only namespaced objects can have namespaced object as owner - err = ctrl.SetControllerReference(parent, tmplRes.Resource, r.Scheme()) - if err != nil { - logger.Error(err, "cannot set owner for namespaced template", "templateName", tmplRes.TemplateName) - errorCnt++ + for _, tmpl := range templates { + for _, res := range tmpl.NewResources { + if res.Rendered == nil || res.GVR == nil { + // We do not yet have enough information to render/apply this resource + continue + } + if res.IsNamespaced { + // Only namespaced objects can have namespaced object as owner + err = ctrl.SetControllerReference(parent, res.Rendered, r.Scheme()) + if err != nil { + logger.Error(err, "cannot set owner for namespaced template", "templateName", tmpl.TemplateName) + errorCnt++ + } else { + ns := parent.GetNamespace() + err = patchUnstructured(ctx, r, res.Rendered, res.GVR, &ns) + if err != nil { + logger.Error(err, "cannot apply namespaced template", "templateName", tmpl.TemplateName) + errorCnt++ + } + } } else { - ns := parent.GetNamespace() - err = patchUnstructured(ctx, r, tmplRes.Resource, tmplRes.GVR, &ns) + err = patchUnstructured(ctx, r, res.Rendered, res.GVR, nil) if err != nil { - logger.Error(err, "cannot apply namespaced template", "templateName", tmplRes.TemplateName) + logger.Error(err, "cannot apply cluster-scoped template", "templateName", tmpl.TemplateName) errorCnt++ } } - } else { - err = patchUnstructured(ctx, r, tmplRes.Resource, tmplRes.GVR, nil) - if err != nil { - logger.Error(err, "cannot apply cluster-scoped template", "templateName", tmplRes.TemplateName) - errorCnt++ - } } } @@ -266,26 +279,44 @@ func templateRender(tmpl *template.Template, templateValues *TemplateValues) (*b return &buffer, nil } -func template2map(tmpl *template.Template, tmplValues *TemplateValues) (map[string]any, error) { +func template2maps(tmpl *template.Template, tmplValues *TemplateValues) ([]map[string]any, error) { renderBuffer, err := templateRender(tmpl, tmplValues) if err != nil { return nil, err } - rawResource := map[string]any{} - err = yaml.Unmarshal(renderBuffer.Bytes(), &rawResource) - if err != nil { - return nil, err + rawSlice := bytes.SplitN(renderBuffer.Bytes(), []byte("---"), -1) + resources := make([]map[string]any, 0, len(rawSlice)) + for _, raw := range rawSlice { + r := map[string]any{} + err = yaml.Unmarshal(raw, &r) + if err != nil { + return nil, err + } + if len(r) == 0 { + continue // Empty resource + } + resources = append(resources, r) } - return rawResource, nil + return resources, nil } -func template2Unstructured(tmpl *template.Template, tmplValues *TemplateValues) (*unstructured.Unstructured, error) { - rawResource, err := template2map(tmpl, tmplValues) +func template2Composite(r ControllerClient, tmpl *template.Template, tmplValues *TemplateValues) ([]ResourceComposite, error) { + rawResources, err := template2maps(tmpl, tmplValues) if err != nil { return nil, err } - return &unstructured.Unstructured{Object: rawResource}, nil + composites := make([]ResourceComposite, 0, len(rawResources)) + for rawIdx := range rawResources { + c := ResourceComposite{} + c.Rendered = &unstructured.Unstructured{Object: rawResources[rawIdx]} + c.GVR, c.IsNamespaced, err = unstructuredToGVR(r, c.Rendered) + if err != nil { + return nil, err + } + composites = append(composites, c) + } + return composites, nil } // Prepare a resource like Gateway or HTTPRoute for use in templates diff --git a/controllers/templating_test.go b/controllers/templating_test.go new file mode 100644 index 00000000..88b4ae0a --- /dev/null +++ b/controllers/templating_test.go @@ -0,0 +1,103 @@ +package controllers + +import ( + "testing" + + "k8s.io/apimachinery/pkg/util/yaml" +) + +func TestParseSingleTemplate(t *testing.T) { + template := "foo" + tmpl, err := parseSingleTemplate("foo", template) + if tmpl == nil || err != nil { + t.Fatalf("Error parsing template %v", err) + } +} + +var textTemplate = ` +t1: | + name: {{ .Values.name1 }} +t2: | + {{ if .Values.t2enable }} + name: {{ .Values.name2 }} + {{ end }} +t3: | + {{ range $idx,$data := .Values.t3data }} + name: {{ $.Values.name3 }}-{{ $data }}-{{ $idx }} + --- + {{ end }} +` + +var textValues = ` +name1: t1name +name2: t2name +name3: t3name +t2enable: false +t3data: +- foo1 +- foo2 +- foo3 +` + +func helperGetResourceState() ([]*ResourceTemplateState, error) { + templates := map[string]string{} + _ = yaml.Unmarshal([]byte(textTemplate), &templates) + return parseTemplates(templates) +} + +func helperGetValues() *TemplateValues { + values := map[string]any{} + _ = yaml.Unmarshal([]byte(textValues), &values) + templateValues := TemplateValues{ + Values: values, + } + return &templateValues +} + +func TestParseTemplate(t *testing.T) { + tmpl, err := helperGetResourceState() + if tmpl == nil || err != nil { + t.Fatalf("Error parsing templates %v", err) + } + if len(tmpl) != 3 { + t.Fatalf("Template slice length mismatch, got %v, expected 3", len(tmpl)) + } + if tmpl[0].TemplateName != "t1" { + t.Fatalf("Template[0] name, got %v, expected t1", tmpl[0].TemplateName) + } +} + +func TestTemplate2map(t *testing.T) { + tmpl, err := helperGetResourceState() + if err != nil { + t.Fatalf("Cannot get resource state: %v", err) + } + tmplValues := helperGetValues() + rawResources, err := template2maps(tmpl[0].Template, tmplValues) + if rawResources == nil || err != nil { + t.Fatalf("Cannot render template to map: %v", err) + } + if len(rawResources) != 1 { + t.Fatalf("Error rendering resource, got len %v, expected 1", len(rawResources)) + } + if rawResources[0]["name"] != "t1name" { + t.Fatalf("Rendered template error, got %v, expected 't1name'", rawResources[0]["name"]) + } + rawResources, err = template2maps(tmpl[1].Template, tmplValues) + if err != nil { + t.Fatalf("Error rendering empty resource, got err %v", err) + } + if len(rawResources) != 0 { + t.Fatalf("Error rendering empty resource, got len %v, expected 0", len(rawResources)) + } + rawResources, err = template2maps(tmpl[2].Template, tmplValues) + if err != nil { + t.Fatalf("Error rendering multi-resource, got err %v", err) + } + if len(rawResources) != 3 { + t.Fatalf("Error rendering multi-resource, got len %v, expected 3", len(rawResources)) + } + if rawResources[2]["name"] != "t3name-foo3-2" { + t.Fatalf("Rendered template error, got %v, expected 't3name-foo3-2'", rawResources[2]["name"]) + } +} diff --git a/doc/creating-gatewayclass-definitions.md b/doc/creating-gatewayclass-definitions.md index 20344230..2a3bc371 100644 --- a/doc/creating-gatewayclass-definitions.md +++ b/doc/creating-gatewayclass-definitions.md @@ -78,6 +78,25 @@ includes support for the 100+ functions from the [Sprig library](http://masterminds.github.io/sprig) as well as a `toYaml` function. +Typically templates will result in a single resource, but conditionals +and loops may result in templates rendering to zero or more than one +resource. This is supported but should be used with caution. + +Consideration for multi-resource templates: + +- Resources should be separated by a line with `---` (like in Helm). + +- The template as a whole is a single unit in the graph of resources, + i.e. individual resources in a template cannot refer to each other + using the `.Resources` method described below. References across + templates using multiple resources are supported. + +- It is supported to mix resource kinds in a single template, however, + consider if it would be more appropriate to use separate templates + in such cases. + +## Namespaced Resources + Namespace-scoped templated resources are always created in the namespace of the parent resource, e.g. a resource defined under `gatewayTemplate` will be created in the namespace of the parent @@ -95,12 +114,17 @@ When a resource template can be rendered without missing references, the rendered template will be used to retrieve the current version of the resource from the API server. These 'current resources' will be made available as template variables under `.Resources` and the name -of the template. +of the template. **Since a template may render to more than one +resource, the `.Resources` variable is a list**. The following excerpt from a `GatewayClassBlueprint` illustrates how a value is read from the status field of one resource `LBTargetGroup` and how the `status.atProvider.arn` value is used in the template of -`TargetGroupBinding` through `.Resources.LBTargetGroup`. +`TargetGroupBinding` through `.Resources.LBTargetGroup`. The use of +the `index` function is because we refer to the first resource +rendered from the `LBTargetGroup` template. This is necessary even if +we in this case know that there is always only a single resource +rendered from the template. ```yaml ... @@ -120,7 +144,7 @@ spec: ... spec: # And here we use the value 'status.atProvider.arn' from the 'LBTargetGroup' resource - targetGroupARN: {{ .Resources.LBTargetGroup.status.atProvider.arn }} + targetGroupARN: {{ (index .Resources.LBTargetGroup 0).status.atProvider.arn }} ``` The following figure illustrates variables available to templates, diff --git a/test/e2e/controller_self_test.go b/test/e2e/controller_self_test.go index d4d4a6e6..7b6ac43a 100644 --- a/test/e2e/controller_self_test.go +++ b/test/e2e/controller_self_test.go @@ -79,7 +79,7 @@ spec: template: | addresses: - type: IPAddress - value: {{ .Resources.configMapTestSource.data.testIPAddress }} + value: {{ (index .Resources.configMapTestSource 0).data.testIPAddress }} resourceTemplates: childGateway: | apiVersion: gateway.networking.k8s.io/v1beta1