mirror of
https://github.com/kubernetes-sigs/external-dns.git
synced 2026-04-15 21:11:01 +02:00
* 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>
602 lines
17 KiB
Go
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(),
|
|
}
|
|
}
|