From b20025e31193c0e3975d573e4803c7e1d7ca16d6 Mon Sep 17 00:00:00 2001 From: Ivan Ka <5395690+ivankatliarchuk@users.noreply.github.com> Date: Mon, 19 May 2025 20:35:22 +0100 Subject: [PATCH] feat(fqdn): improve ExecTemplate and add more functions (#5406) * chore(fqdn): fqdn move ExecTemplate to fqdn. add proper tests * chore(fqdn): fqdn move ExecTemplate to fqdn. add proper tests Signed-off-by: ivan katliarchuk * chore(fqdn): fqdn move ExecTemplate to fqdn. add proper tests Co-authored-by: Lino Layani <39967417+linoleparquet@users.noreply.github.com> * chore(fqdn): fqdn move ExecTemplate to fqdn. add proper tests Co-authored-by: Michel Loiseleur <97035654+mloiseleur@users.noreply.github.com> * chore(fqdn): fqdn move ExecTemplate to fqdn. add proper tests Signed-off-by: ivan katliarchuk --------- Signed-off-by: ivan katliarchuk Co-authored-by: Lino Layani <39967417+linoleparquet@users.noreply.github.com> Co-authored-by: Michel Loiseleur <97035654+mloiseleur@users.noreply.github.com> --- docs/advanced/fqdn-templating.md | 16 ++-- source/contour_httpproxy.go | 2 +- source/fqdn/fqdn.go | 39 ++++++++- source/fqdn/fqdn_test.go | 132 +++++++++++++++++++++++++++++++ source/gateway.go | 2 +- source/ingress.go | 2 +- source/istio_gateway.go | 2 +- source/istio_virtualservice.go | 2 +- source/node.go | 2 +- source/openshift_route.go | 2 +- source/service.go | 2 +- source/source.go | 18 ----- 12 files changed, 186 insertions(+), 35 deletions(-) diff --git a/docs/advanced/fqdn-templating.md b/docs/advanced/fqdn-templating.md index 16607f33f..9993708a9 100644 --- a/docs/advanced/fqdn-templating.md +++ b/docs/advanced/fqdn-templating.md @@ -65,12 +65,16 @@ The template uses the following data from the source object (e.g., a `Service` o -| Function | Description | -|:-------------|:-----------------------------------------------------------------------------------------| -| `trimPrefix` | Function from the `strings` package. Returns `string` without the provided leading prefix. | -| `replace` | Function that performs a simple replacement of all `old` string with `new` in the source string. | -| `isIPv4` | Function that checks if a string is a valid IPv4 address. | -| `isIPv6` | Function that checks if a string is a valid IPv6 address (including IPv4-mapped IPv6). | +| Function | Description | Example | +|:-------------|:------------------------------------------------------|:---------------------------------------------------------------------------------| +| `contains` | Check if `substr` is in `string` | `{{ contains "hello" "ell" }} → true` | +| `isIPv4` | Validate an IPv4 address | `{{ isIPv4 "192.168.1.1" }} → true` | +| `isIPv6` | Validate an IPv6 address (including IPv4-mapped IPv6) | `{{ isIPv6 "2001:db8::1" }} → true`
`{{ isIPv6 "::FFFF:192.168.1.1" }}→true` | +| `replace` | Replace `old` with `new` | `{{ replace "hello" "l" "w" }} → hewwo` | +| `trim` | Remove leading and trailing spaces | `{{ trim " hello " }} → hello` | +| `toLower` | Convert to lowercase | `{{ toLower "HELLO" }} → hello` | +| `trimPrefix` | Remove the leading `prefix` | `{{ trimPrefix "pre" "prefix" }} → fix` | +| `trimSuffix` | Remove the trailing `suffix` | `{{ trimSuffix "fix" "suffix" }} → suf` | --- diff --git a/source/contour_httpproxy.go b/source/contour_httpproxy.go index 0d872d5e2..7977ea840 100644 --- a/source/contour_httpproxy.go +++ b/source/contour_httpproxy.go @@ -181,7 +181,7 @@ func (sc *httpProxySource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, } func (sc *httpProxySource) endpointsFromTemplate(httpProxy *projectcontour.HTTPProxy) ([]*endpoint.Endpoint, error) { - hostnames, err := execTemplate(sc.fqdnTemplate, httpProxy) + hostnames, err := fqdn.ExecTemplate(sc.fqdnTemplate, httpProxy) if err != nil { return nil, err } diff --git a/source/fqdn/fqdn.go b/source/fqdn/fqdn.go index d7ba044ac..1c01d0450 100644 --- a/source/fqdn/fqdn.go +++ b/source/fqdn/fqdn.go @@ -17,22 +17,55 @@ limitations under the License. package fqdn import ( + "bytes" + "fmt" "net/netip" "strings" "text/template" + "unicode" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" ) -func ParseTemplate(fqdnTemplate string) (tmpl *template.Template, err error) { - if fqdnTemplate == "" { +func ParseTemplate(input string) (tmpl *template.Template, err error) { + if input == "" { return nil, nil } funcs := template.FuncMap{ + "contains": strings.Contains, "trimPrefix": strings.TrimPrefix, + "trimSuffix": strings.TrimSuffix, + "trim": strings.TrimSpace, + "toLower": strings.ToLower, "replace": replace, "isIPv6": isIPv6String, "isIPv4": isIPv4String, } - return template.New("endpoint").Funcs(funcs).Parse(fqdnTemplate) + return template.New("endpoint").Funcs(funcs).Parse(input) +} + +type kubeObject interface { + runtime.Object + metav1.Object +} + +func ExecTemplate(tmpl *template.Template, obj kubeObject) ([]string, error) { + if obj == nil { + return nil, fmt.Errorf("object is nil") + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, obj); err != nil { + kind := obj.GetObjectKind().GroupVersionKind().Kind + return nil, fmt.Errorf("failed to apply template on %s %s/%s: %w", kind, obj.GetNamespace(), obj.GetName(), err) + } + var hostnames []string + for _, name := range strings.Split(buf.String(), ",") { + name = strings.TrimFunc(name, unicode.IsSpace) + name = strings.TrimSuffix(name, ".") + hostnames = append(hostnames, name) + } + return hostnames, nil } // replace all instances of oldValue with newValue in target string. diff --git a/source/fqdn/fqdn_test.go b/source/fqdn/fqdn_test.go index 3aa23807c..9ec101815 100644 --- a/source/fqdn/fqdn_test.go +++ b/source/fqdn/fqdn_test.go @@ -20,6 +20,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" ) func TestParseTemplate(t *testing.T) { @@ -97,6 +99,125 @@ func TestParseTemplate(t *testing.T) { } } +func TestExecTemplate(t *testing.T) { + tests := []struct { + name string + tmpl string + obj kubeObject + want []string + wantErr bool + }{ + { + name: "simple template", + tmpl: "{{ .Name }}.example.com, {{ .Namespace }}.example.org", + obj: &testObject{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + want: []string{"test.example.com", "default.example.org"}, + }, + { + name: "multiple hostnames", + tmpl: "{{.Name}}.example.com, {{.Name}}.example.org", + obj: &testObject{ + + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + want: []string{"test.example.com", "test.example.org"}, + }, + { + name: "trim spaces", + tmpl: " {{ trim .Name}}.example.com. ", + obj: &testObject{ + ObjectMeta: metav1.ObjectMeta{ + Name: " test ", + }, + }, + want: []string{"test.example.com"}, + }, + { + name: "annotations and labels", + tmpl: "{{.Labels.environment }}.example.com, {{ index .ObjectMeta.Annotations \"alb.ingress.kubernetes.io/scheme\" }}.{{ .Labels.environment }}.{{ index .ObjectMeta.Annotations \"dns.company.com/zone\" }}", + obj: &testObject{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Annotations: map[string]string{ + "external-dns.alpha.kubernetes.io/hostname": "test.example.com, test.example.org", + "kubernetes.io/role/internal-elb": "true", + "alb.ingress.kubernetes.io/scheme": "internal", + "dns.company.com/zone": "company.org", + }, + Labels: map[string]string{ + "environment": "production", + "app": "myapp", + "tier": "backend", + "role": "worker", + "version": "1", + }, + }, + }, + want: []string{"production.example.com", "internal.production.company.org"}, + }, + { + name: "labels to lowercase", + tmpl: "{{ toLower .Labels.department }}.example.org", + obj: &testObject{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Labels: map[string]string{ + "department": "FINANCE", + "app": "myapp", + }, + }, + }, + want: []string{"finance.example.org"}, + }, + { + name: "generate multiple hostnames with if condition", + tmpl: "{{ if contains (index .ObjectMeta.Annotations \"external-dns.alpha.kubernetes.io/hostname\") \"example.com\" }}{{ toLower .Labels.hostoverride }}{{end}}", + obj: &testObject{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Labels: map[string]string{ + "hostoverride": "abrakadabra.google.com", + "app": "myapp", + }, + Annotations: map[string]string{ + "external-dns.alpha.kubernetes.io/hostname": "test.example.com", + }, + }, + }, + want: []string{"abrakadabra.google.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpl, err := ParseTemplate(tt.tmpl) + assert.NoError(t, err) + + got, err := ExecTemplate(tmpl, tt.obj) + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestExecTemplateEmptyObject(t *testing.T) { + tmpl, err := ParseTemplate("{{ toLower .Labels.department }}.example.org") + assert.NoError(t, err) + _, err = ExecTemplate(tmpl, nil) + assert.Error(t, err) +} + func TestFqdnTemplate(t *testing.T) { tests := []struct { name string @@ -259,3 +380,14 @@ func TestIsIPv4String(t *testing.T) { }) } } + +type testObject struct { + metav1.ObjectMeta + runtime.Object +} + +func (t *testObject) DeepCopyObject() runtime.Object { + return &testObject{ + ObjectMeta: *t.ObjectMeta.DeepCopy(), + } +} diff --git a/source/gateway.go b/source/gateway.go index 1bba6c58e..48315bbe2 100644 --- a/source/gateway.go +++ b/source/gateway.go @@ -410,7 +410,7 @@ func (c *gatewayRouteResolver) hosts(rt gatewayRoute) ([]string, error) { } // TODO: The combine-fqdn-annotation flag is similarly vague. if c.src.fqdnTemplate != nil && (len(hostnames) == 0 || c.src.combineFQDNAnnotation) { - hosts, err := execTemplate(c.src.fqdnTemplate, rt.Object()) + hosts, err := fqdn.ExecTemplate(c.src.fqdnTemplate, rt.Object()) if err != nil { return nil, err } diff --git a/source/ingress.go b/source/ingress.go index 9a2eec163..f00c170f4 100644 --- a/source/ingress.go +++ b/source/ingress.go @@ -179,7 +179,7 @@ func (sc *ingressSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, e } func (sc *ingressSource) endpointsFromTemplate(ing *networkv1.Ingress) ([]*endpoint.Endpoint, error) { - hostnames, err := execTemplate(sc.fqdnTemplate, ing) + hostnames, err := fqdn.ExecTemplate(sc.fqdnTemplate, ing) if err != nil { return nil, err } diff --git a/source/istio_gateway.go b/source/istio_gateway.go index 546c211e7..ceed678c9 100644 --- a/source/istio_gateway.go +++ b/source/istio_gateway.go @@ -156,7 +156,7 @@ func (sc *gatewaySource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, e // apply template if host is missing on gateway if (sc.combineFQDNAnnotation || len(gwHostnames) == 0) && sc.fqdnTemplate != nil { - iHostnames, err := execTemplate(sc.fqdnTemplate, gateway) + iHostnames, err := fqdn.ExecTemplate(sc.fqdnTemplate, gateway) if err != nil { return nil, err } diff --git a/source/istio_virtualservice.go b/source/istio_virtualservice.go index 7e76f597a..1e0a75067 100644 --- a/source/istio_virtualservice.go +++ b/source/istio_virtualservice.go @@ -229,7 +229,7 @@ func (sc *virtualServiceSource) getGateway(_ context.Context, gatewayStr string, } func (sc *virtualServiceSource) endpointsFromTemplate(ctx context.Context, virtualService *networkingv1alpha3.VirtualService) ([]*endpoint.Endpoint, error) { - hostnames, err := execTemplate(sc.fqdnTemplate, virtualService) + hostnames, err := fqdn.ExecTemplate(sc.fqdnTemplate, virtualService) if err != nil { return nil, err } diff --git a/source/node.go b/source/node.go index 3f76e6655..38ecb5457 100644 --- a/source/node.go +++ b/source/node.go @@ -124,7 +124,7 @@ func (ns *nodeSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, erro } if ns.fqdnTemplate != nil { - hostnames, err := execTemplate(ns.fqdnTemplate, node) + hostnames, err := fqdn.ExecTemplate(ns.fqdnTemplate, node) if err != nil { return nil, err } diff --git a/source/openshift_route.go b/source/openshift_route.go index d500cd228..1168d054d 100644 --- a/source/openshift_route.go +++ b/source/openshift_route.go @@ -170,7 +170,7 @@ func (ors *ocpRouteSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, } func (ors *ocpRouteSource) endpointsFromTemplate(ocpRoute *routev1.Route) ([]*endpoint.Endpoint, error) { - hostnames, err := execTemplate(ors.fqdnTemplate, ocpRoute) + hostnames, err := fqdn.ExecTemplate(ors.fqdnTemplate, ocpRoute) if err != nil { return nil, err } diff --git a/source/service.go b/source/service.go index c341d3f85..0fc6eb642 100644 --- a/source/service.go +++ b/source/service.go @@ -386,7 +386,7 @@ func (sc *serviceSource) extractHeadlessEndpoints(svc *v1.Service, hostname stri } func (sc *serviceSource) endpointsFromTemplate(svc *v1.Service) ([]*endpoint.Endpoint, error) { - hostnames, err := execTemplate(sc.fqdnTemplate, svc) + hostnames, err := fqdn.ExecTemplate(sc.fqdnTemplate, svc) if err != nil { return nil, err } diff --git a/source/source.go b/source/source.go index 414ff1ad5..1b45c7630 100644 --- a/source/source.go +++ b/source/source.go @@ -17,14 +17,10 @@ limitations under the License. package source import ( - "bytes" "context" "fmt" "reflect" - "strings" - "text/template" "time" - "unicode" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" @@ -63,20 +59,6 @@ type kubeObject interface { metav1.Object } -func execTemplate(tmpl *template.Template, obj kubeObject) (hostnames []string, err error) { - var buf bytes.Buffer - if err := tmpl.Execute(&buf, obj); err != nil { - kind := obj.GetObjectKind().GroupVersionKind().Kind - return nil, fmt.Errorf("failed to apply template on %s %s/%s: %w", kind, obj.GetNamespace(), obj.GetName(), err) - } - for _, name := range strings.Split(buf.String(), ",") { - name = strings.TrimFunc(name, unicode.IsSpace) - name = strings.TrimSuffix(name, ".") - hostnames = append(hostnames, name) - } - return hostnames, nil -} - func getAccessFromAnnotations(input map[string]string) string { return input[accessAnnotationKey] }