diff --git a/charts/external-dns/README.md b/charts/external-dns/README.md index bfdb56deb6..a0e543ed4a 100644 --- a/charts/external-dns/README.md +++ b/charts/external-dns/README.md @@ -83,16 +83,17 @@ The following table lists the configurable parameters of the _ExternalDNS_ chart | `secretConfiguration.mountPath` | Mount path of secret configuration secret (this can be templated). | `""` | | `secretConfiguration.data` | Secret configuration secret data. Could be used to store DNS provider credentials. | `{}` | | `secretConfiguration.subPath` | Sub-path of secret configuration secret (this can be templated). | `""` | +| `resolveServiceLoadBalancerHostname` | Resolve the hostname of LoadBalancer-type Service object to IP addresses in order to create DNS A/AAAA records instead of CNAMEs | `false` | ## Namespaced scoped installation -external-dns supports running on a namespaced only scope, too. +external-dns supports running on a namespaced only scope, too. If `namespaced=true` is defined, the helm chart will setup `Roles` and `RoleBindings` instead `ClusterRoles` and `ClusterRoleBindings`. ### Limited supported Not all sources are supported in namespaced scope, since some sources depends on cluster-wide resources. For example: Source `node` isn't supported, since `kind: Node` has scope `Cluster`. -Sources like `istio-virtualservice` only work, if all resources like `Gateway` and `VirtualService` are present in the same +Sources like `istio-virtualservice` only work, if all resources like `Gateway` and `VirtualService` are present in the same namespaces as `external-dns`. The annotation `external-dns.alpha.kubernetes.io/endpoints-type: NodeExternalIP` is not supported. diff --git a/charts/external-dns/templates/deployment.yaml b/charts/external-dns/templates/deployment.yaml index 5c3e1128ff..a2f0b74f99 100644 --- a/charts/external-dns/templates/deployment.yaml +++ b/charts/external-dns/templates/deployment.yaml @@ -96,6 +96,9 @@ spec: - --domain-filter={{ . }} {{- end }} - --provider={{ tpl .Values.provider $ }} + {{- if .Values.resolveServiceLoadBalancerHostname }} + - --resolve-service-load-balancer-hostname + {{- end }} {{- range .Values.extraArgs }} - {{ tpl . $ }} {{- end }} diff --git a/charts/external-dns/values.yaml b/charts/external-dns/values.yaml index 6e0f80265a..1084cd2acb 100644 --- a/charts/external-dns/values.yaml +++ b/charts/external-dns/values.yaml @@ -15,6 +15,8 @@ fullnameOverride: "" commonLabels: {} +resolveServiceLoadBalancerHostname: false + serviceAccount: # Specifies whether a service account should be created create: true diff --git a/main.go b/main.go index c408c37ee6..960045ce98 100644 --- a/main.go +++ b/main.go @@ -141,6 +141,7 @@ func main() { DefaultTargets: cfg.DefaultTargets, OCPRouterName: cfg.OCPRouterName, UpdateEvents: cfg.UpdateEvents, + ResolveLoadBalancerHostname: cfg.ResolveServiceLoadBalancerHostname, } // Lookup all the selected sources by names and pass them the desired configuration. diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 9b6c4d3e9e..eefd2b2bf0 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -43,164 +43,165 @@ var Version = "unknown" // Config is a project-wide configuration type Config struct { - APIServerURL string - KubeConfig string - RequestTimeout time.Duration - DefaultTargets []string - ContourLoadBalancerService string - GlooNamespace string - SkipperRouteGroupVersion string - Sources []string - Namespace string - AnnotationFilter string - LabelFilter string - FQDNTemplate string - CombineFQDNAndAnnotation bool - IgnoreHostnameAnnotation bool - IgnoreIngressTLSSpec bool - IgnoreIngressRulesSpec bool - GatewayNamespace string - GatewayLabelFilter string - Compatibility string - PublishInternal bool - PublishHostIP bool - AlwaysPublishNotReadyAddresses bool - ConnectorSourceServer string - Provider string - GoogleProject string - GoogleBatchChangeSize int - GoogleBatchChangeInterval time.Duration - GoogleZoneVisibility string - DomainFilter []string - ExcludeDomains []string - RegexDomainFilter *regexp.Regexp - RegexDomainExclusion *regexp.Regexp - ZoneNameFilter []string - ZoneIDFilter []string - TargetNetFilter []string - ExcludeTargetNets []string - AlibabaCloudConfigFile string - AlibabaCloudZoneType string - AWSZoneType string - AWSZoneTagFilter []string - AWSAssumeRole string - AWSAssumeRoleExternalID string - AWSBatchChangeSize int - AWSBatchChangeInterval time.Duration - AWSEvaluateTargetHealth bool - AWSAPIRetries int - AWSPreferCNAME bool - AWSZoneCacheDuration time.Duration - AWSSDServiceCleanup bool - AzureConfigFile string - AzureResourceGroup string - AzureSubscriptionID string - AzureUserAssignedIdentityClientID string - BluecatDNSConfiguration string - BluecatConfigFile string - BluecatDNSView string - BluecatGatewayHost string - BluecatRootZone string - BluecatDNSServerName string - BluecatDNSDeployType string - BluecatSkipTLSVerify bool - CloudflareProxied bool - CloudflareDNSRecordsPerPage int - CoreDNSPrefix string - RcodezeroTXTEncrypt bool - AkamaiServiceConsumerDomain string - AkamaiClientToken string - AkamaiClientSecret string - AkamaiAccessToken string - AkamaiEdgercPath string - AkamaiEdgercSection string - InfobloxGridHost string - InfobloxWapiPort int - InfobloxWapiUsername string - InfobloxWapiPassword string `secure:"yes"` - InfobloxWapiVersion string - InfobloxSSLVerify bool - InfobloxView string - InfobloxMaxResults int - InfobloxFQDNRegEx string - InfobloxNameRegEx string - InfobloxCreatePTR bool - InfobloxCacheDuration int - DynCustomerName string - DynUsername string - DynPassword string `secure:"yes"` - DynMinTTLSeconds int - OCIConfigFile string - OCICompartmentOCID string - OCIAuthInstancePrincipal bool - InMemoryZones []string - OVHEndpoint string - OVHApiRateLimit int - PDNSServer string - PDNSAPIKey string `secure:"yes"` - PDNSTLSEnabled bool - TLSCA string - TLSClientCert string - TLSClientCertKey string - Policy string - Registry string - TXTOwnerID string - TXTPrefix string - TXTSuffix string - Interval time.Duration - MinEventSyncInterval time.Duration - Once bool - DryRun bool - UpdateEvents bool - LogFormat string - MetricsAddress string - LogLevel string - TXTCacheInterval time.Duration - TXTWildcardReplacement string - ExoscaleEndpoint string - ExoscaleAPIKey string `secure:"yes"` - ExoscaleAPISecret string `secure:"yes"` - CRDSourceAPIVersion string - CRDSourceKind string - ServiceTypeFilter []string - CFAPIEndpoint string - CFUsername string - CFPassword string - RFC2136Host string - RFC2136Port int - RFC2136Zone string - RFC2136Insecure bool - RFC2136GSSTSIG bool - RFC2136KerberosRealm string - RFC2136KerberosUsername string - RFC2136KerberosPassword string `secure:"yes"` - RFC2136TSIGKeyName string - RFC2136TSIGSecret string `secure:"yes"` - RFC2136TSIGSecretAlg string - RFC2136TAXFR bool - RFC2136MinTTL time.Duration - RFC2136BatchChangeSize int - NS1Endpoint string - NS1IgnoreSSL bool - NS1MinTTLSeconds int - TransIPAccountName string - TransIPPrivateKeyFile string - DigitalOceanAPIPageSize int - ManagedDNSRecordTypes []string - GoDaddyAPIKey string `secure:"yes"` - GoDaddySecretKey string `secure:"yes"` - GoDaddyTTL int64 - GoDaddyOTE bool - OCPRouterName string - IBMCloudProxied bool - IBMCloudConfigFile string - TencentCloudConfigFile string - TencentCloudZoneType string - PiholeServer string - PiholePassword string `secure:"yes"` - PiholeTLSInsecureSkipVerify bool - PluralCluster string - PluralProvider string + APIServerURL string + KubeConfig string + RequestTimeout time.Duration + DefaultTargets []string + ContourLoadBalancerService string + GlooNamespace string + SkipperRouteGroupVersion string + Sources []string + Namespace string + AnnotationFilter string + LabelFilter string + FQDNTemplate string + CombineFQDNAndAnnotation bool + IgnoreHostnameAnnotation bool + IgnoreIngressTLSSpec bool + IgnoreIngressRulesSpec bool + GatewayNamespace string + GatewayLabelFilter string + Compatibility string + PublishInternal bool + PublishHostIP bool + AlwaysPublishNotReadyAddresses bool + ConnectorSourceServer string + Provider string + GoogleProject string + GoogleBatchChangeSize int + GoogleBatchChangeInterval time.Duration + GoogleZoneVisibility string + DomainFilter []string + ExcludeDomains []string + RegexDomainFilter *regexp.Regexp + RegexDomainExclusion *regexp.Regexp + ZoneNameFilter []string + ZoneIDFilter []string + TargetNetFilter []string + ExcludeTargetNets []string + AlibabaCloudConfigFile string + AlibabaCloudZoneType string + AWSZoneType string + AWSZoneTagFilter []string + AWSAssumeRole string + AWSAssumeRoleExternalID string + AWSBatchChangeSize int + AWSBatchChangeInterval time.Duration + AWSEvaluateTargetHealth bool + AWSAPIRetries int + AWSPreferCNAME bool + AWSZoneCacheDuration time.Duration + AWSSDServiceCleanup bool + AzureConfigFile string + AzureResourceGroup string + AzureSubscriptionID string + AzureUserAssignedIdentityClientID string + BluecatDNSConfiguration string + BluecatConfigFile string + BluecatDNSView string + BluecatGatewayHost string + BluecatRootZone string + BluecatDNSServerName string + BluecatDNSDeployType string + BluecatSkipTLSVerify bool + CloudflareProxied bool + CloudflareDNSRecordsPerPage int + CoreDNSPrefix string + RcodezeroTXTEncrypt bool + AkamaiServiceConsumerDomain string + AkamaiClientToken string + AkamaiClientSecret string + AkamaiAccessToken string + AkamaiEdgercPath string + AkamaiEdgercSection string + InfobloxGridHost string + InfobloxWapiPort int + InfobloxWapiUsername string + InfobloxWapiPassword string `secure:"yes"` + InfobloxWapiVersion string + InfobloxSSLVerify bool + InfobloxView string + InfobloxMaxResults int + InfobloxFQDNRegEx string + InfobloxNameRegEx string + InfobloxCreatePTR bool + InfobloxCacheDuration int + DynCustomerName string + DynUsername string + DynPassword string `secure:"yes"` + DynMinTTLSeconds int + OCIConfigFile string + OCICompartmentOCID string + OCIAuthInstancePrincipal bool + InMemoryZones []string + OVHEndpoint string + OVHApiRateLimit int + PDNSServer string + PDNSAPIKey string `secure:"yes"` + PDNSTLSEnabled bool + TLSCA string + TLSClientCert string + TLSClientCertKey string + Policy string + Registry string + TXTOwnerID string + TXTPrefix string + TXTSuffix string + Interval time.Duration + MinEventSyncInterval time.Duration + Once bool + DryRun bool + UpdateEvents bool + LogFormat string + MetricsAddress string + LogLevel string + TXTCacheInterval time.Duration + TXTWildcardReplacement string + ExoscaleEndpoint string + ExoscaleAPIKey string `secure:"yes"` + ExoscaleAPISecret string `secure:"yes"` + CRDSourceAPIVersion string + CRDSourceKind string + ServiceTypeFilter []string + CFAPIEndpoint string + CFUsername string + CFPassword string + ResolveServiceLoadBalancerHostname bool + RFC2136Host string + RFC2136Port int + RFC2136Zone string + RFC2136Insecure bool + RFC2136GSSTSIG bool + RFC2136KerberosRealm string + RFC2136KerberosUsername string + RFC2136KerberosPassword string `secure:"yes"` + RFC2136TSIGKeyName string + RFC2136TSIGSecret string `secure:"yes"` + RFC2136TSIGSecretAlg string + RFC2136TAXFR bool + RFC2136MinTTL time.Duration + RFC2136BatchChangeSize int + NS1Endpoint string + NS1IgnoreSSL bool + NS1MinTTLSeconds int + TransIPAccountName string + TransIPPrivateKeyFile string + DigitalOceanAPIPageSize int + ManagedDNSRecordTypes []string + GoDaddyAPIKey string `secure:"yes"` + GoDaddySecretKey string `secure:"yes"` + GoDaddyTTL int64 + GoDaddyOTE bool + OCPRouterName string + IBMCloudProxied bool + IBMCloudConfigFile string + TencentCloudConfigFile string + TencentCloudZoneType string + PiholeServer string + PiholePassword string `secure:"yes"` + PiholeTLSInsecureSkipVerify bool + PluralCluster string + PluralProvider string } var defaultConfig = &Config{ @@ -390,6 +391,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("server", "The Kubernetes API server to connect to (default: auto-detect)").Default(defaultConfig.APIServerURL).StringVar(&cfg.APIServerURL) app.Flag("kubeconfig", "Retrieve target cluster configuration from a Kubernetes configuration file (default: auto-detect)").Default(defaultConfig.KubeConfig).StringVar(&cfg.KubeConfig) app.Flag("request-timeout", "Request timeout when calling Kubernetes APIs. 0s means no timeout").Default(defaultConfig.RequestTimeout.String()).DurationVar(&cfg.RequestTimeout) + app.Flag("resolve-service-load-balancer-hostname", "Resolve the hostname of LoadBalancer-type Service object to IP addresses in order to create DNS A/AAAA records instead of CNAMEs").BoolVar(&cfg.ResolveServiceLoadBalancerHostname) // Flags related to cloud foundry app.Flag("cf-api-endpoint", "The fully-qualified domain name of the cloud foundry instance you are targeting").Default(defaultConfig.CFAPIEndpoint).StringVar(&cfg.CFAPIEndpoint) diff --git a/source/service.go b/source/service.go index 4355cab37d..925c327f24 100644 --- a/source/service.go +++ b/source/service.go @@ -19,6 +19,7 @@ package source import ( "context" "fmt" + "net" "sort" "strings" "text/template" @@ -57,6 +58,7 @@ type serviceSource struct { publishInternal bool publishHostIP bool alwaysPublishNotReadyAddresses bool + resolveLoadBalancerHostname bool serviceInformer coreinformers.ServiceInformer endpointsInformer coreinformers.EndpointsInformer podInformer coreinformers.PodInformer @@ -66,7 +68,7 @@ type serviceSource struct { } // NewServiceSource creates a new serviceSource with the given config. -func NewServiceSource(ctx context.Context, kubeClient kubernetes.Interface, namespace, annotationFilter string, fqdnTemplate string, combineFqdnAnnotation bool, compatibility string, publishInternal bool, publishHostIP bool, alwaysPublishNotReadyAddresses bool, serviceTypeFilter []string, ignoreHostnameAnnotation bool, labelSelector labels.Selector) (Source, error) { +func NewServiceSource(ctx context.Context, kubeClient kubernetes.Interface, namespace, annotationFilter string, fqdnTemplate string, combineFqdnAnnotation bool, compatibility string, publishInternal bool, publishHostIP bool, alwaysPublishNotReadyAddresses bool, serviceTypeFilter []string, ignoreHostnameAnnotation bool, labelSelector labels.Selector, resolveLoadBalancerHostname bool) (Source, error) { tmpl, err := parseTemplate(fqdnTemplate) if err != nil { return nil, err @@ -137,6 +139,7 @@ func NewServiceSource(ctx context.Context, kubeClient kubernetes.Interface, name nodeInformer: nodeInformer, serviceTypeFilter: serviceTypes, labelSelector: labelSelector, + resolveLoadBalancerHostname: resolveLoadBalancerHostname, }, nil } @@ -480,7 +483,7 @@ func (sc *serviceSource) generateEndpoints(svc *v1.Service, hostname string, pro if useClusterIP { targets = append(targets, extractServiceIps(svc)...) } else { - targets = append(targets, extractLoadBalancerTargets(svc)...) + targets = append(targets, extractLoadBalancerTargets(svc, sc.resolveLoadBalancerHostname)...) } case v1.ServiceTypeClusterIP: if sc.publishInternal { @@ -540,7 +543,7 @@ func extractServiceExternalName(svc *v1.Service) endpoint.Targets { return endpoint.Targets{svc.Spec.ExternalName} } -func extractLoadBalancerTargets(svc *v1.Service) endpoint.Targets { +func extractLoadBalancerTargets(svc *v1.Service, resolveLoadBalancerHostname bool) endpoint.Targets { var ( targets endpoint.Targets externalIPs endpoint.Targets @@ -552,7 +555,18 @@ func extractLoadBalancerTargets(svc *v1.Service) endpoint.Targets { targets = append(targets, lb.IP) } if lb.Hostname != "" { - targets = append(targets, lb.Hostname) + if resolveLoadBalancerHostname { + ips, err := net.LookupIP(lb.Hostname) + if err != nil { + log.Errorf("Unable to resolve %q: %v", lb.Hostname, err) + continue + } + for _, ip := range ips { + targets = append(targets, ip.String()) + } + } else { + targets = append(targets, lb.Hostname) + } } } diff --git a/source/service_test.go b/source/service_test.go index e04d380242..98b400fca3 100644 --- a/source/service_test.go +++ b/source/service_test.go @@ -78,6 +78,7 @@ func (suite *ServiceSuite) SetupTest() { []string{}, false, labels.Everything(), + false, ) suite.NoError(err, "should initialize service source") } @@ -158,6 +159,7 @@ func testServiceSourceNewServiceSource(t *testing.T) { ti.serviceTypesFilter, false, labels.Everything(), + false, ) if ti.expectError { @@ -174,25 +176,26 @@ func testServiceSourceEndpoints(t *testing.T) { t.Parallel() for _, tc := range []struct { - title string - targetNamespace string - annotationFilter string - svcNamespace string - svcName string - svcType v1.ServiceType - compatibility string - fqdnTemplate string - combineFQDNAndAnnotation bool - ignoreHostnameAnnotation bool - labels map[string]string - annotations map[string]string - clusterIP string - externalIPs []string - lbs []string - serviceTypesFilter []string - expected []*endpoint.Endpoint - expectError bool - serviceLabelSelector string + title string + targetNamespace string + annotationFilter string + svcNamespace string + svcName string + svcType v1.ServiceType + compatibility string + fqdnTemplate string + combineFQDNAndAnnotation bool + ignoreHostnameAnnotation bool + labels map[string]string + annotations map[string]string + clusterIP string + externalIPs []string + lbs []string + serviceTypesFilter []string + expected []*endpoint.Endpoint + expectError bool + serviceLabelSelector string + resolveLoadBalancerHostname bool }{ { title: "no annotated services return no endpoints", @@ -389,6 +392,24 @@ func testServiceSourceEndpoints(t *testing.T) { {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.example.com"}}, }, }, + { + title: "annotated services return an endpoint with hostname then resolve hostname", + svcNamespace: "testing", + svcName: "foo", + svcType: v1.ServiceTypeLoadBalancer, + labels: map[string]string{}, + annotations: map[string]string{ + hostnameAnnotationKey: "foo.example.org.", + }, + externalIPs: []string{}, + lbs: []string{"example.com"}, // Use a resolvable hostname for testing. + serviceTypesFilter: []string{}, + resolveLoadBalancerHostname: true, + expected: []*endpoint.Endpoint{ + {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"93.184.216.34"}}, + {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2606:2800:220:1:248:1893:25c8:1946"}}, + }, + }, { title: "annotated services can omit trailing dot", svcNamespace: "testing", @@ -1086,6 +1107,7 @@ func testServiceSourceEndpoints(t *testing.T) { tc.serviceTypesFilter, tc.ignoreHostnameAnnotation, sourceLabel, + tc.resolveLoadBalancerHostname, ) require.NoError(t, err) @@ -1275,6 +1297,7 @@ func testMultipleServicesEndpoints(t *testing.T) { tc.serviceTypesFilter, tc.ignoreHostnameAnnotation, labels.Everything(), + false, ) require.NoError(t, err) @@ -1440,6 +1463,7 @@ func TestClusterIpServices(t *testing.T) { []string{}, tc.ignoreHostnameAnnotation, labelSelector, + false, ) require.NoError(t, err) @@ -2010,6 +2034,7 @@ func TestServiceSourceNodePortServices(t *testing.T) { []string{}, tc.ignoreHostnameAnnotation, labels.Everything(), + false, ) require.NoError(t, err) @@ -2483,6 +2508,7 @@ func TestHeadlessServices(t *testing.T) { []string{}, tc.ignoreHostnameAnnotation, labels.Everything(), + false, ) require.NoError(t, err) @@ -2840,6 +2866,7 @@ func TestHeadlessServicesHostIP(t *testing.T) { []string{}, tc.ignoreHostnameAnnotation, labels.Everything(), + false, ) require.NoError(t, err) @@ -2952,6 +2979,7 @@ func TestExternalServices(t *testing.T) { []string{}, tc.ignoreHostnameAnnotation, labels.Everything(), + false, ) require.NoError(t, err) @@ -3006,6 +3034,7 @@ func BenchmarkServiceEndpoints(b *testing.B) { []string{}, false, labels.Everything(), + false, ) require.NoError(b, err) diff --git a/source/store.go b/source/store.go index 2df28cf41f..d22e16ff87 100644 --- a/source/store.go +++ b/source/store.go @@ -73,6 +73,7 @@ type Config struct { DefaultTargets []string OCPRouterName string UpdateEvents bool + ResolveLoadBalancerHostname bool } // ClientGenerator provides clients @@ -215,7 +216,7 @@ func BuildWithConfig(ctx context.Context, source string, p ClientGenerator, cfg 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) + 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) case "ingress": client, err := p.KubeClient() if err != nil {