mirror of
https://github.com/kubernetes-sigs/external-dns.git
synced 2025-08-06 17:46:57 +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 -->
|
<!-- 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` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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.
|
||||||
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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]
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user