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 --> <!-- TODO: generate from code -->
| Function | Description | | Function | Description | Example |
|:-------------|:-----------------------------------------------------------------------------------------| |:-------------|:------------------------------------------------------|:---------------------------------------------------------------------------------|
| `trimPrefix` | Function from the `strings` package. Returns `string` without the provided leading prefix. | | `contains` | Check if `substr` is in `string` | `{{ contains "hello" "ell" }} → true` |
| `replace` | Function that performs a simple replacement of all `old` string with `new` in the source string. | | `isIPv4` | Validate an IPv4 address | `{{ isIPv4 "192.168.1.1" }} → true` |
| `isIPv4` | Function that checks if a string is a valid IPv4 address. | | `isIPv6` | Validate an IPv6 address (including IPv4-mapped IPv6) | `{{ isIPv6 "2001:db8::1" }} → true`<br/>`{{ isIPv6 "::FFFF:192.168.1.1" }}→true` |
| `isIPv6` | Function that checks if a string is a valid IPv6 address (including IPv4-mapped IPv6). | | `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) { 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 { if err != nil {
return nil, err return nil, err
} }

View File

@ -17,22 +17,55 @@ limitations under the License.
package fqdn package fqdn
import ( import (
"bytes"
"fmt"
"net/netip" "net/netip"
"strings" "strings"
"text/template" "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) { func ParseTemplate(input string) (tmpl *template.Template, err error) {
if fqdnTemplate == "" { if input == "" {
return nil, nil return nil, nil
} }
funcs := template.FuncMap{ funcs := template.FuncMap{
"contains": strings.Contains,
"trimPrefix": strings.TrimPrefix, "trimPrefix": strings.TrimPrefix,
"trimSuffix": strings.TrimSuffix,
"trim": strings.TrimSpace,
"toLower": strings.ToLower,
"replace": replace, "replace": replace,
"isIPv6": isIPv6String, "isIPv6": isIPv6String,
"isIPv4": isIPv4String, "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. // replace all instances of oldValue with newValue in target string.

View File

@ -20,6 +20,8 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
) )
func TestParseTemplate(t *testing.T) { 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) { func TestFqdnTemplate(t *testing.T) {
tests := []struct { tests := []struct {
name string 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. // TODO: The combine-fqdn-annotation flag is similarly vague.
if c.src.fqdnTemplate != nil && (len(hostnames) == 0 || c.src.combineFQDNAnnotation) { 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 { if err != nil {
return nil, err 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) { 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 { if err != nil {
return nil, err 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 // apply template if host is missing on gateway
if (sc.combineFQDNAnnotation || len(gwHostnames) == 0) && sc.fqdnTemplate != nil { 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 { if err != nil {
return nil, err 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) { 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 { if err != nil {
return nil, err return nil, err
} }

View File

@ -124,7 +124,7 @@ func (ns *nodeSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, erro
} }
if ns.fqdnTemplate != nil { if ns.fqdnTemplate != nil {
hostnames, err := execTemplate(ns.fqdnTemplate, node) hostnames, err := fqdn.ExecTemplate(ns.fqdnTemplate, node)
if err != nil { if err != nil {
return nil, err 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) { 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 { if err != nil {
return nil, err 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) { 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 { if err != nil {
return nil, err return nil, err
} }

View File

@ -17,14 +17,10 @@ limitations under the License.
package source package source
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"reflect" "reflect"
"strings"
"text/template"
"time" "time"
"unicode"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/labels"
@ -63,20 +59,6 @@ type kubeObject interface {
metav1.Object 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 { func getAccessFromAnnotations(input map[string]string) string {
return input[accessAnnotationKey] return input[accessAnnotationKey]
} }