Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 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:
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"
}
52 changes: 39 additions & 13 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 @@ -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())
Expand All @@ -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 {
Expand Down
118 changes: 118 additions & 0 deletions source/gateway_httproute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
},
},
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

{
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) {
Expand Down
Loading