diff --git a/docs/flags.md b/docs/flags.md index b3da164121..e618c0da0e 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -41,7 +41,7 @@ tags: | `--[no-]ignore-ingress-tls-spec` | Ignore the spec.tls section in Ingress resources (default: false) | | `--[no-]ignore-non-host-network-pods` | Ignore pods not running on host network when using pod source (default: false) | | `--ingress-class=INGRESS-CLASS` | Require an Ingress to have this class name; specify multiple times to allow more than one class (optional; defaults to any class) | -| `--label-filter=""` | Filter resources queried for endpoints by label selector; currently supported by source types crd, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, ingress, node, openshift-route, service and ambassador-host | +| `--label-filter=""` | Filter resources queried for endpoints by label selector; currently supported by source types crd, gateway, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, ingress, node, openshift-route, service and ambassador-host | | `--managed-record-types=A...` | Record types to manage; specify multiple times to include many; (default: A,AAAA,CNAME) (supported records: A, AAAA, CNAME, NS, SRV, TXT) | | `--namespace=""` | Limit resources queried for endpoints to a specific namespace (default: all namespaces) | | `--nat64-networks=NAT64-NETWORKS` | Adding an A record for each AAAA record in NAT64-enabled networks; specify multiple times for multiple possible nets (optional) | @@ -196,4 +196,4 @@ tags: | `--kube-api-qps=5` | Maximum QPS to the Kubernetes API server from this client. | | `--kube-api-burst=10` | Maximum burst for throttle to the Kubernetes API server from this client. | | `--provider=provider` | The DNS provider where the DNS records will be created (required, options: akamai, alibabacloud, aws, aws-sd, azure, azure-dns, azure-private-dns, civo, cloudflare, coredns, dnsimple, exoscale, gandi, godaddy, google, inmemory, linode, ns1, oci, ovh, pdns, pihole, plural, rfc2136, scaleway, skydns, transip, webhook) | -| `--source=source` | The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, node, pod, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, istio-gateway, istio-virtualservice, contour-httpproxy, gloo-proxy, fake, connector, crd, empty, skipper-routegroup, openshift-route, ambassador-host, kong-tcpingress, f5-virtualserver, f5-transportserver, traefik-proxy, unstructured) | +| `--source=source` | The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, node, pod, gateway, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, istio-gateway, istio-virtualservice, contour-httpproxy, gloo-proxy, fake, connector, crd, empty, skipper-routegroup, openshift-route, ambassador-host, kong-tcpingress, f5-virtualserver, f5-transportserver, traefik-proxy, unstructured) | diff --git a/docs/sources/gateway-api.md b/docs/sources/gateway-api.md index fe18107b32..af4624123b 100644 --- a/docs/sources/gateway-api.md +++ b/docs/sources/gateway-api.md @@ -1,8 +1,66 @@ -# Gateway API Route Sources +# Gateway API Sources -This describes how to configure ExternalDNS to use Gateway API Route sources. +This describes how to configure ExternalDNS to use Gateway API sources. It is meant to supplement the other provider-specific setup tutorials. +## Gateway Source (`--source=gateway`) + +The `gateway` source watches `Gateway` resources directly and creates DNS records +for each Gateway that has the `external-dns.alpha.kubernetes.io/hostname` annotation. +Targets are taken from `gateway.status.addresses` (or overridden via the +`external-dns.alpha.kubernetes.io/target` annotation). + +### When to use + +Use `--source=gateway` when you want DNS records tied to the Gateway itself rather +than individual routes. This is useful when: + +- All routes share the same DNS name as the Gateway's external address. +- You manage hostnames at the Gateway level rather than per-route. + +### How it works + +1. ExternalDNS lists all `Gateway` objects in the configured namespace. +2. For each Gateway with `external-dns.alpha.kubernetes.io/hostname`, it reads the value as one or more comma-separated hostnames. +3. Targets are sourced from `gateway.status.addresses`. IP addresses produce A/AAAA records; hostnames produce CNAME records. +4. The `external-dns.alpha.kubernetes.io/target` annotation overrides `status.addresses`. + +### Example + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: my-gateway + namespace: default + annotations: + external-dns.alpha.kubernetes.io/hostname: app.example.com + external-dns.alpha.kubernetes.io/ttl: "300" +spec: + gatewayClassName: cilium + listeners: + - name: https + protocol: HTTPS + port: 443 +status: + addresses: + - type: IPAddress + value: 203.0.113.1 +``` + +This produces an A record: `app.example.com → 203.0.113.1`. + +### Supported flags + +| Flag | Effect on `gateway` source | +|------|---------------------------| +| `--gateway-name` | Limit to a single Gateway by name | +| `--gateway-namespace` | Limit to Gateways in a specific namespace | +| `--gateway-label-filter` | Filter Gateways by label selector | +| `--annotation-filter` | Filter Gateways by annotation selector | +| `--ignore-hostname-annotation` | Skip the hostname annotation (useful with `--fqdn-template`) | +| `--fqdn-template` | Generate hostnames from a Go template applied to the Gateway object | + ## Supported API Versions ExternalDNS uses Gateway API CRDs, which are distributed at different versions in Standard and/or diff --git a/docs/sources/index.md b/docs/sources/index.md index 8534109d48..11b184ccf8 100644 --- a/docs/sources/index.md +++ b/docs/sources/index.md @@ -32,6 +32,7 @@ Sources are responsible for: | **f5-transportserver** | annotation | all,single | false | false | false | load balancers | TransportServer.cis.f5.com | | **f5-virtualserver** | annotation | all,single | false | false | false | load balancers | VirtualServer.cis.f5.com | | **fake** | | | true | true | false | testing | Fake Endpoints | +| **gateway** | annotation,label | all,single | true | false | true | gateway api | Gateway.gateway.networking.k8s.io | | **gateway-grpcroute** | annotation,label | all,single | true | false | true | gateway api | GRPCRoute.gateway.networking.k8s.io | | **gateway-httproute** | annotation,label | all,single | true | false | true | gateway api | HTTPRoute.gateway.networking.k8s.io | | **gateway-tcproute** | annotation,label | all,single | true | false | true | gateway api | TCPRoute.gateway.networking.k8s.io | diff --git a/source/gateway.go b/source/gateway.go index 7015e0494b..fa66949279 100644 --- a/source/gateway.go +++ b/source/gateway.go @@ -151,6 +151,153 @@ type gatewayRouteSource struct { ignoreHostnameAnnotation bool } +func NewGatewaySource(ctx context.Context, clients ClientGenerator, config *Config) (Source, error) { + return newGatewaySource(ctx, clients, config, newGatewayInformerFactory) +} + +// +externaldns:source:name=gateway +// +externaldns:source:category=Gateway API +// +externaldns:source:description=Creates DNS entries from Gateway API Gateway resources annotated with external-dns.alpha.kubernetes.io/hostname +// +externaldns:source:resources=Gateway.gateway.networking.k8s.io +// +externaldns:source:filters=annotation,label +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=true +// +externaldns:source:provider-specific=true +type gatewayResourceSource struct { + gwName string + gwNamespace string + gwLabels labels.Selector + gwAnnotations labels.Selector + gwInformer informers_v1.GatewayInformer + + templateEngine template.Engine + ignoreHostnameAnnotation bool +} + +func newGatewaySource( + ctx context.Context, + clients ClientGenerator, + config *Config, + newInformerFactory func(gateway.Interface, string, labels.Selector) gwinformers.SharedInformerFactory, +) (Source, error) { + gwLabels, err := getLabelSelector(config.GatewayLabelFilter) + if err != nil { + return nil, err + } + gwAnnotations, err := getLabelSelector(config.AnnotationFilter) + if err != nil { + return nil, err + } + + client, err := clients.GatewayClient() + if err != nil { + return nil, err + } + + gwInformerFactory := newInformerFactory(client, config.GatewayNamespace, gwLabels) + gwInformer := gwInformerFactory.Gateway().V1().Gateways() + gwInformer.Informer() // Register with factory before starting. + + gwInformerFactory.Start(ctx.Done()) + if err := informers.WaitForCacheSync(ctx, gwInformerFactory); err != nil { + return nil, err + } + + return &gatewayResourceSource{ + gwName: config.GatewayName, + gwNamespace: config.GatewayNamespace, + gwLabels: gwLabels, + gwAnnotations: gwAnnotations, + gwInformer: gwInformer, + templateEngine: config.TemplateEngine, + ignoreHostnameAnnotation: config.IgnoreHostnameAnnotation, + }, nil +} + +func (src *gatewayResourceSource) AddEventHandler(_ context.Context, handler func()) { + log.Debug("Adding event handlers for Gateway") + informers.MustAddEventHandler(src.gwInformer.Informer(), eventHandlerFunc(handler)) +} + +func (src *gatewayResourceSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { + gateways, err := src.gwInformer.Lister().Gateways(src.gwNamespace).List(src.gwLabels) + if err != nil { + return nil, err + } + + var endpoints []*endpoint.Endpoint + for _, gw := range gateways { + if src.gwName != "" && src.gwName != gw.Name { + continue + } + + meta := &gw.ObjectMeta + annots := meta.Annotations + + if !src.gwAnnotations.Matches(labels.Set(annots)) { + continue + } + + if annotations.IsControllerMismatch(meta, gatewayKind) { + continue + } + + hostnames, err := src.hostnames(gw) + if err != nil { + return nil, err + } + if len(hostnames) == 0 { + log.Debugf("No endpoints could be generated from Gateway %s/%s", gw.Namespace, gw.Name) + continue + } + + targets := src.targets(gw) + if len(targets) == 0 { + log.Debugf("No targets found for Gateway %s/%s", gw.Namespace, gw.Name) + continue + } + + resource := fmt.Sprintf("gateway/%s/%s", gw.Namespace, gw.Name) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(annots) + ttl := annotations.TTLFromAnnotations(annots, resource) + + var gwEndpoints []*endpoint.Endpoint + for _, host := range hostnames { + gwEndpoints = append(gwEndpoints, endpoint.EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) + } + log.Debugf("Endpoints generated from Gateway %s/%s: %v", gw.Namespace, gw.Name, gwEndpoints) + endpoints = append(endpoints, gwEndpoints...) + } + return MergeEndpoints(endpoints), nil +} + +func (src *gatewayResourceSource) hostnames(gw *v1.Gateway) ([]string, error) { + var hostnames []string + if !src.ignoreHostnameAnnotation { + hostnames = append(hostnames, annotations.HostnamesFromAnnotations(gw.Annotations)...) + } + if src.templateEngine.IsConfigured() && (len(hostnames) == 0 || src.templateEngine.Combining()) { + hosts, err := src.templateEngine.ExecFQDN(gw) + if err != nil { + return nil, err + } + hostnames = append(hostnames, hosts...) + } + return hostnames, nil +} + +func (src *gatewayResourceSource) targets(gw *v1.Gateway) endpoint.Targets { + override := annotations.TargetsFromTargetAnnotation(gw.Annotations) + if len(override) > 0 { + return override + } + var targets endpoint.Targets + for _, addr := range gw.Status.Addresses { + targets = append(targets, addr.Value) + } + return targets +} + func newGatewayRouteSource( ctx context.Context, clients ClientGenerator, diff --git a/source/gateway_resource_test.go b/source/gateway_resource_test.go new file mode 100644 index 0000000000..e3cd46dc04 --- /dev/null +++ b/source/gateway_resource_test.go @@ -0,0 +1,268 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayfake "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned/fake" + + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/internal/testutils" + "sigs.k8s.io/external-dns/source/annotations" +) + +func gatewayStatusHostname(hostnames ...string) v1.GatewayStatus { + typ := v1.HostnameAddressType + addrs := make([]v1.GatewayStatusAddress, len(hostnames)) + for i, h := range hostnames { + addrs[i] = v1.GatewayStatusAddress{Type: &typ, Value: h} + } + return v1.GatewayStatus{Addresses: addrs} +} + +func makeGateway(namespace, name string, annots map[string]string, status v1.GatewayStatus, labels map[string]string) *v1.Gateway { + return &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: annots, + Labels: labels, + }, + Status: status, + } +} + +func TestGatewayResourceSourceEndpoints(t *testing.T) { + t.Parallel() + + hostnameAnnotation := func(hostnames ...string) map[string]string { + return map[string]string{annotations.HostnameKey: strings.Join(hostnames, ",")} + } + + tests := []struct { + title string + config *Config + gateways []*v1.Gateway + endpoints []*endpoint.Endpoint + }{ + { + title: "IP address in status produces A record", + config: &Config{}, + gateways: []*v1.Gateway{ + makeGateway("default", "gw", hostnameAnnotation("foo.example.com"), gatewayStatus("1.2.3.4"), nil), + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("foo.example.com", "1.2.3.4"), + }, + }, + { + title: "hostname address in status produces CNAME record", + config: &Config{}, + gateways: []*v1.Gateway{ + makeGateway("default", "gw", hostnameAnnotation("foo.example.com"), gatewayStatusHostname("lb.example.com"), nil), + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpointWithTTL("foo.example.com", endpoint.RecordTypeCNAME, 0, "lb.example.com"), + }, + }, + { + title: "IPv6 address in status produces AAAA record", + config: &Config{}, + gateways: []*v1.Gateway{ + makeGateway("default", "gw", hostnameAnnotation("foo.example.com"), gatewayStatus("2001:db8::1"), nil), + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpointWithTTL("foo.example.com", endpoint.RecordTypeAAAA, 0, "2001:db8::1"), + }, + }, + { + title: "multiple hostnames in annotation produce multiple endpoints", + config: &Config{}, + gateways: []*v1.Gateway{ + makeGateway("default", "gw", hostnameAnnotation("a.example.com", "b.example.com"), gatewayStatus("1.2.3.4"), nil), + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("a.example.com", "1.2.3.4"), + newTestEndpoint("b.example.com", "1.2.3.4"), + }, + }, + { + title: "multiple gateways produce multiple endpoints", + config: &Config{}, + gateways: []*v1.Gateway{ + makeGateway("default", "gw1", hostnameAnnotation("a.example.com"), gatewayStatus("1.2.3.4"), nil), + makeGateway("default", "gw2", hostnameAnnotation("b.example.com"), gatewayStatus("5.6.7.8"), nil), + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("a.example.com", "1.2.3.4"), + newTestEndpoint("b.example.com", "5.6.7.8"), + }, + }, + { + title: "GatewayName filter skips non-matching gateways", + config: &Config{GatewayName: "gw1"}, + gateways: []*v1.Gateway{ + makeGateway("default", "gw1", hostnameAnnotation("a.example.com"), gatewayStatus("1.2.3.4"), nil), + makeGateway("default", "gw2", hostnameAnnotation("b.example.com"), gatewayStatus("5.6.7.8"), nil), + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("a.example.com", "1.2.3.4"), + }, + }, + { + title: "GatewayNamespace filter limits to configured namespace", + config: &Config{GatewayNamespace: "ns1"}, + gateways: []*v1.Gateway{ + makeGateway("ns1", "gw1", hostnameAnnotation("a.example.com"), gatewayStatus("1.2.3.4"), nil), + makeGateway("ns2", "gw2", hostnameAnnotation("b.example.com"), gatewayStatus("5.6.7.8"), nil), + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("a.example.com", "1.2.3.4"), + }, + }, + { + title: "GatewayLabelFilter skips gateways without matching label", + config: &Config{GatewayLabelFilter: "env=prod"}, + gateways: []*v1.Gateway{ + makeGateway("default", "gw1", hostnameAnnotation("a.example.com"), gatewayStatus("1.2.3.4"), map[string]string{"env": "prod"}), + makeGateway("default", "gw2", hostnameAnnotation("b.example.com"), gatewayStatus("5.6.7.8"), map[string]string{"env": "staging"}), + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("a.example.com", "1.2.3.4"), + }, + }, + { + title: "AnnotationFilter skips gateways without matching annotation", + config: &Config{AnnotationFilter: "custom=yes"}, + gateways: []*v1.Gateway{ + makeGateway("default", "gw1", map[string]string{ + annotations.HostnameKey: "a.example.com", + "custom": "yes", + }, gatewayStatus("1.2.3.4"), nil), + makeGateway("default", "gw2", hostnameAnnotation("b.example.com"), gatewayStatus("5.6.7.8"), nil), + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("a.example.com", "1.2.3.4"), + }, + }, + { + title: "IgnoreHostnameAnnotation produces no endpoints", + config: &Config{IgnoreHostnameAnnotation: true}, + gateways: []*v1.Gateway{ + makeGateway("default", "gw", hostnameAnnotation("foo.example.com"), gatewayStatus("1.2.3.4"), nil), + }, + endpoints: nil, + }, + { + title: "no hostname annotation produces no endpoints", + config: &Config{}, + gateways: []*v1.Gateway{ + makeGateway("default", "gw", nil, gatewayStatus("1.2.3.4"), nil), + }, + endpoints: nil, + }, + { + title: "no status addresses produces no endpoints", + config: &Config{}, + gateways: []*v1.Gateway{ + makeGateway("default", "gw", hostnameAnnotation("foo.example.com"), v1.GatewayStatus{}, nil), + }, + endpoints: nil, + }, + { + title: "target override annotation replaces status addresses", + config: &Config{}, + gateways: []*v1.Gateway{ + makeGateway("default", "gw", map[string]string{ + annotations.HostnameKey: "foo.example.com", + annotations.TargetKey: "override.example.com", + }, gatewayStatus("1.2.3.4"), nil), + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpointWithTTL("foo.example.com", endpoint.RecordTypeCNAME, 0, "override.example.com"), + }, + }, + { + title: "TTL annotation is applied to endpoints", + config: &Config{}, + gateways: []*v1.Gateway{ + makeGateway("default", "gw", map[string]string{ + annotations.HostnameKey: "foo.example.com", + annotations.TtlKey: "300", + }, gatewayStatus("1.2.3.4"), nil), + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpointWithTTL("foo.example.com", endpoint.RecordTypeA, 300, "1.2.3.4"), + }, + }, + { + title: "controller annotation mismatch skips gateway", + config: &Config{}, + gateways: []*v1.Gateway{ + makeGateway("default", "gw", map[string]string{ + annotations.HostnameKey: "foo.example.com", + annotations.ControllerKey: "other-controller", + }, gatewayStatus("1.2.3.4"), nil), + }, + endpoints: nil, + }, + { + title: "same hostname from two gateways merges targets", + config: &Config{}, + gateways: []*v1.Gateway{ + makeGateway("default", "gw1", hostnameAnnotation("shared.example.com"), gatewayStatus("1.2.3.4"), nil), + makeGateway("default", "gw2", hostnameAnnotation("shared.example.com"), gatewayStatus("5.6.7.8"), nil), + }, + endpoints: []*endpoint.Endpoint{ + newTestEndpoint("shared.example.com", "1.2.3.4", "5.6.7.8"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.title, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + gwClient := gatewayfake.NewSimpleClientset() + for _, gw := range tt.gateways { + _, err := gwClient.GatewayV1().Gateways(gw.Namespace).Create(ctx, gw, metav1.CreateOptions{}) + require.NoError(t, err, "failed to create Gateway") + } + + clients := new(testutils.MockClientGenerator) + clients.On("GatewayClient").Return(gwClient, nil) + + src, err := NewGatewaySource(ctx, clients, tt.config) + require.NoError(t, err, "failed to create Gateway source") + + endpoints, err := src.Endpoints(ctx) + require.NoError(t, err, "failed to get endpoints") + + testutils.ValidateEndpoints(t, endpoints, tt.endpoints) + }) + } +} diff --git a/source/store.go b/source/store.go index 888f4839e6..737eda561a 100644 --- a/source/store.go +++ b/source/store.go @@ -442,6 +442,8 @@ func BuildWithConfig(ctx context.Context, source string, p ClientGenerator, cfg return buildIngressSource(ctx, p, cfg) case types.Pod: return buildPodSource(ctx, p, cfg) + case types.Gateway: + return NewGatewaySource(ctx, p, cfg) case types.GatewayHttpRoute: return NewGatewayHTTPRouteSource(ctx, p, cfg) case types.GatewayGrpcRoute: diff --git a/source/types/types.go b/source/types/types.go index 71a7fb6b1a..b0e2780d68 100644 --- a/source/types/types.go +++ b/source/types/types.go @@ -23,6 +23,7 @@ const ( Service Type = "service" Ingress Type = "ingress" Pod Type = "pod" + Gateway Type = "gateway" GatewayHttpRoute Type = "gateway-httproute" GatewayGrpcRoute Type = "gateway-grpcroute" GatewayTlsRoute Type = "gateway-tlsroute"