mirror of
https://github.com/kubernetes-sigs/external-dns.git
synced 2025-08-06 09:36:58 +02:00
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:
parent
0a72dc4268
commit
b20025e311
@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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]
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user