diff --git a/docs/flags.md b/docs/flags.md index f03026c99..4e3ab35e9 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -180,5 +180,6 @@ | `--webhook-provider-read-timeout=5s` | The read timeout for the webhook provider in duration format (default: 5s) | | `--webhook-provider-write-timeout=10s` | The write timeout for the webhook provider in duration format (default: 10s) | | `--[no-]webhook-server` | When enabled, runs as a webhook server instead of a controller. (default: false). | +| `--service-per-pod-fqdn=` | enables/disables to create per pod FQDNs for headless services. (default: 'true' for pods with non empty Hostnames, 'false' otherwise). | | `--provider=provider` | The DNS provider where the DNS records will be created (required, options: akamai, alibabacloud, aws, aws-sd, azure, azure-dns, azure-private-dns, civo, cloudflare, coredns, digitalocean, dnsimple, exoscale, gandi, godaddy, google, inmemory, linode, ns1, oci, ovh, pdns, pihole, plural, rfc2136, scaleway, skydns, transip, webhook) | | `--source=source` | The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, node, pod, fake, connector, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, istio-gateway, istio-virtualservice, cloudfoundry, contour-httpproxy, gloo-proxy, crd, empty, skipper-routegroup, openshift-route, ambassador-host, kong-tcpingress, f5-virtualserver, f5-transportserver, traefik-proxy) | diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 5321b2636..42be8a7d3 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -217,6 +217,7 @@ type Config struct { ExcludeUnschedulable bool EmitEvents []string ForceDefaultTargets bool + ServicePerPodFqdn string } var defaultConfig = &Config{ @@ -382,6 +383,7 @@ var defaultConfig = &Config{ WebhookServer: false, ZoneIDFilter: []string{}, ForceDefaultTargets: false, + ServicePerPodFqdn: "", } var providerNames = []string{ @@ -804,6 +806,7 @@ func bindFlags(b FlagBinder, cfg *Config) { b.DurationVar("webhook-provider-read-timeout", "The read timeout for the webhook provider in duration format (default: 5s)", defaultConfig.WebhookProviderReadTimeout, &cfg.WebhookProviderReadTimeout) b.DurationVar("webhook-provider-write-timeout", "The write timeout for the webhook provider in duration format (default: 10s)", defaultConfig.WebhookProviderWriteTimeout, &cfg.WebhookProviderWriteTimeout) b.BoolVar("webhook-server", "When enabled, runs as a webhook server instead of a controller. (default: false).", defaultConfig.WebhookServer, &cfg.WebhookServer) + b.EnumVar("service-per-pod-fqdn", "enables/disables to create per pod FQDNs for headless services. (default: 'true' for pods with non empty Hostnames, 'false' otherwise).", defaultConfig.ServicePerPodFqdn, &cfg.ServicePerPodFqdn, "true", "false", "") } func App(cfg *Config) *kingpin.Application { diff --git a/source/service.go b/source/service.go index e2d3f4cfe..a4a434ca2 100644 --- a/source/service.go +++ b/source/service.go @@ -75,6 +75,7 @@ type serviceSource struct { alwaysPublishNotReadyAddresses bool resolveLoadBalancerHostname bool listenEndpointEvents bool + servicePerPodFqdn *bool serviceInformer coreinformers.ServiceInformer endpointSlicesInformer discoveryinformers.EndpointSliceInformer podInformer coreinformers.PodInformer @@ -97,7 +98,7 @@ func NewServiceSource( ignoreHostnameAnnotation bool, labelSelector labels.Selector, resolveLoadBalancerHostname, - listenEndpointEvents, exposeInternalIPv6 bool, + listenEndpointEvents, exposeInternalIPv6 bool, servicePerPodFqdn *bool, ) (Source, error) { tmpl, err := fqdn.ParseTemplate(fqdnTemplate) if err != nil { @@ -223,6 +224,7 @@ func NewServiceSource( resolveLoadBalancerHostname: resolveLoadBalancerHostname, listenEndpointEvents: listenEndpointEvents, exposeInternalIPv6: exposeInternalIPv6, + servicePerPodFqdn: servicePerPodFqdn, }, nil } @@ -410,8 +412,10 @@ func (sc *serviceSource) processHeadlessEndpointsFromSlices( continue } headlessDomains := []string{hostname} - if pod.Spec.Hostname != "" { + if pod.Spec.Hostname != "" && (sc.servicePerPodFqdn == nil || *sc.servicePerPodFqdn) { headlessDomains = append(headlessDomains, fmt.Sprintf("%s.%s", pod.Spec.Hostname, hostname)) + } else if sc.servicePerPodFqdn != nil && *sc.servicePerPodFqdn { + headlessDomains = append(headlessDomains, fmt.Sprintf("%s.%s", pod.Name, hostname)) } for _, headlessDomain := range headlessDomains { targets := sc.getTargetsForDomain(pod, ep, endpointSlice, endpointsType, headlessDomain) diff --git a/source/service_fqdn_test.go b/source/service_fqdn_test.go index 821e52f37..f03b13b9b 100644 --- a/source/service_fqdn_test.go +++ b/source/service_fqdn_test.go @@ -745,6 +745,7 @@ func TestServiceSourceFqdnTemplatingExamples(t *testing.T) { false, false, true, + nil, ) require.NoError(t, err) diff --git a/source/service_test.go b/source/service_test.go index 9c87bd536..81b2e1179 100644 --- a/source/service_test.go +++ b/source/service_test.go @@ -22,6 +22,7 @@ import ( "maps" "math/rand" "net" + "slices" "sort" "strings" "testing" @@ -38,6 +39,7 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" kubeinformers "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes/fake" + "k8s.io/utils/ptr" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/source/annotations" @@ -91,6 +93,7 @@ func (suite *ServiceSuite) SetupTest() { false, false, false, + nil, ) suite.NoError(err, "should initialize service source") } @@ -174,6 +177,7 @@ func testServiceSourceNewServiceSource(t *testing.T) { false, false, false, + nil, ) if ti.expectError { @@ -1158,6 +1162,7 @@ func testServiceSourceEndpoints(t *testing.T) { tc.resolveLoadBalancerHostname, false, false, + nil, ) require.NoError(t, err) @@ -1374,6 +1379,7 @@ func testMultipleServicesEndpoints(t *testing.T) { false, false, false, + nil, ) require.NoError(t, err) @@ -1679,6 +1685,7 @@ func TestClusterIpServices(t *testing.T) { false, false, false, + nil, ) require.NoError(t, err) @@ -2456,6 +2463,7 @@ func TestServiceSourceNodePortServices(t *testing.T) { false, false, tc.exposeInternalIPv6, + nil, ) require.NoError(t, err) @@ -3364,6 +3372,7 @@ func TestHeadlessServices(t *testing.T) { false, false, tc.exposeInternalIPv6, + nil, ) require.NoError(t, err) @@ -3500,6 +3509,7 @@ func TestMultipleServicesPointingToSameLoadBalancer(t *testing.T) { false, false, false, + nil, ) require.NoError(t, err) assert.NotNil(t, src) @@ -3866,6 +3876,7 @@ func TestMultipleHeadlessServicesPointingToPodsOnTheSameNode(t *testing.T) { false, false, false, + nil, ) require.NoError(t, err) assert.NotNil(t, src) @@ -4324,6 +4335,7 @@ func TestHeadlessServicesHostIP(t *testing.T) { false, false, false, + nil, ) require.NoError(t, err) @@ -4534,6 +4546,7 @@ func TestExternalServices(t *testing.T) { false, false, false, + nil, ) require.NoError(t, err) @@ -4596,6 +4609,7 @@ func BenchmarkServiceEndpoints(b *testing.B) { false, false, false, + nil, ) require.NoError(b, err) @@ -4695,6 +4709,7 @@ func TestNewServiceSourceInformersEnabled(t *testing.T) { false, false, false, + nil, ) require.NoError(t, err) svcSrc, ok := svc.(*serviceSource) @@ -4726,6 +4741,7 @@ func TestNewServiceSourceWithServiceTypeFilters_Unsupported(t *testing.T) { false, false, false, + nil, ) require.Errorf(t, err, "unsupported service type filter: \"UnknownType\". Supported types are: [\"ClusterIP\" \"NodePort\" \"LoadBalancer\" \"ExternalName\"]") require.Nil(t, svc, "ServiceSource should be nil when an unsupported service type is provided") @@ -4905,6 +4921,7 @@ func TestEndpointSlicesIndexer(t *testing.T) { false, false, false, + nil, ) require.NoError(t, err) ss, ok := src.(*serviceSource) @@ -4992,6 +5009,7 @@ func TestPodTransformerInServiceSource(t *testing.T) { false, false, false, + nil, ) require.NoError(t, err) ss, ok := src.(*serviceSource) @@ -5356,6 +5374,96 @@ func TestProcessEndpointSlices_NotReadyWithPublishNotReady(t *testing.T) { assert.NotEmpty(t, result, "Not ready endpoints should be processed when publishNotReadyAddresses is true") } +func TestProcessEndpointSlices_PerPodFQDN(t *testing.T) { + for _, test := range []struct { + title string + servicePerPodFqdn *bool + podName string + hostName string + expectedFqdnPrefix string + }{ + { + title: "pod's FQDN should be created if hostname is specified", + servicePerPodFqdn: nil, + podName: "test-pod-name", + hostName: "test-pod-host-name", + expectedFqdnPrefix: "test-pod-host-name", + }, + { + title: "pod's FQDN should be created if enabled", + servicePerPodFqdn: ptr.To(true), + podName: "test-pod-name", + hostName: "", + expectedFqdnPrefix: "test-pod-name", + }, + { + title: "pod's FQDN should not be created if hostname is specified and disabled", + servicePerPodFqdn: ptr.To(false), + podName: "test-pod-name", + hostName: "test-pod-host-name", + expectedFqdnPrefix: "", + }, + { + title: "pod's FQDN should not be created if disabled", + servicePerPodFqdn: ptr.To(false), + podName: "test-pod-name", + hostName: "", + expectedFqdnPrefix: "", + }, + { + title: "hostname should be used for pod's FQDN", + servicePerPodFqdn: ptr.To(true), + podName: "test-pod-name", + hostName: "test-pod-host-name", + expectedFqdnPrefix: "test-pod-host-name", + }, + } { + t.Run(test.title, func(t *testing.T) { + sc := &serviceSource{servicePerPodFqdn: test.servicePerPodFqdn} + svc := &v1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "test-service", Namespace: "default"}, + } + + endpointSlice := &discoveryv1.EndpointSlice{ + ObjectMeta: metav1.ObjectMeta{Name: "slice1", Namespace: "default"}, + AddressType: discoveryv1.AddressTypeIPv4, + Endpoints: []discoveryv1.Endpoint{ + { + TargetRef: &v1.ObjectReference{Kind: "Pod", Name: test.podName}, + Conditions: discoveryv1.EndpointConditions{Ready: testutils.ToPtr(false)}, // Not ready + Addresses: []string{"10.0.0.1"}, + }, + }, + } + pods := []*v1.Pod{{ + ObjectMeta: metav1.ObjectMeta{Name: test.podName}, + Status: v1.PodStatus{PodIP: "10.0.0.1"}, + Spec: v1.PodSpec{Hostname: test.hostName}, + }} + const serviceHostname = "test-service.example.com" + const endpointsType = "IPv4" + const publishPodIPs = false + const publishNotReadyAddresses = true // This should allow not-ready endpoints + + result := sc.processHeadlessEndpointsFromSlices( + svc, pods, []*discoveryv1.EndpointSlice{endpointSlice}, + serviceHostname, endpointsType, publishPodIPs, publishNotReadyAddresses) + if len(test.expectedFqdnPrefix) > 0 { + expectedPodFqdn := test.expectedFqdnPrefix + "." + serviceHostname + hasPodFqdn := slices.ContainsFunc(slices.Collect(maps.Keys(result)), func(record endpoint.EndpointKey) bool { + return record.DNSName == expectedPodFqdn + }) + assert.True(t, hasPodFqdn, "Endpoint with pod's hostname (%s) should be generated but got: %v", expectedPodFqdn, result) + } else { + hasServiceFqdn := slices.ContainsFunc(slices.Collect(maps.Keys(result)), func(record endpoint.EndpointKey) bool { + return record.DNSName == serviceHostname + }) + assert.True(t, hasServiceFqdn && len(result) == 1, "Result should include only service endpoint (%s): %v", serviceHostname, result) + } + }) + } +} + // Test getTargetsForDomain with empty ep.Addresses func TestGetTargetsForDomain_EmptyAddresses(t *testing.T) { sc := &serviceSource{} diff --git a/source/store.go b/source/store.go index 103ec94a3..57c35504e 100644 --- a/source/store.go +++ b/source/store.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "os" + "strings" "sync" "time" @@ -33,6 +34,7 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" + "k8s.io/utils/ptr" gateway "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" "sigs.k8s.io/external-dns/source/types" @@ -101,6 +103,7 @@ type Config struct { TraefikDisableNew bool ExcludeUnschedulable bool ExposeInternalIPv6 bool + ServicePerPodFqdn *bool } func NewSourceConfig(cfg *externaldns.Config) *Config { @@ -147,6 +150,18 @@ func NewSourceConfig(cfg *externaldns.Config) *Config { TraefikDisableNew: cfg.TraefikDisableNew, ExcludeUnschedulable: cfg.ExcludeUnschedulable, ExposeInternalIPv6: cfg.ExposeInternalIPV6, + ServicePerPodFqdn: toBoolPtr(cfg.ServicePerPodFqdn), + } +} + +func toBoolPtr(value string) *bool { + if len(value) == 0 { + return nil + } + if strings.EqualFold(value, "true") { + return ptr.To(true) + } else { + return ptr.To(false) } } @@ -429,7 +444,7 @@ func buildServiceSource(ctx context.Context, p ClientGenerator, cfg *Config) (So if err != nil { return nil, err } - return NewServiceSource(ctx, client, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.Compatibility, cfg.PublishInternal, cfg.PublishHostIP, cfg.AlwaysPublishNotReadyAddresses, cfg.ServiceTypeFilter, cfg.IgnoreHostnameAnnotation, cfg.LabelFilter, cfg.ResolveLoadBalancerHostname, cfg.ListenEndpointEvents, cfg.ExposeInternalIPv6) + return NewServiceSource(ctx, client, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.Compatibility, cfg.PublishInternal, cfg.PublishHostIP, cfg.AlwaysPublishNotReadyAddresses, cfg.ServiceTypeFilter, cfg.IgnoreHostnameAnnotation, cfg.LabelFilter, cfg.ResolveLoadBalancerHostname, cfg.ListenEndpointEvents, cfg.ExposeInternalIPv6, cfg.ServicePerPodFqdn) } // buildIngressSource creates an Ingress source for exposing Kubernetes ingresses as DNS records. diff --git a/source/store_test.go b/source/store_test.go index 77e29fa50..7f738570b 100644 --- a/source/store_test.go +++ b/source/store_test.go @@ -23,6 +23,7 @@ import ( "github.com/cloudfoundry-community/go-cfclient" openshift "github.com/openshift/client-go/route/clientset/versioned" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" istioclient "istio.io/client-go/pkg/clientset/versioned" @@ -34,6 +35,7 @@ import ( fakeDynamic "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/kubernetes" fakeKube "k8s.io/client-go/kubernetes/fake" + "k8s.io/utils/ptr" "sigs.k8s.io/external-dns/source/types" gateway "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" ) @@ -279,3 +281,9 @@ func TestBuildWithConfig_InvalidSource(t *testing.T) { t.Errorf("expected ErrSourceNotFound, got: %v", err) } } + +func TestToBoolPtr(t *testing.T) { + assert.Equal(t, ptr.To(true), toBoolPtr("true")) + assert.Equal(t, ptr.To(false), toBoolPtr("false")) + assert.Nil(t, toBoolPtr("")) +}