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 <ivan.katliarchuk@gmail.com>

* 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 <ivan.katliarchuk@gmail.com>

---------

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>
Co-authored-by: Lino Layani <39967417+linoleparquet@users.noreply.github.com>
Co-authored-by: Michel Loiseleur <97035654+mloiseleur@users.noreply.github.com>
This commit is contained in:
Ivan Ka 2025-05-19 20:35:22 +01:00 committed by GitHub
parent 0a72dc4268
commit b20025e311
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 186 additions and 35 deletions

View File

@ -65,12 +65,16 @@ The template uses the following data from the source object (e.g., a `Service` o
<!-- TODO: generate from code -->
| 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`<br/>`{{ 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` |
---

View File

@ -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
}

View File

@ -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.

View File

@ -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(),
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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]
}