diff --git a/docs/annotations/annotations.md b/docs/annotations/annotations.md index 9784a3ab6..aefb1d95f 100644 --- a/docs/annotations/annotations.md +++ b/docs/annotations/annotations.md @@ -185,6 +185,16 @@ It must be between `1` and `2,147,483,647` seconds. > Note; setting the value to `0` means, that TTL is not configured and thus use default. +## external-dns.alpha.kubernetes.io/gateway-hostname-source + +Specifies where to get the domain for a `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 1ed779c2d..e9708c28e 100644 --- a/docs/sources/gateway-api.md +++ b/docs/sources/gateway-api.md @@ -138,6 +138,36 @@ metadata: external-dns.alpha.kubernetes.io/target: "203.0.113.1" ``` +### 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. + For a complete list of supported annotations, see the [annotations documentation](../annotations/annotations.md#gateway-api-annotation-placement). diff --git a/source/annotations/annotations.go b/source/annotations/annotations.go index 73f916f23..ebe4ba548 100644 --- a/source/annotations/annotations.go +++ b/source/annotations/annotations.go @@ -64,6 +64,8 @@ var ( ControllerValue = "dns-controller" // InternalHostnameKey The annotation used for defining the desired hostname InternalHostnameKey string + // The annotation used for defining the desired hostname source for gateways + GatewayHostnameSourceKey string ) // SetAnnotationPrefix sets a custom annotation prefix and rebuilds all annotation keys. @@ -98,4 +100,5 @@ func SetAnnotationPrefix(prefix string) { Ingress = AnnotationKeyPrefix + "ingress" IngressHostnameSourceKey = AnnotationKeyPrefix + "ingress-hostname-source" InternalHostnameKey = AnnotationKeyPrefix + "internal-hostname" + GatewayHostnameSourceKey = AnnotationKeyPrefix + "gateway-hostname-source" } diff --git a/source/gateway.go b/source/gateway.go index 95a551f5e..74dd4d848 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 { @@ -404,11 +406,6 @@ func (c *gatewayRouteResolver) hosts(rt gatewayRoute) ([]string, error) { 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)...) - } // TODO: The combine-fqdn-annotation flag is similarly vague. if c.src.fqdnTemplate != nil && (len(hostnames) == 0 || c.src.combineFQDNAnnotation) { hosts, err := fqdn.ExecTemplate(c.src.fqdnTemplate, rt.Object()) @@ -417,13 +414,42 @@ 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[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, "") + } + if !c.src.ignoreHostnameAnnotation { + hostnames = append(hostnames, annotations.HostnamesFromAnnotations(rt.Metadata().Annotations)...) + } + return hostnames, nil + } + + switch strings.ToLower(hostNameAnnotation) { + case gatewayHostnameSourceAnnotationOnlyValue: + if c.src.ignoreHostnameAnnotation { + return []string{}, nil + } + return annotations.HostnamesFromAnnotations(rt.Metadata().Annotations), nil + case gatewayHostnameSourceDefinedHostsOnlyValue: + // Explicitly use only defined hostnames (route spec and optional template result) + return hostnames, nil + default: + // Invalid value provided: warn and fall back to default behavior (as if the annotation is absent) + log.Warnf("Invalid value for %q on %s/%s: %q. Falling back to default behavior.", + annotations.GatewayHostnameSourceKey, rt.Metadata().Namespace, rt.Metadata().Name, hostNameAnnotation) + if len(rt.Hostnames()) == 0 { + hostnames = append(hostnames, "") + } + if !c.src.ignoreHostnameAnnotation { + hostnames = append(hostnames, annotations.HostnamesFromAnnotations(rt.Metadata().Annotations)...) + } + return hostnames, nil } - return hostnames, nil } func (c *gatewayRouteResolver) routeIsAllowed(gw *v1beta1.Gateway, lis *v1.Listener, rt gatewayRoute) bool { diff --git a/source/gateway_httproute_test.go b/source/gateway_httproute_test.go index 031ae93cf..686dfd6a5 100644 --- a/source/gateway_httproute_test.go +++ b/source/gateway_httproute_test.go @@ -1540,6 +1540,124 @@ 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{annotations.GatewayHostnameSourceKey: "defined-hosts-only", annotations.HostnameKey: "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{annotations.GatewayHostnameSourceKey: "annotation-only", annotations.HostnameKey: "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"), + }, + }, + { + title: "InvalidSourceAnnotation", + config: Config{}, + namespaces: namespaces("default"), + gateways: []*v1beta1.Gateway{{ + ObjectMeta: objectMeta("default", "test"), + Spec: v1.GatewaySpec{ + Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, + }, + Status: gatewayStatus("1.2.3.4"), + }}, + routes: []*v1beta1.HTTPRoute{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-annotation", + Namespace: "default", + Annotations: map[string]string{ + annotations.GatewayHostnameSourceKey: "invalid-value", + annotations.HostnameKey: "annotation.invalid.internal", + }, + }, + Spec: v1.HTTPRouteSpec{ + Hostnames: hostnames("route.invalid.internal"), + CommonRouteSpec: v1.CommonRouteSpec{ + ParentRefs: []v1.ParentReference{ + gwParentRef("default", "test"), + }, + }, + }, + Status: httpRouteStatus(gwParentRef("default", "test")), + }}, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("route.invalid.internal", "A", "1.2.3.4"), + newTestEndpoint("annotation.invalid.internal", "A", "1.2.3.4"), + }, + logExpectations: []string{ + "Invalid value for \"external-dns.alpha.kubernetes.io/gateway-hostname-source\" on default/invalid-annotation: \"invalid-value\". Falling back to default behavior.", + }, + }, } for _, tt := range tests { t.Run(tt.title, func(t *testing.T) {