external-dns/source/store_test.go
Ivan Ka d217706973
refactor(fqdn): encapsulate FQDN template logic into TemplateEngine (#6292)
* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

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

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

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

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

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

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

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

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

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

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

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

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

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

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

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

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

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

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

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

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

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

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

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

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

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

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

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

* efactor(fqdn): encapsulate FQDN template logic into TemplateEngine

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

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

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

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

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

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

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

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

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

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

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

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

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

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

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

---------

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>
2026-03-23 19:40:19 +05:30

395 lines
13 KiB
Go

/*
Copyright 2017 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 source
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
istiofake "istio.io/client-go/pkg/clientset/versioned/fake"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
fakeDynamic "k8s.io/client-go/dynamic/fake"
fakeKube "k8s.io/client-go/kubernetes/fake"
"sigs.k8s.io/external-dns/internal/testutils"
externaldns "sigs.k8s.io/external-dns/pkg/apis/externaldns"
"sigs.k8s.io/external-dns/source/types"
)
type ByNamesTestSuite struct {
suite.Suite
}
func (suite *ByNamesTestSuite) TestAllInitialized() {
mockClientGenerator := new(testutils.MockClientGenerator)
mockClientGenerator.On("KubeClient").Return(fakeKube.NewSimpleClientset(), nil)
mockClientGenerator.On("IstioClient").Return(istiofake.NewSimpleClientset(), nil)
mockClientGenerator.On("DynamicKubernetesClient").Return(fakeDynamic.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(),
map[schema.GroupVersionResource]string{
{
Group: "projectcontour.io",
Version: "v1",
Resource: "httpproxies",
}: "HTTPPRoxiesList",
{
Group: "contour.heptio.com",
Version: "v1beta1",
Resource: "tcpingresses",
}: "TCPIngressesList",
{
Group: "configuration.konghq.com",
Version: "v1beta1",
Resource: "tcpingresses",
}: "TCPIngressesList",
{
Group: "cis.f5.com",
Version: "v1",
Resource: "virtualservers",
}: "VirtualServersList",
{
Group: "cis.f5.com",
Version: "v1",
Resource: "transportservers",
}: "TransportServersList",
{
Group: "traefik.containo.us",
Version: "v1alpha1",
Resource: "ingressroutes",
}: "IngressRouteList",
{
Group: "traefik.containo.us",
Version: "v1alpha1",
Resource: "ingressroutetcps",
}: "IngressRouteTCPList",
{
Group: "traefik.containo.us",
Version: "v1alpha1",
Resource: "ingressrouteudps",
}: "IngressRouteUDPList",
{
Group: "traefik.io",
Version: "v1alpha1",
Resource: "ingressroutes",
}: "IngressRouteList",
{
Group: "traefik.io",
Version: "v1alpha1",
Resource: "ingressroutetcps",
}: "IngressRouteTCPList",
{
Group: "traefik.io",
Version: "v1alpha1",
Resource: "ingressrouteudps",
}: "IngressRouteUDPList",
}), nil)
ss := []string{
types.Service, types.Ingress, types.IstioGateway, types.ContourHTTPProxy,
types.KongTCPIngress, types.F5VirtualServer, types.F5TransportServer, types.TraefikProxy, types.Fake,
}
sources, err := ByNames(context.TODO(), &Config{
sources: ss,
}, mockClientGenerator)
suite.NoError(err, "should not generate errors")
suite.Len(sources, 9, "should generate all nine sources")
}
func (suite *ByNamesTestSuite) TestOnlyFake() {
mockClientGenerator := new(testutils.MockClientGenerator)
mockClientGenerator.On("KubeClient").Return(fakeKube.NewClientset(), nil)
sources, err := ByNames(context.TODO(), &Config{
sources: []string{types.Fake},
}, mockClientGenerator)
suite.NoError(err, "should not generate errors")
suite.Len(sources, 1, "should generate fake source")
suite.Nil(mockClientGenerator.KubeClientValue, "client should not be created")
}
func (suite *ByNamesTestSuite) TestSourceNotFound() {
mockClientGenerator := new(testutils.MockClientGenerator)
mockClientGenerator.On("KubeClient").Return(fakeKube.NewClientset(), nil)
sources, err := ByNames(context.TODO(), &Config{
sources: []string{"foo"},
}, mockClientGenerator)
suite.Equal(err, ErrSourceNotFound, "should return source not found")
suite.Empty(sources, "should not returns any source")
}
func (suite *ByNamesTestSuite) TestKubeClientFails() {
mockClientGenerator := new(testutils.MockClientGenerator)
mockClientGenerator.On("KubeClient").Return(nil, errors.New("foo"))
sourceUnderTest := []string{
types.Node, types.Service, types.Ingress, types.Pod, types.IstioGateway, types.IstioVirtualService,
types.AmbassadorHost, types.GlooProxy, types.TraefikProxy, types.CRD, types.KongTCPIngress,
types.F5VirtualServer, types.F5TransportServer,
}
for _, source := range sourceUnderTest {
_, err := ByNames(context.TODO(), &Config{
sources: []string{source},
}, mockClientGenerator)
suite.Error(err, source+" should return an error if kubernetes client cannot be created")
}
}
func (suite *ByNamesTestSuite) TestIstioClientFails() {
mockClientGenerator := new(testutils.MockClientGenerator)
mockClientGenerator.On("KubeClient").Return(fakeKube.NewSimpleClientset(), nil)
mockClientGenerator.On("IstioClient").Return(nil, errors.New("foo"))
mockClientGenerator.On("DynamicKubernetesClient").Return(nil, errors.New("foo"))
sourcesDependentOnIstioClient := []string{types.IstioGateway, types.IstioVirtualService}
for _, source := range sourcesDependentOnIstioClient {
_, err := ByNames(context.TODO(), &Config{
sources: []string{source},
}, mockClientGenerator)
suite.Error(err, source+" should return an error if istio client cannot be created")
}
}
func (suite *ByNamesTestSuite) TestDynamicKubernetesClientFails() {
mockClientGenerator := new(testutils.MockClientGenerator)
mockClientGenerator.On("KubeClient").Return(fakeKube.NewClientset(), nil)
mockClientGenerator.On("IstioClient").Return(istiofake.NewSimpleClientset(), nil)
mockClientGenerator.On("DynamicKubernetesClient").Return(nil, errors.New("foo"))
sourcesDependentOnDynamicKubernetesClient := []string{
types.AmbassadorHost, types.ContourHTTPProxy, types.GlooProxy, types.TraefikProxy,
types.KongTCPIngress, types.F5VirtualServer, types.F5TransportServer,
}
for _, source := range sourcesDependentOnDynamicKubernetesClient {
_, err := ByNames(context.TODO(), &Config{
sources: []string{source},
}, mockClientGenerator)
suite.Error(err, source+" should return an error if dynamic kubernetes client cannot be created")
}
}
func TestByNames(t *testing.T) {
suite.Run(t, new(ByNamesTestSuite))
}
func TestBuildWithConfig_InvalidSource(t *testing.T) {
ctx := t.Context()
p := testutils.StubClientGenerator{}
cfg := &Config{LabelFilter: labels.NewSelector()}
src, err := BuildWithConfig(ctx, "not-a-source", p, cfg)
if src != nil {
t.Errorf("expected nil source for invalid type, got: %v", src)
}
if !errors.Is(err, ErrSourceNotFound) {
t.Errorf("expected ErrSourceNotFound, got: %v", err)
}
}
func TestConfig_ClientGenerator_Caching(t *testing.T) {
cfg := &Config{
KubeConfig: "/path/to/kubeconfig",
APIServerURL: "https://api.example.com",
RequestTimeout: 30 * time.Second,
}
gen1 := cfg.ClientGenerator()
gen2 := cfg.ClientGenerator()
assert.Same(t, gen1, gen2, "ClientGenerator should return the same cached instance")
}
// TestSingletonClientGenerator_RESTConfig_TimeoutPropagation verifies timeout configuration
func TestSingletonClientGenerator_RESTConfig_TimeoutPropagation(t *testing.T) {
testCases := []struct {
name string
requestTimeout time.Duration
}{
{
name: "30 second timeout",
requestTimeout: 30 * time.Second,
},
{
name: "60 second timeout",
requestTimeout: 60 * time.Second,
},
{
name: "zero timeout (for watches)",
requestTimeout: 0,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
gen := &SingletonClientGenerator{
KubeConfig: "",
APIServerURL: "",
RequestTimeout: tc.requestTimeout,
}
// Verify the generator was configured with correct timeout
assert.Equal(t, tc.requestTimeout, gen.RequestTimeout,
"SingletonClientGenerator should have the configured RequestTimeout")
config, err := gen.RESTConfig()
// Even if config creation failed, verify the timeout was set in generator
assert.Equal(t, tc.requestTimeout, gen.RequestTimeout,
"RequestTimeout should remain unchanged after RESTConfig() call")
// If config was successfully created, verify timeout propagated correctly
if err == nil {
require.NotNil(t, config, "Config should not be nil when error is nil")
assert.Equal(t, tc.requestTimeout, config.Timeout,
"REST config should have timeout matching RequestTimeout field")
}
})
}
}
// TestConfig_ClientGenerator_RESTConfig_Integration verifies Config → ClientGenerator → RESTConfig flow
func TestConfig_ClientGenerator_RESTConfig_Integration(t *testing.T) {
t.Run("normal timeout is propagated", func(t *testing.T) {
cfg := &Config{RequestTimeout: 45 * time.Second}
config, err := cfg.ClientGenerator().RESTConfig()
if err == nil {
require.NotNil(t, config)
assert.Equal(t, 45*time.Second, config.Timeout, "RESTConfig should propagate the timeout")
}
})
t.Run("UpdateEvents sets timeout to zero", func(t *testing.T) {
cfg := &Config{RequestTimeout: 45 * time.Second, UpdateEvents: true}
config, err := cfg.ClientGenerator().RESTConfig()
if err == nil {
require.NotNil(t, config)
assert.Equal(t, time.Duration(0), config.Timeout, "RESTConfig should have zero timeout for watch operations")
}
})
}
// TestSingletonClientGenerator_RESTConfig_SharedAcrossClients verifies singleton is shared
func TestSingletonClientGenerator_RESTConfig_SharedAcrossClients(t *testing.T) {
gen := &SingletonClientGenerator{
KubeConfig: "/nonexistent/path/to/kubeconfig",
APIServerURL: "",
RequestTimeout: 30 * time.Second,
}
// Get REST config multiple times
restConfig1, err1 := gen.RESTConfig()
restConfig2, err2 := gen.RESTConfig()
restConfig3, err3 := gen.RESTConfig()
// Verify singleton behavior - all should return same instance
assert.Same(t, restConfig1, restConfig2, "RESTConfig should return same instance on second call")
assert.Same(t, restConfig1, restConfig3, "RESTConfig should return same instance on third call")
// Verify the internal field matches
assert.Same(t, restConfig1, gen.restConfig,
"Internal restConfig field should match returned value")
// Verify first call had error (no valid kubeconfig)
assert.Error(t, err1, "First call should return error when kubeconfig is invalid")
// Due to sync.Once bug, subsequent calls won't return the error
// This is documented in the TODO comment on SingletonClientGenerator
require.NoError(t, err2, "Second call does not return error due to sync.Once bug")
require.NoError(t, err3, "Third call does not return error due to sync.Once bug")
}
func TestNewSourceConfig(t *testing.T) {
tests := []struct {
name string
cfg *externaldns.Config
wantConfigured bool
wantCombining bool
wantErr bool
}{
{
name: "no templates configured",
cfg: &externaldns.Config{},
},
{
name: "fqdn template only",
cfg: &externaldns.Config{
FQDNTemplate: "{{.Name}}.example.com",
},
wantConfigured: true,
},
{
name: "fqdn template with combine",
cfg: &externaldns.Config{
FQDNTemplate: "{{.Name}}.example.com",
CombineFQDNAndAnnotation: true,
},
wantConfigured: true,
wantCombining: true,
},
{
name: "all three templates configured",
cfg: &externaldns.Config{
FQDNTemplate: "{{.Name}}.example.com",
TargetTemplate: "{{.Name}}.targets.example.com",
FQDNTargetTemplate: "{{.Name}}.example.com:{{.Name}}.targets.example.com",
CombineFQDNAndAnnotation: true,
},
wantConfigured: true,
wantCombining: true,
},
{
name: "invalid fqdn template",
cfg: &externaldns.Config{FQDNTemplate: "{{.Name"},
wantErr: true,
},
{
name: "invalid target template",
cfg: &externaldns.Config{TargetTemplate: "{{.Status.LoadBalancer.Ingress"},
wantErr: true,
},
{
name: "invalid fqdn-target template",
cfg: &externaldns.Config{FQDNTargetTemplate: "{{.Name}}.example.com:{{.Status"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := NewSourceConfig(tt.cfg)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
tmpl := got.TemplateEngine
assert.Equal(t, tt.wantConfigured, tmpl.IsConfigured(), "IsConfigured")
assert.Equal(t, tt.wantCombining, tmpl.Combining(), "Combining")
})
}
}