Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/annotations/annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `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:
Expand Down
30 changes: 30 additions & 0 deletions docs/sources/gateway-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
3 changes: 3 additions & 0 deletions source/annotations/annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"
}
30 changes: 22 additions & 8 deletions source/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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[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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're not checking for gatewayHostnameSourceDefinedHostsOnlyValue, so any invalid value will return only the defined hostnames.
In this case, the ingress implementation returns an empty list.
It might be worth logging a warning too.

You also build hostnames from both sources and eventually discard them.
Wouldn’t it be better to only add hostnames when they’re actually needed?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated logic

}

Expand Down
80 changes: 80 additions & 0 deletions source/gateway_httproute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1540,6 +1540,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{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"),
},
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a test case with an invalid annotation value?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a test case with an invalid annotation value

}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
Expand Down
Loading