diff --git a/docs/annotations/annotations.md b/docs/annotations/annotations.md index c5c172f232..dc9fd568e4 100644 --- a/docs/annotations/annotations.md +++ b/docs/annotations/annotations.md @@ -99,6 +99,16 @@ Specifies the TTL (time to live) for the resource's DNS records. The value may be specified as either a duration or an integer number of seconds. It must be between 1 and 2,147,483,647 seconds. +## external-dns.alpha.kubernetes.io/gateway-hostname-source + +Specifies where to get the domain for `Route` resource. This annotation should be present on the actual `Route` resource, not the `Gateway` resource itself. + +If the value is `defined-hosts-only`, use only the domains from the `Route` spec. + +If the value is `annotation-only`, use only the domains from the `Route` annotations. + +If the annotation is not present, use the domains from both the spec and annotations. + ## Provider-specific annotations Some providers define their own annotations. Cloud-specific annotations have keys prefixed as follows: diff --git a/docs/sources/gateway-api.md b/docs/sources/gateway-api.md index 6e03e960e3..28164659fe 100644 --- a/docs/sources/gateway-api.md +++ b/docs/sources/gateway-api.md @@ -112,3 +112,35 @@ spec: - --registry=txt - --txt-owner-id=my-identifier ``` + +## Annotations + +### external-dns.alpha.kubernetes.io/gateway-hostname-source + +**Why is this needed:** +In certain scenarios, conflicting DNS records can arise when External DNS processes both the hostname annotations and the hostnames defined in the `*Route` spec. For example: + +- A CNAME record (`company.public.example.com -> company.private.example.com`) is used to direct traffic to private endpoints (e.g., AWS PrivateLink). +- Some third-party services require traffic to resolve publicly to the Gateway API load balancer, but the hostname (`company.public.example.com`) must remain unchanged to avoid breaking the CNAME setup. +- Without this annotation, External DNS may override the CNAME record with an A record due to conflicting hostname definitions. + +**Usage:** +By setting the annotation `external-dns.alpha.kubernetes.io/gateway-hostname-source: annotation-only`, users can instruct External DNS +to ignore hostnames defined in the `HTTPRoute` spec and use only the hostnames specified in annotations. This ensures +compatibility with complex DNS configurations and avoids record conflicts. + +**Example:** + +```yaml +apiVersion: gateway.networking.k8s.io/v1beta1 +kind: HTTPRoute +metadata: + annotations: + external-dns.alpha.kubernetes.io/gateway-hostname-source: annotation-only + external-dns.alpha.kubernetes.io/hostname: company.private.example.com +spec: + hostnames: + - company.public.example.com +``` + +In this example, External DNS will create DNS records only for `company.private.example.com` based on the annotation, ignoring the `hostnames` field in the `HTTPRoute` spec. This prevents conflicts with existing CNAME records while enabling public resolution for specific endpoints. diff --git a/source/annotations/annotations.go b/source/annotations/annotations.go index bd21ca2bca..b77eb53206 100644 --- a/source/annotations/annotations.go +++ b/source/annotations/annotations.go @@ -51,4 +51,6 @@ const ( ControllerValue = "dns-controller" // The annotation used for defining the desired hostname InternalHostnameKey = "external-dns.alpha.kubernetes.io/internal-hostname" + // The annotation used for defining the desired hostname source for gateways + GatewayHostnameSourceKey = "external-dns.alpha.kubernetes.io/gateway-hostname-source" ) diff --git a/source/gateway.go b/source/gateway.go index 6b72c56f4a..aac6ba3cbb 100644 --- a/source/gateway.go +++ b/source/gateway.go @@ -46,8 +46,10 @@ import ( ) const ( - gatewayGroup = "gateway.networking.k8s.io" - gatewayKind = "Gateway" + gatewayGroup = "gateway.networking.k8s.io" + gatewayKind = "Gateway" + gatewayHostnameSourceAnnotationOnlyValue = "annotation-only" + gatewayHostnameSourceDefinedHostsOnlyValue = "defined-hosts-only" ) type gatewayRoute interface { @@ -401,13 +403,14 @@ func (c *gatewayRouteResolver) resolve(rt gatewayRoute) (map[string]endpoint.Tar func (c *gatewayRouteResolver) hosts(rt gatewayRoute) ([]string, error) { var hostnames []string + var annotationHostnames []string for _, name := range rt.Hostnames() { hostnames = append(hostnames, string(name)) } // TODO: The ignore-hostname-annotation flag help says "valid only when using fqdn-template" // but other sources don't check if fqdn-template is set. Which should it be? if !c.src.ignoreHostnameAnnotation { - hostnames = append(hostnames, annotations.HostnamesFromAnnotations(rt.Metadata().Annotations)...) + annotationHostnames = append(annotationHostnames, annotations.HostnamesFromAnnotations(rt.Metadata().Annotations)...) } // TODO: The combine-fqdn-annotation flag is similarly vague. if c.src.fqdnTemplate != nil && (len(hostnames) == 0 || c.src.combineFQDNAnnotation) { @@ -417,12 +420,23 @@ func (c *gatewayRouteResolver) hosts(rt gatewayRoute) ([]string, error) { } hostnames = append(hostnames, hosts...) } - // This means that the route doesn't specify a hostname and should use any provided by - // attached Gateway Listeners. This is only useful for {HTTP,TLS}Routes, but it doesn't - // break {TCP,UDP}Routes. - if len(rt.Hostnames()) == 0 { - hostnames = append(hostnames, "") + + hostNameAnnotation, hostNameAnnotationExists := rt.Metadata().Annotations[gatewayHostnameSourceKey] + if !hostNameAnnotationExists { + // This means that the route doesn't specify a hostname and should use any provided by + // attached Gateway Listeners. This is only useful for {HTTP,TLS}Routes, but it doesn't + // break {TCP,UDP}Routes. + if len(rt.Hostnames()) == 0 { + hostnames = append(hostnames, "") + } + + hostnames = append(hostnames, annotationHostnames...) } + + if strings.ToLower(hostNameAnnotation) == gatewayHostnameSourceAnnotationOnlyValue { + return annotationHostnames, nil + } + return hostnames, nil } diff --git a/source/gateway_httproute_test.go b/source/gateway_httproute_test.go index 987bc011d6..f112037d4b 100644 --- a/source/gateway_httproute_test.go +++ b/source/gateway_httproute_test.go @@ -1580,6 +1580,86 @@ func TestGatewayHTTPRouteSourceEndpoints(t *testing.T) { "Parent reference gateway-namespace/other-gateway not found in routeParentRefs for HTTPRoute route-namespace/test", }, }, + { + title: "SourceAnnotation", + config: Config{ + GatewayNamespace: "gateway-namespace", + }, + namespaces: namespaces("gateway-namespace", "route-namespace"), + gateways: []*v1beta1.Gateway{ + { + ObjectMeta: objectMeta("gateway-namespace", "test"), + Spec: v1.GatewaySpec{ + Listeners: []v1.Listener{{ + Protocol: v1.HTTPProtocolType, + AllowedRoutes: allowAllNamespaces, + }}, + }, + Status: gatewayStatus("1.2.3.4"), + }, + }, + routes: []*v1beta1.HTTPRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "route-test", + Namespace: "test", + Annotations: map[string]string{gatewayHostnameSourceKey: "defined-hosts-only", hostnameAnnotationKey: "test.org.internal"}, + }, + Spec: v1.HTTPRouteSpec{ + Hostnames: hostnames("test.example.internal"), + CommonRouteSpec: v1.CommonRouteSpec{ + ParentRefs: []v1.ParentReference{ + gwParentRef("gateway-namespace", "test"), + }, + }, + }, + Status: httpRouteStatus(gwParentRef("gateway-namespace", "test")), + }, + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("test.example.internal", "A", "1.2.3.4"), + }, + }, + { + title: "OnlyAnnotationHost", + config: Config{ + GatewayNamespace: "gateway-namespace", + }, + namespaces: namespaces("gateway-namespace", "route-namespace"), + gateways: []*v1beta1.Gateway{ + { + ObjectMeta: objectMeta("gateway-namespace", "test"), + Spec: v1.GatewaySpec{ + Listeners: []v1.Listener{{ + Protocol: v1.HTTPProtocolType, + AllowedRoutes: allowAllNamespaces, + }}, + }, + Status: gatewayStatus("1.2.3.4"), + }, + }, + routes: []*v1beta1.HTTPRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "route-test", + Namespace: "test", + Annotations: map[string]string{gatewayHostnameSourceKey: "annotation-only", hostnameAnnotationKey: "test.org.internal"}, + }, + Spec: v1.HTTPRouteSpec{ + Hostnames: hostnames("test.example.internal"), + CommonRouteSpec: v1.CommonRouteSpec{ + ParentRefs: []v1.ParentReference{ + gwParentRef("gateway-namespace", "test"), + }, + }, + }, + Status: httpRouteStatus(gwParentRef("gateway-namespace", "test")), + }, + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("test.org.internal", "A", "1.2.3.4"), + }, + }, } for _, tt := range tests { diff --git a/source/source.go b/source/source.go index aaa2d1dc11..d55b7193ab 100644 --- a/source/source.go +++ b/source/source.go @@ -38,6 +38,7 @@ const ( ingressHostnameSourceKey = annotations.IngressHostnameSourceKey controllerAnnotationValue = annotations.ControllerValue internalHostnameAnnotationKey = annotations.InternalHostnameKey + gatewayHostnameSourceKey = annotations.GatewayHostnameSourceKey EndpointsTypeNodeExternalIP = "NodeExternalIP" EndpointsTypeHostIP = "HostIP"