external-dns/source/template/engine_test.go
Ivan Ka eb40149b99
test: improve code coverage (#6321)
* test: improve code coverage

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* test: improve code coverage

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

---------

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>
2026-03-29 23:46:12 +05:30

602 lines
17 KiB
Go

/*
Copyright 2025 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package template
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/external-dns/endpoint"
)
func TestNewEngine(t *testing.T) {
for _, tt := range []struct {
name string
fqdn string
target string
fqdnTarget string
errContains string
}{
{
name: "invalid fqdn template",
fqdn: "{{.Name",
errContains: `parse --fqdn-template: "{{.Name"`,
},
{
name: "empty fqdn template",
},
{
name: "valid fqdn template",
fqdn: "{{.Name}}-{{.Namespace}}.ext-dns.test.com",
},
{
name: "valid fqdn template with multiple hosts",
fqdn: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com",
},
{
name: "replace template function",
fqdn: "{{\"hello.world\" | replace \".\" \"-\"}}.ext-dns.test.com",
},
{
name: "isIPv4 template function with valid IPv4",
fqdn: "{{if isIPv4 \"192.168.1.1\"}}valid{{else}}invalid{{end}}.ext-dns.test.com",
},
{
name: "isIPv4 template function with invalid IPv4",
fqdn: "{{if isIPv4 \"not.an.ip.addr\"}}valid{{else}}invalid{{end}}.ext-dns.test.com",
},
{
name: "isIPv6 template function with valid IPv6",
fqdn: "{{if isIPv6 \"2001:db8::1\"}}valid{{else}}invalid{{end}}.ext-dns.test.com",
},
{
name: "isIPv6 template function with invalid IPv6",
fqdn: "{{if isIPv6 \"not:ipv6:addr\"}}valid{{else}}invalid{{end}}.ext-dns.test.com",
},
{
name: "invalid target template",
target: "{{.Status.LoadBalancer.Ingress",
errContains: `parse --target-template: "{{.Status.LoadBalancer.Ingress"`,
},
{
name: "valid target template",
target: "{{.Name}}.targets.example.com",
},
{
name: "invalid fqdn-target template",
fqdnTarget: "{{.Name}}.example.com:{{.Status",
errContains: `parse --fqdn-target-template: "{{.Name}}.example.com:{{.Status"`,
},
{
name: "valid fqdn-target template",
fqdnTarget: "{{.Name}}.example.com:{{.Name}}.targets.example.com",
},
} {
t.Run(tt.name, func(t *testing.T) {
_, err := NewEngine(tt.fqdn, tt.target, tt.fqdnTarget, false)
if tt.errContains != "" {
assert.ErrorContains(t, err, tt.errContains)
} else {
assert.NoError(t, err)
}
})
}
}
func TestTemplateEngineIsConfigured(t *testing.T) {
empty, err := NewEngine("", "", "", false)
require.NoError(t, err)
assert.False(t, empty.IsConfigured())
configured, err := NewEngine("{{ .Name }}.example.com", "", "", false)
require.NoError(t, err)
assert.True(t, configured.IsConfigured())
}
func TestEngine_Combining(t *testing.T) {
t.Run("false when not set", func(t *testing.T) {
e, err := NewEngine("{{ .Name }}.example.com", "", "", false)
require.NoError(t, err)
assert.False(t, e.Combining())
})
t.Run("true when set", func(t *testing.T) {
e, err := NewEngine("{{ .Name }}.example.com", "", "", true)
require.NoError(t, err)
assert.True(t, e.Combining())
})
}
func TestEngine_ExecTarget(t *testing.T) {
obj := &testObject{ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: "default"}}
t.Run("returns targets from template", func(t *testing.T) {
e, err := NewEngine("", "{{ .Name }}.target.example.com", "", false)
require.NoError(t, err)
got, err := e.ExecTarget(obj)
require.NoError(t, err)
assert.Equal(t, []string{"svc.target.example.com"}, got)
})
t.Run("returns empty when target template is unset", func(t *testing.T) {
e, err := NewEngine("", "", "", false)
require.NoError(t, err)
got, err := e.ExecTarget(obj)
require.NoError(t, err)
assert.Empty(t, got)
})
t.Run("propagates execution error", func(t *testing.T) {
e, err := NewEngine("", "{{index . 0}}", "", false)
require.NoError(t, err)
_, err = e.ExecTarget(obj)
require.Error(t, err)
})
}
func TestEngine_ExecFQDNTarget(t *testing.T) {
obj := &testObject{ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: "default"}}
t.Run("returns fqdn:target pairs from template", func(t *testing.T) {
e, err := NewEngine("", "", "{{ .Name }}.example.com:1.2.3.4", false)
require.NoError(t, err)
got, err := e.ExecFQDNTarget(obj)
require.NoError(t, err)
assert.Equal(t, []string{"svc.example.com:1.2.3.4"}, got)
})
t.Run("returns empty when fqdn-target template is unset", func(t *testing.T) {
e, err := NewEngine("", "", "", false)
require.NoError(t, err)
got, err := e.ExecFQDNTarget(obj)
require.NoError(t, err)
assert.Empty(t, got)
})
t.Run("propagates execution error", func(t *testing.T) {
e, err := NewEngine("", "", "{{index . 0}}", false)
require.NoError(t, err)
_, err = e.ExecFQDNTarget(obj)
require.Error(t, err)
})
}
func TestExecFQDN(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{"default.example.org", "test.example.com"},
},
{
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: "trim prefix",
tmpl: `{{ trimPrefix .Name "the-" }}.example.com`,
obj: &testObject{
ObjectMeta: metav1.ObjectMeta{
Name: "the-test",
Namespace: "default",
},
},
want: []string{"test.example.com"},
},
{
name: "trim suffix",
tmpl: `{{ trimSuffix .Name "-v2" }}.example.com`,
obj: &testObject{
ObjectMeta: metav1.ObjectMeta{
Name: "test-v2",
Namespace: "default",
},
},
want: []string{"test.example.com"},
},
{
name: "replace dash",
tmpl: `{{ replace "-" "." .Name }}.example.com`,
obj: &testObject{
ObjectMeta: metav1.ObjectMeta{
Name: "test-v2",
Namespace: "default",
},
},
want: []string{"test.v2.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{"internal.production.company.org", "production.example.com"},
},
{
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"},
},
{
name: "ignore empty template output",
tmpl: "{{ if eq .Name \"other\" }}{{ .Name }}.example.com{{ end }}",
obj: &testObject{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
want: nil,
},
{
name: "ignore trailing comma output",
tmpl: "{{ .Name }}.example.com,",
obj: &testObject{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
want: []string{"test.example.com"},
},
{
name: "contains label with empty value",
tmpl: `{{if hasKey .Labels "service.kubernetes.io/headless"}}{{ .Name }}.example.com,{{end}}`,
obj: &testObject{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Labels: map[string]string{
"service.kubernetes.io/headless": "",
},
},
},
want: []string{"test.example.com"},
},
{
name: "result only contains unique values",
tmpl: `{{ .Name }}.example.com,{{ .Name }}.example.com,{{ .Name }}.example.com`,
obj: &testObject{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Labels: map[string]string{
"service.kubernetes.io/headless": "",
},
},
},
want: []string{"test.example.com"},
},
{
name: "dns entries in labels",
tmpl: `
{{ if hasKey .Labels "records" }}{{ range $entry := (index .Labels "records" | fromJson) }}{{ index $entry "dns" }},{{ end }}{{ end }}`,
obj: &testObject{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Labels: map[string]string{
"records": `
[{"dns":"entry1.internal.tld","target":"10.10.10.10"},{"dns":"entry2.example.tld","target":"my.cluster.local"}]`,
},
},
},
want: []string{"entry1.internal.tld", "entry2.example.tld"},
},
{
name: "configmap with multiple entries",
tmpl: `{{ range $entry := (index .Data "entries" | fromJson) }}{{ index $entry "dns" }},{{ end }}`,
obj: &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-configmap",
},
Data: map[string]string{
"entries": `
[{"dns":"entry1.internal.tld","target":"10.10.10.10"},{"dns":"entry2.example.tld","target":"my.cluster.local"}]`,
},
},
want: []string{"entry1.internal.tld", "entry2.example.tld"},
},
{
name: "rancher publicEndpoints annotation",
tmpl: `
{{ range $entry := (index .Annotations "field.cattle.io/publicEndpoints" | fromJson) }}{{ index $entry "hostname" }},{{ end }}`,
obj: &testObject{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Annotations: map[string]string{
"field.cattle.io/publicEndpoints": `
[{"addresses":[""],"port":80,"protocol":"HTTP",
"serviceName":"development:keycloak-ha-service",
"ingressName":"development:keycloak-ha-ingress",
"hostname":"keycloak.snip.com","allNodes":false
}]`,
},
},
},
want: []string{"keycloak.snip.com"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
engine, err := NewEngine(tt.tmpl, "", "", false)
require.NoError(t, err)
got, err := engine.ExecFQDN(tt.obj)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestExecFQDNNilObject(t *testing.T) {
engine, err := NewEngine("{{ toLower .Labels.department }}.example.org", "", "", false)
require.NoError(t, err)
_, err = engine.ExecFQDN(nil)
assert.Error(t, err)
}
func TestExecFQDNPopulatesEmptyKind(t *testing.T) {
// Test that Kind is populated when initially empty (simulates informer behavior)
engine, err := NewEngine("{{ .Kind }}.{{ .Name }}.example.com", "", "", false)
require.NoError(t, err)
// Create object with empty TypeMeta (Kind == "")
obj := &testObject{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
},
}
// Kind should be empty initially
assert.Empty(t, obj.GetObjectKind().GroupVersionKind().Kind)
got, err := engine.ExecFQDN(obj)
require.NoError(t, err)
// Kind should now be populated via reflection
assert.Equal(t, "testObject", obj.GetObjectKind().GroupVersionKind().Kind)
assert.Equal(t, []string{"testObject.test.example.com"}, got)
}
func TestExecFQDNPreservesExistingKind(t *testing.T) {
// Test that existing Kind is not overwritten
engine, err := NewEngine("{{ .Kind }}.{{ .Name }}.example.com", "", "", false)
require.NoError(t, err)
obj := &testObject{
TypeMeta: metav1.TypeMeta{
Kind: "CustomKind",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
},
}
got, err := engine.ExecFQDN(obj)
require.NoError(t, err)
// Kind should remain unchanged
assert.Equal(t, "CustomKind", obj.GetObjectKind().GroupVersionKind().Kind)
assert.Equal(t, []string{"CustomKind.test.example.com"}, got)
}
func TestExecFQDNExecutionError(t *testing.T) {
engine, err := NewEngine("{{ call .Name }}", "", "", false)
require.NoError(t, err)
obj := &metav1.PartialObjectMetadata{
TypeMeta: metav1.TypeMeta{
Kind: "TestKind",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test-name",
Namespace: "default",
},
}
_, err = engine.ExecFQDN(obj)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to apply template on TestKind default/test-name")
}
func TestCombineWithEndpoints(t *testing.T) {
configured, err := NewEngine("{{.Name}}", "", "", false)
require.NoError(t, err)
configuredCombine, err := NewEngine("{{.Name}}", "", "", true)
require.NoError(t, err)
unconfigured, err := NewEngine("", "", "", false)
require.NoError(t, err)
annotationEndpoints := []*endpoint.Endpoint{
endpoint.NewEndpoint("annotation.example.com", endpoint.RecordTypeA, "1.2.3.4"),
}
templatedEndpoints := []*endpoint.Endpoint{
endpoint.NewEndpoint("template.example.com", endpoint.RecordTypeA, "5.6.7.8"),
}
successTemplateFunc := func() ([]*endpoint.Endpoint, error) {
return templatedEndpoints, nil
}
errorTemplateFunc := func() ([]*endpoint.Endpoint, error) {
return nil, errors.New("template error")
}
tests := []struct {
name string
endpoints []*endpoint.Endpoint
engine Engine
templateFunc func() ([]*endpoint.Endpoint, error)
want []*endpoint.Endpoint
wantErr bool
}{
{
name: "unconfigured engine returns original endpoints",
endpoints: annotationEndpoints,
engine: unconfigured,
templateFunc: successTemplateFunc,
want: annotationEndpoints,
},
{
name: "combine=false with existing endpoints returns original",
endpoints: annotationEndpoints,
engine: configured,
templateFunc: successTemplateFunc,
want: annotationEndpoints,
},
{
name: "combine=false with empty endpoints returns templated",
endpoints: []*endpoint.Endpoint{},
engine: configured,
templateFunc: successTemplateFunc,
want: templatedEndpoints,
},
{
name: "combine=true appends templated to existing",
endpoints: annotationEndpoints,
engine: configuredCombine,
templateFunc: successTemplateFunc,
want: append(annotationEndpoints, templatedEndpoints...),
},
{
name: "combine=true with empty endpoints returns templated",
endpoints: []*endpoint.Endpoint{},
engine: configuredCombine,
templateFunc: successTemplateFunc,
want: templatedEndpoints,
},
{
name: "template error is propagated",
endpoints: []*endpoint.Endpoint{},
engine: configured,
templateFunc: errorTemplateFunc,
want: nil,
wantErr: true,
},
{
name: "nil endpoints with combine=false returns templated",
endpoints: nil,
engine: configured,
templateFunc: successTemplateFunc,
want: templatedEndpoints,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.engine.CombineWithEndpoints(
tt.endpoints,
tt.templateFunc,
)
if tt.wantErr {
require.Error(t, err)
require.ErrorContains(t, err, "failed to get endpoints from template")
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
type testObject struct {
metav1.TypeMeta
metav1.ObjectMeta
}
func (t *testObject) DeepCopyObject() runtime.Object {
return &testObject{
TypeMeta: t.TypeMeta,
ObjectMeta: *t.ObjectMeta.DeepCopy(),
}
}