Skip to content

Commit 97ff505

Browse files
authored
Merge pull request #19 from numberly/feat/gateway-address-discovery
feat: implement gateway upstream resolution precedence
2 parents dcf94fc + b0fcff0 commit 97ff505

3 files changed

Lines changed: 165 additions & 15 deletions

File tree

docs/GETTINGSTARTED.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,24 @@ If `syncSecrets` is enabled, add read access to TLS secrets as well:
122122
verbs: ["get", "list", "watch"]
123123
```
124124

125+
### Gateway address discovery
126+
127+
For each Gateway, Yggdrasil discovers the data-plane address used as Envoy
128+
upstream endpoints in the following order (first match wins):
129+
130+
1. `Gateway.status.addresses` (published by any conformant Gateway API
131+
implementation), combined with the listener port.
132+
2. A Service in the Gateway's namespace whose `ownerReferences` point at the
133+
Gateway (per-Gateway deployments), using its `externalIPs` or LoadBalancer
134+
ingress.
135+
3. The optional `serviceNamespace`/`serviceName` fields of the matching
136+
`gatewayClasses` entry in the Yggdrasil configuration. This is only needed
137+
for implementations that share one Service across Gateways without
138+
publishing status addresses (e.g. Envoy Gateway merged mode with
139+
`externalIPs`, see envoyproxy/gateway#8987).
140+
141+
If no address is found, the route is skipped and a diagnostic is reported.
142+
125143
For Gateway API HTTPS listeners, Yggdrasil currently uses only the first
126144
`listener.tls.certificateRefs` entry. Additional certificate references are
127145
ignored and reported as diagnostics.

pkg/k8s/gateway_resources.go

Lines changed: 71 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -98,18 +98,25 @@ func ConvertGatewayResources(stores GatewayStores) (GatewayConversionResult, err
9898
if len(hosts) == 0 {
9999
continue
100100
}
101-
upstreams := gatewayServiceUpstreams(classConfig, listener, serviceByKey)
101+
source := RouteSource{
102+
Kind: "HTTPRoute",
103+
Namespace: route.Namespace,
104+
Name: route.Name,
105+
Class: classConfig.Name,
106+
KubernetesClusterName: stores.ClusterName,
107+
}
108+
upstreams := resolveGatewayUpstreams(gateway, listener, classConfig, serviceByKey)
102109
if len(upstreams) == 0 {
110+
for _, host := range hosts {
111+
result.Diagnostics = append(result.Diagnostics, GatewayDiagnostic{
112+
Host: host,
113+
Source: source,
114+
Reason: fmt.Sprintf("no gateway address found for gateway %s/%s: empty status.addresses, no owned Service, no serviceName configured for class %s", gateway.Namespace, gateway.Name, classConfig.Name),
115+
})
116+
}
103117
continue
104118
}
105119
if denyReason := listenerSecretRefDenyReason(gateway, listener, stores.ReferenceGrants); denyReason != "" {
106-
source := RouteSource{
107-
Kind: "HTTPRoute",
108-
Namespace: route.Namespace,
109-
Name: route.Name,
110-
Class: classConfig.Name,
111-
KubernetesClusterName: stores.ClusterName,
112-
}
113120
for _, host := range hosts {
114121
result.Diagnostics = append(result.Diagnostics, GatewayDiagnostic{
115122
Host: host,
@@ -119,13 +126,6 @@ func ConvertGatewayResources(stores GatewayStores) (GatewayConversionResult, err
119126
}
120127
continue
121128
}
122-
source := RouteSource{
123-
Kind: "HTTPRoute",
124-
Namespace: route.Namespace,
125-
Name: route.Name,
126-
Class: classConfig.Name,
127-
KubernetesClusterName: stores.ClusterName,
128-
}
129129
for _, diagnostic := range listenerCertificateRefDiagnostics(source, listener, hosts) {
130130
result.Diagnostics = append(result.Diagnostics, diagnostic)
131131
}
@@ -240,11 +240,67 @@ func hostMatchesGatewayListener(routeHost, listenerHost string) bool {
240240
return false
241241
}
242242

243+
// resolveGatewayUpstreams discovers the Gateway Address (data-plane endpoints) for a
244+
// listener, in vendor-agnostic precedence order (see docs/adr/0002):
245+
// 1. Gateway.status.addresses (spec-guaranteed) with the listener port
246+
// 2. a Service owned by the Gateway (per-Gateway deployments)
247+
// 3. the static serviceName/serviceNamespace from config.json (escape hatch for
248+
// merged/shared deployments whose implementation does not publish status addresses)
249+
func resolveGatewayUpstreams(gateway *gatewayv1.Gateway, listener gatewayv1.Listener, classConfig GatewayClassConfig, services map[string]*v1.Service) []UpstreamEndpoint {
250+
if upstreams := gatewayStatusUpstreams(gateway, listener); len(upstreams) > 0 {
251+
return upstreams
252+
}
253+
if upstreams := ownedServiceUpstreams(gateway, listener, services); len(upstreams) > 0 {
254+
return upstreams
255+
}
256+
return gatewayServiceUpstreams(classConfig, listener, services)
257+
}
258+
259+
func gatewayStatusUpstreams(gateway *gatewayv1.Gateway, listener gatewayv1.Listener) []UpstreamEndpoint {
260+
upstreams := []UpstreamEndpoint{}
261+
for _, address := range gateway.Status.Addresses {
262+
if address.Value == "" {
263+
continue
264+
}
265+
upstreams = append(upstreams, UpstreamEndpoint{Host: address.Value, Port: uint32(listener.Port)})
266+
}
267+
return uniqueUpstreams(upstreams)
268+
}
269+
270+
func ownedServiceUpstreams(gateway *gatewayv1.Gateway, listener gatewayv1.Listener, services map[string]*v1.Service) []UpstreamEndpoint {
271+
owned := []*v1.Service{}
272+
for _, service := range services {
273+
if service.Namespace == gateway.Namespace && isOwnedByGateway(service, gateway) {
274+
owned = append(owned, service)
275+
}
276+
}
277+
sort.Slice(owned, func(i, j int) bool { return owned[i].Name < owned[j].Name })
278+
for _, service := range owned {
279+
if upstreams := serviceUpstreams(service, listener); len(upstreams) > 0 {
280+
return upstreams
281+
}
282+
}
283+
return nil
284+
}
285+
286+
func isOwnedByGateway(service *v1.Service, gateway *gatewayv1.Gateway) bool {
287+
for _, owner := range service.OwnerReferences {
288+
if owner.Kind == "Gateway" && owner.Name == gateway.Name && strings.HasPrefix(owner.APIVersion, "gateway.networking.k8s.io/") {
289+
return true
290+
}
291+
}
292+
return false
293+
}
294+
243295
func gatewayServiceUpstreams(classConfig GatewayClassConfig, listener gatewayv1.Listener, services map[string]*v1.Service) []UpstreamEndpoint {
244296
service := services[namespacedName(classConfig.ServiceNamespace, classConfig.ServiceName)]
245297
if service == nil {
246298
return nil
247299
}
300+
return serviceUpstreams(service, listener)
301+
}
302+
303+
func serviceUpstreams(service *v1.Service, listener gatewayv1.Listener) []UpstreamEndpoint {
248304
port := servicePortForListener(service, listener)
249305
if port == 0 {
250306
return nil

pkg/k8s/gateway_resources_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,3 +641,79 @@ func namespacePtr(namespace string) *gatewayv1.Namespace {
641641
func fromNamespacesPtr(from gatewayv1.FromNamespaces) *gatewayv1.FromNamespaces {
642642
return &from
643643
}
644+
645+
func TestResolveGatewayUpstreamsPrecedence(t *testing.T) {
646+
listener := gatewayv1.Listener{Name: gatewayv1.SectionName("https"), Port: gatewayv1.PortNumber(443), Protocol: gatewayv1.HTTPSProtocolType}
647+
classConfig := GatewayClassConfig{Name: "public", ServiceNamespace: "gateway-system", ServiceName: "envoy"}
648+
configuredService := &corev1.Service{
649+
ObjectMeta: metav1.ObjectMeta{Name: "envoy", Namespace: "gateway-system"},
650+
Spec: corev1.ServiceSpec{ExternalIPs: []string{"10.0.0.3"}, Ports: []corev1.ServicePort{{Port: 443}}},
651+
}
652+
ownedService := &corev1.Service{
653+
ObjectMeta: metav1.ObjectMeta{
654+
Name: "edge-owned",
655+
Namespace: "gateway-system",
656+
OwnerReferences: []metav1.OwnerReference{{APIVersion: "gateway.networking.k8s.io/v1", Kind: "Gateway", Name: "edge"}},
657+
},
658+
Spec: corev1.ServiceSpec{ExternalIPs: []string{"10.0.0.2"}, Ports: []corev1.ServicePort{{Port: 443}}},
659+
}
660+
gatewayWithStatus := &gatewayv1.Gateway{
661+
ObjectMeta: metav1.ObjectMeta{Name: "edge", Namespace: "gateway-system"},
662+
Status: gatewayv1.GatewayStatus{
663+
Addresses: []gatewayv1.GatewayStatusAddress{{Value: "10.0.0.1"}, {Value: "lb.example.net"}},
664+
},
665+
}
666+
gatewayWithoutStatus := &gatewayv1.Gateway{ObjectMeta: metav1.ObjectMeta{Name: "edge", Namespace: "gateway-system"}}
667+
668+
services := map[string]*corev1.Service{
669+
"gateway-system/envoy": configuredService,
670+
"gateway-system/edge-owned": ownedService,
671+
}
672+
673+
got := resolveGatewayUpstreams(gatewayWithStatus, listener, classConfig, services)
674+
want := []UpstreamEndpoint{{Host: "10.0.0.1", Port: 443}, {Host: "lb.example.net", Port: 443}}
675+
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
676+
t.Fatalf("expected status addresses to win, got %+v", got)
677+
}
678+
679+
got = resolveGatewayUpstreams(gatewayWithoutStatus, listener, classConfig, services)
680+
if len(got) != 1 || got[0] != (UpstreamEndpoint{Host: "10.0.0.2", Port: 443}) {
681+
t.Fatalf("expected owned Service fallback, got %+v", got)
682+
}
683+
684+
got = resolveGatewayUpstreams(gatewayWithoutStatus, listener, classConfig, map[string]*corev1.Service{"gateway-system/envoy": configuredService})
685+
if len(got) != 1 || got[0] != (UpstreamEndpoint{Host: "10.0.0.3", Port: 443}) {
686+
t.Fatalf("expected configured Service fallback, got %+v", got)
687+
}
688+
689+
got = resolveGatewayUpstreams(gatewayWithoutStatus, listener, GatewayClassConfig{Name: "public"}, map[string]*corev1.Service{})
690+
if len(got) != 0 {
691+
t.Fatalf("expected no upstreams without any discovery source, got %+v", got)
692+
}
693+
}
694+
695+
func TestOwnedServiceUpstreamsIgnoresForeignOwners(t *testing.T) {
696+
listener := gatewayv1.Listener{Port: gatewayv1.PortNumber(443)}
697+
gateway := &gatewayv1.Gateway{ObjectMeta: metav1.ObjectMeta{Name: "edge", Namespace: "gateway-system"}}
698+
services := map[string]*corev1.Service{
699+
"gateway-system/other": {
700+
ObjectMeta: metav1.ObjectMeta{
701+
Name: "other",
702+
Namespace: "gateway-system",
703+
OwnerReferences: []metav1.OwnerReference{{APIVersion: "apps/v1", Kind: "Deployment", Name: "edge"}},
704+
},
705+
Spec: corev1.ServiceSpec{ExternalIPs: []string{"10.0.0.9"}, Ports: []corev1.ServicePort{{Port: 443}}},
706+
},
707+
"apps/edge-owned": {
708+
ObjectMeta: metav1.ObjectMeta{
709+
Name: "edge-owned",
710+
Namespace: "apps",
711+
OwnerReferences: []metav1.OwnerReference{{APIVersion: "gateway.networking.k8s.io/v1", Kind: "Gateway", Name: "edge"}},
712+
},
713+
Spec: corev1.ServiceSpec{ExternalIPs: []string{"10.0.0.8"}, Ports: []corev1.ServicePort{{Port: 443}}},
714+
},
715+
}
716+
if got := ownedServiceUpstreams(gateway, listener, services); len(got) != 0 {
717+
t.Fatalf("expected no owned upstreams (wrong owner kind / wrong namespace), got %+v", got)
718+
}
719+
}

0 commit comments

Comments
 (0)