Add support for backend protocol selection in HTTP and GRPC routes
This commit is contained in:
parent
9dc2155e63
commit
e222d5cb2f
9 changed files with 495 additions and 219 deletions
|
@ -213,6 +213,8 @@ func (s *K8sConformanceSuite) TestK8sGatewayAPIConformance() {
|
||||||
features.SupportHTTPRoutePathRedirect,
|
features.SupportHTTPRoutePathRedirect,
|
||||||
features.SupportHTTPRouteResponseHeaderModification,
|
features.SupportHTTPRouteResponseHeaderModification,
|
||||||
features.SupportTLSRoute,
|
features.SupportTLSRoute,
|
||||||
|
features.SupportHTTPRouteBackendProtocolH2C,
|
||||||
|
features.SupportHTTPRouteBackendProtocolWebSocket,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
---
|
||||||
|
kind: GatewayClass
|
||||||
|
apiVersion: gateway.networking.k8s.io/v1
|
||||||
|
metadata:
|
||||||
|
name: my-gateway-class
|
||||||
|
spec:
|
||||||
|
controllerName: traefik.io/gateway-controller
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: Gateway
|
||||||
|
apiVersion: gateway.networking.k8s.io/v1
|
||||||
|
metadata:
|
||||||
|
name: my-gateway
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
gatewayClassName: my-gateway-class
|
||||||
|
listeners: # Use GatewayClass defaults for listener definition.
|
||||||
|
- name: http
|
||||||
|
protocol: HTTP
|
||||||
|
port: 80
|
||||||
|
allowedRoutes:
|
||||||
|
kinds:
|
||||||
|
- kind: HTTPRoute
|
||||||
|
group: gateway.networking.k8s.io
|
||||||
|
namespaces:
|
||||||
|
from: Same
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: HTTPRoute
|
||||||
|
apiVersion: gateway.networking.k8s.io/v1
|
||||||
|
metadata:
|
||||||
|
name: http-multi-protocols
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
parentRefs:
|
||||||
|
- name: my-gateway
|
||||||
|
kind: Gateway
|
||||||
|
group: gateway.networking.k8s.io
|
||||||
|
hostnames:
|
||||||
|
- "foo.com"
|
||||||
|
rules:
|
||||||
|
- matches:
|
||||||
|
- path:
|
||||||
|
type: Exact
|
||||||
|
value: /bar
|
||||||
|
backendRefs:
|
||||||
|
- name: whoami-h2c
|
||||||
|
port: 80
|
||||||
|
weight: 1
|
||||||
|
kind: Service
|
||||||
|
group: ""
|
||||||
|
- name: whoami-ws
|
||||||
|
port: 80
|
||||||
|
weight: 1
|
||||||
|
kind: Service
|
||||||
|
group: ""
|
||||||
|
- name: whoami-wss
|
||||||
|
port: 80
|
||||||
|
weight: 1
|
||||||
|
kind: Service
|
||||||
|
group: ""
|
|
@ -7,9 +7,11 @@ metadata:
|
||||||
spec:
|
spec:
|
||||||
ports:
|
ports:
|
||||||
- name: web2
|
- name: web2
|
||||||
|
protocol: TCP
|
||||||
port: 8000
|
port: 8000
|
||||||
targetPort: web2
|
targetPort: web2
|
||||||
- name: web
|
- name: web
|
||||||
|
protocol: TCP
|
||||||
port: 80
|
port: 80
|
||||||
targetPort: web
|
targetPort: web
|
||||||
selector:
|
selector:
|
||||||
|
@ -48,9 +50,11 @@ metadata:
|
||||||
spec:
|
spec:
|
||||||
ports:
|
ports:
|
||||||
- name: web2
|
- name: web2
|
||||||
|
protocol: TCP
|
||||||
port: 8000
|
port: 8000
|
||||||
targetPort: web2
|
targetPort: web2
|
||||||
- name: web
|
- name: web
|
||||||
|
protocol: TCP
|
||||||
port: 80
|
port: 80
|
||||||
targetPort: web
|
targetPort: web
|
||||||
selector:
|
selector:
|
||||||
|
@ -89,6 +93,7 @@ metadata:
|
||||||
spec:
|
spec:
|
||||||
ports:
|
ports:
|
||||||
- name: web
|
- name: web
|
||||||
|
protocol: TCP
|
||||||
port: 8080
|
port: 8080
|
||||||
targetPort: web
|
targetPort: web
|
||||||
selector:
|
selector:
|
||||||
|
@ -317,3 +322,105 @@ status:
|
||||||
ingress:
|
ingress:
|
||||||
- hostname: foo.bar
|
- hostname: foo.bar
|
||||||
- ip: 1.2.3.4
|
- ip: 1.2.3.4
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: EndpointSlice
|
||||||
|
apiVersion: discovery.k8s.io/v1
|
||||||
|
metadata:
|
||||||
|
name: whoami-h2c
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
kubernetes.io/service-name: whoami-h2c
|
||||||
|
|
||||||
|
addressType: IPv4
|
||||||
|
ports:
|
||||||
|
- name: h2c
|
||||||
|
protocol: TCP
|
||||||
|
port: 80
|
||||||
|
endpoints:
|
||||||
|
- addresses:
|
||||||
|
- 10.10.0.13
|
||||||
|
conditions:
|
||||||
|
ready: true
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: whoami-h2c
|
||||||
|
namespace: default
|
||||||
|
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 80
|
||||||
|
name: h2c
|
||||||
|
appProtocol: kubernetes.io/h2c
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: EndpointSlice
|
||||||
|
apiVersion: discovery.k8s.io/v1
|
||||||
|
metadata:
|
||||||
|
name: whoami-ws
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
kubernetes.io/service-name: whoami-ws
|
||||||
|
|
||||||
|
addressType: IPv4
|
||||||
|
ports:
|
||||||
|
- name: ws
|
||||||
|
protocol: TCP
|
||||||
|
port: 80
|
||||||
|
endpoints:
|
||||||
|
- addresses:
|
||||||
|
- 10.10.0.14
|
||||||
|
conditions:
|
||||||
|
ready: true
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: whoami-ws
|
||||||
|
namespace: default
|
||||||
|
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 80
|
||||||
|
name: ws
|
||||||
|
appProtocol: kubernetes.io/ws
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: EndpointSlice
|
||||||
|
apiVersion: discovery.k8s.io/v1
|
||||||
|
metadata:
|
||||||
|
name: whoami-wss
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
kubernetes.io/service-name: whoami-wss
|
||||||
|
|
||||||
|
addressType: IPv4
|
||||||
|
ports:
|
||||||
|
- name: wss
|
||||||
|
protocol: TCP
|
||||||
|
port: 80
|
||||||
|
endpoints:
|
||||||
|
- addresses:
|
||||||
|
- 10.10.0.15
|
||||||
|
conditions:
|
||||||
|
ready: true
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: whoami-wss
|
||||||
|
namespace: default
|
||||||
|
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 80
|
||||||
|
name: wss
|
||||||
|
appProtocol: kubernetes.io/wss
|
||||||
|
|
|
@ -2,7 +2,6 @@ package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
@ -260,23 +259,16 @@ func (p *Provider) loadGRPCBackendRef(route *gatev1.GRPCRoute, backendRef gatev1
|
||||||
ObservedGeneration: route.Generation,
|
ObservedGeneration: route.Generation,
|
||||||
LastTransitionTime: metav1.Now(),
|
LastTransitionTime: metav1.Now(),
|
||||||
Reason: string(gatev1.RouteReasonUnsupportedProtocol),
|
Reason: string(gatev1.RouteReasonUnsupportedProtocol),
|
||||||
Message: fmt.Sprintf("Cannot load GRPCBackendRef %s/%s/%s/%s port is required", group, kind, namespace, backendRef.Name),
|
Message: fmt.Sprintf("Cannot load GRPCBackendRef %s/%s/%s/%s: port is required", group, kind, namespace, backendRef.Name),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
portStr := strconv.FormatInt(int64(port), 10)
|
portStr := strconv.FormatInt(int64(port), 10)
|
||||||
serviceName = provider.Normalize(serviceName + "-" + portStr)
|
serviceName = provider.Normalize(serviceName + "-" + portStr)
|
||||||
|
|
||||||
lb, err := p.loadGRPCServers(namespace, backendRef)
|
lb, errCondition := p.loadGRPCServers(namespace, route, backendRef)
|
||||||
if err != nil {
|
if errCondition != nil {
|
||||||
return serviceName, nil, &metav1.Condition{
|
return serviceName, nil, errCondition
|
||||||
Type: string(gatev1.RouteConditionResolvedRefs),
|
|
||||||
Status: metav1.ConditionFalse,
|
|
||||||
ObservedGeneration: route.Generation,
|
|
||||||
LastTransitionTime: metav1.Now(),
|
|
||||||
Reason: string(gatev1.RouteReasonBackendNotFound),
|
|
||||||
Message: fmt.Sprintf("Cannot load GRPCBackendRef %s/%s/%s/%s: %s", group, kind, namespace, backendRef.Name, err),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return serviceName, &dynamic.Service{LoadBalancer: lb}, nil
|
return serviceName, &dynamic.Service{LoadBalancer: lb}, nil
|
||||||
|
@ -319,72 +311,49 @@ func (p *Provider) loadGRPCMiddlewares(conf *dynamic.Configuration, namespace, r
|
||||||
return middlewareNames, nil
|
return middlewareNames, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Provider) loadGRPCServers(namespace string, backendRef gatev1.GRPCBackendRef) (*dynamic.ServersLoadBalancer, error) {
|
func (p *Provider) loadGRPCServers(namespace string, route *gatev1.GRPCRoute, backendRef gatev1.GRPCBackendRef) (*dynamic.ServersLoadBalancer, *metav1.Condition) {
|
||||||
if backendRef.Port == nil {
|
backendAddresses, svcPort, err := p.getBackendAddresses(namespace, backendRef.BackendRef)
|
||||||
return nil, errors.New("port is required for Kubernetes Service reference")
|
|
||||||
}
|
|
||||||
|
|
||||||
service, exists, err := p.client.GetService(namespace, string(backendRef.Name))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("getting service: %w", err)
|
return nil, &metav1.Condition{
|
||||||
|
Type: string(gatev1.RouteConditionResolvedRefs),
|
||||||
|
Status: metav1.ConditionFalse,
|
||||||
|
ObservedGeneration: route.Generation,
|
||||||
|
LastTransitionTime: metav1.Now(),
|
||||||
|
Reason: string(gatev1.RouteReasonBackendNotFound),
|
||||||
|
Message: fmt.Sprintf("Cannot load GRPCBackendRef %s/%s: %s", namespace, backendRef.Name, err),
|
||||||
}
|
}
|
||||||
if !exists {
|
|
||||||
return nil, errors.New("service not found")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var svcPort *corev1.ServicePort
|
if svcPort.Protocol != corev1.ProtocolTCP {
|
||||||
for _, p := range service.Spec.Ports {
|
return nil, &metav1.Condition{
|
||||||
if p.Port == int32(*backendRef.Port) {
|
Type: string(gatev1.RouteConditionResolvedRefs),
|
||||||
svcPort = &p
|
Status: metav1.ConditionFalse,
|
||||||
break
|
ObservedGeneration: route.Generation,
|
||||||
|
LastTransitionTime: metav1.Now(),
|
||||||
|
Reason: string(gatev1.RouteReasonUnsupportedProtocol),
|
||||||
|
Message: fmt.Sprintf("Cannot load GRPCBackendRef %s/%s: only TCP protocol is supported", namespace, backendRef.Name),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if svcPort == nil {
|
|
||||||
return nil, fmt.Errorf("service port %d not found", *backendRef.Port)
|
|
||||||
}
|
|
||||||
|
|
||||||
endpointSlices, err := p.client.ListEndpointSlicesForService(namespace, string(backendRef.Name))
|
if svcPort.AppProtocol != nil && *svcPort.AppProtocol != appProtocolH2C {
|
||||||
if err != nil {
|
return nil, &metav1.Condition{
|
||||||
return nil, fmt.Errorf("getting endpointslices: %w", err)
|
Type: string(gatev1.RouteConditionResolvedRefs),
|
||||||
|
Status: metav1.ConditionFalse,
|
||||||
|
ObservedGeneration: route.Generation,
|
||||||
|
LastTransitionTime: metav1.Now(),
|
||||||
|
Reason: string(gatev1.RouteReasonUnsupportedProtocol),
|
||||||
|
Message: fmt.Sprintf("Cannot load GRPCBackendRef %s/%s: only kubernetes.io/h2c appProtocol is supported", namespace, backendRef.Name),
|
||||||
}
|
}
|
||||||
if len(endpointSlices) == 0 {
|
|
||||||
return nil, errors.New("endpointslices not found")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lb := &dynamic.ServersLoadBalancer{}
|
lb := &dynamic.ServersLoadBalancer{}
|
||||||
lb.SetDefaults()
|
lb.SetDefaults()
|
||||||
|
|
||||||
addresses := map[string]struct{}{}
|
for _, ba := range backendAddresses {
|
||||||
for _, endpointSlice := range endpointSlices {
|
|
||||||
var port int32
|
|
||||||
for _, p := range endpointSlice.Ports {
|
|
||||||
if svcPort.Name == *p.Name {
|
|
||||||
port = *p.Port
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if port == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, endpoint := range endpointSlice.Endpoints {
|
|
||||||
if endpoint.Conditions.Ready == nil || !*endpoint.Conditions.Ready {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, address := range endpoint.Addresses {
|
|
||||||
if _, ok := addresses[address]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
addresses[address] = struct{}{}
|
|
||||||
lb.Servers = append(lb.Servers, dynamic.Server{
|
lb.Servers = append(lb.Servers, dynamic.Server{
|
||||||
URL: fmt.Sprintf("h2c://%s", net.JoinHostPort(address, strconv.Itoa(int(port)))),
|
URL: fmt.Sprintf("h2c://%s", net.JoinHostPort(ba.Address, strconv.Itoa(int(ba.Port)))),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lb, nil
|
return lb, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -260,23 +260,16 @@ func (p *Provider) loadService(route *gatev1.HTTPRoute, backendRef gatev1.HTTPBa
|
||||||
ObservedGeneration: route.Generation,
|
ObservedGeneration: route.Generation,
|
||||||
LastTransitionTime: metav1.Now(),
|
LastTransitionTime: metav1.Now(),
|
||||||
Reason: string(gatev1.RouteReasonUnsupportedProtocol),
|
Reason: string(gatev1.RouteReasonUnsupportedProtocol),
|
||||||
Message: fmt.Sprintf("Cannot load HTTPBackendRef %s/%s/%s/%s port is required", group, kind, namespace, backendRef.Name),
|
Message: fmt.Sprintf("Cannot load HTTPBackendRef %s/%s/%s/%s: port is required", group, kind, namespace, backendRef.Name),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
portStr := strconv.FormatInt(int64(port), 10)
|
portStr := strconv.FormatInt(int64(port), 10)
|
||||||
serviceName = provider.Normalize(serviceName + "-" + portStr)
|
serviceName = provider.Normalize(serviceName + "-" + portStr)
|
||||||
|
|
||||||
lb, err := p.loadHTTPServers(namespace, backendRef)
|
lb, errCondition := p.loadHTTPServers(namespace, route, backendRef)
|
||||||
if err != nil {
|
if errCondition != nil {
|
||||||
return serviceName, nil, &metav1.Condition{
|
return serviceName, nil, errCondition
|
||||||
Type: string(gatev1.RouteConditionResolvedRefs),
|
|
||||||
Status: metav1.ConditionFalse,
|
|
||||||
ObservedGeneration: route.Generation,
|
|
||||||
LastTransitionTime: metav1.Now(),
|
|
||||||
Reason: string(gatev1.RouteReasonBackendNotFound),
|
|
||||||
Message: fmt.Sprintf("Cannot load HTTPBackendRef %s/%s/%s/%s: %s", group, kind, namespace, backendRef.Name, err),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return serviceName, &dynamic.Service{LoadBalancer: lb}, nil
|
return serviceName, &dynamic.Service{LoadBalancer: lb}, nil
|
||||||
|
@ -372,74 +365,39 @@ func (p *Provider) loadHTTPRouteFilterExtensionRef(namespace string, extensionRe
|
||||||
return filterFunc(string(extensionRef.Name), namespace)
|
return filterFunc(string(extensionRef.Name), namespace)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Provider) loadHTTPServers(namespace string, backendRef gatev1.HTTPBackendRef) (*dynamic.ServersLoadBalancer, error) {
|
func (p *Provider) loadHTTPServers(namespace string, route *gatev1.HTTPRoute, backendRef gatev1.HTTPBackendRef) (*dynamic.ServersLoadBalancer, *metav1.Condition) {
|
||||||
if backendRef.Port == nil {
|
backendAddresses, svcPort, err := p.getBackendAddresses(namespace, backendRef.BackendRef)
|
||||||
return nil, errors.New("port is required for Kubernetes Service reference")
|
|
||||||
}
|
|
||||||
|
|
||||||
service, exists, err := p.client.GetService(namespace, string(backendRef.Name))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("getting service: %w", err)
|
return nil, &metav1.Condition{
|
||||||
|
Type: string(gatev1.RouteConditionResolvedRefs),
|
||||||
|
Status: metav1.ConditionFalse,
|
||||||
|
ObservedGeneration: route.Generation,
|
||||||
|
LastTransitionTime: metav1.Now(),
|
||||||
|
Reason: string(gatev1.RouteReasonBackendNotFound),
|
||||||
|
Message: fmt.Sprintf("Cannot load HTTPBackendRef %s/%s: %s", namespace, backendRef.Name, err),
|
||||||
}
|
}
|
||||||
if !exists {
|
|
||||||
return nil, errors.New("service not found")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var svcPort *corev1.ServicePort
|
protocol, err := getProtocol(svcPort)
|
||||||
for _, p := range service.Spec.Ports {
|
|
||||||
if p.Port == int32(*backendRef.Port) {
|
|
||||||
svcPort = &p
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if svcPort == nil {
|
|
||||||
return nil, fmt.Errorf("service port %d not found", *backendRef.Port)
|
|
||||||
}
|
|
||||||
|
|
||||||
endpointSlices, err := p.client.ListEndpointSlicesForService(namespace, string(backendRef.Name))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("getting endpointslices: %w", err)
|
return nil, &metav1.Condition{
|
||||||
|
Type: string(gatev1.RouteConditionResolvedRefs),
|
||||||
|
Status: metav1.ConditionFalse,
|
||||||
|
ObservedGeneration: route.Generation,
|
||||||
|
LastTransitionTime: metav1.Now(),
|
||||||
|
Reason: string(gatev1.RouteReasonUnsupportedProtocol),
|
||||||
|
Message: fmt.Sprintf("Cannot load HTTPBackendRef %s/%s: %s", namespace, backendRef.Name, err),
|
||||||
}
|
}
|
||||||
if len(endpointSlices) == 0 {
|
|
||||||
return nil, errors.New("endpointslices not found")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lb := &dynamic.ServersLoadBalancer{}
|
lb := &dynamic.ServersLoadBalancer{}
|
||||||
lb.SetDefaults()
|
lb.SetDefaults()
|
||||||
|
|
||||||
protocol := getProtocol(*svcPort)
|
for _, ba := range backendAddresses {
|
||||||
|
|
||||||
addresses := map[string]struct{}{}
|
|
||||||
for _, endpointSlice := range endpointSlices {
|
|
||||||
var port int32
|
|
||||||
for _, p := range endpointSlice.Ports {
|
|
||||||
if svcPort.Name == *p.Name {
|
|
||||||
port = *p.Port
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if port == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, endpoint := range endpointSlice.Endpoints {
|
|
||||||
if endpoint.Conditions.Ready == nil || !*endpoint.Conditions.Ready {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, address := range endpoint.Addresses {
|
|
||||||
if _, ok := addresses[address]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
addresses[address] = struct{}{}
|
|
||||||
lb.Servers = append(lb.Servers, dynamic.Server{
|
lb.Servers = append(lb.Servers, dynamic.Server{
|
||||||
URL: fmt.Sprintf("%s://%s", protocol, net.JoinHostPort(address, strconv.Itoa(int(port)))),
|
URL: fmt.Sprintf("%s://%s", protocol, net.JoinHostPort(ba.Address, strconv.Itoa(int(ba.Port)))),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lb, nil
|
return lb, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -702,13 +660,29 @@ func createURLRewrite(filter *gatev1.HTTPURLRewriteFilter, pathMatch gatev1.HTTP
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getProtocol(portSpec corev1.ServicePort) string {
|
func getProtocol(portSpec corev1.ServicePort) (string, error) {
|
||||||
|
if portSpec.Protocol != corev1.ProtocolTCP {
|
||||||
|
return "", errors.New("only TCP protocol is supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
if portSpec.AppProtocol == nil {
|
||||||
protocol := "http"
|
protocol := "http"
|
||||||
if portSpec.Port == 443 || strings.HasPrefix(portSpec.Name, "https") {
|
if portSpec.Port == 443 || strings.HasPrefix(portSpec.Name, "https") {
|
||||||
protocol = "https"
|
protocol = "https"
|
||||||
}
|
}
|
||||||
|
return protocol, nil
|
||||||
|
}
|
||||||
|
|
||||||
return protocol
|
switch ap := *portSpec.AppProtocol; ap {
|
||||||
|
case appProtocolH2C:
|
||||||
|
return "h2c", nil
|
||||||
|
case appProtocolWS:
|
||||||
|
return "http", nil
|
||||||
|
case appProtocolWSS:
|
||||||
|
return "https", nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported application protocol %s", ap)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeHTTPConfiguration(from, to *dynamic.Configuration) {
|
func mergeHTTPConfiguration(from, to *dynamic.Configuration) {
|
||||||
|
|
|
@ -36,6 +36,7 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
providerName = "kubernetesgateway"
|
providerName = "kubernetesgateway"
|
||||||
|
|
||||||
controllerName = "traefik.io/gateway-controller"
|
controllerName = "traefik.io/gateway-controller"
|
||||||
|
|
||||||
groupCore = "core"
|
groupCore = "core"
|
||||||
|
@ -48,6 +49,10 @@ const (
|
||||||
kindTCPRoute = "TCPRoute"
|
kindTCPRoute = "TCPRoute"
|
||||||
kindTLSRoute = "TLSRoute"
|
kindTLSRoute = "TLSRoute"
|
||||||
kindService = "Service"
|
kindService = "Service"
|
||||||
|
|
||||||
|
appProtocolH2C = "kubernetes.io/h2c"
|
||||||
|
appProtocolWS = "kubernetes.io/ws"
|
||||||
|
appProtocolWSS = "kubernetes.io/wss"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Provider holds configurations of the provider.
|
// Provider holds configurations of the provider.
|
||||||
|
@ -854,6 +859,79 @@ func (p *Provider) allowedNamespaces(gatewayNamespace string, routeNamespaces *g
|
||||||
return nil, fmt.Errorf("unsupported RouteSelectType: %q", *routeNamespaces.From)
|
return nil, fmt.Errorf("unsupported RouteSelectType: %q", *routeNamespaces.From)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type backendAddress struct {
|
||||||
|
Address string
|
||||||
|
Port int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) getBackendAddresses(namespace string, ref gatev1.BackendRef) ([]backendAddress, corev1.ServicePort, error) {
|
||||||
|
if ref.Port == nil {
|
||||||
|
return nil, corev1.ServicePort{}, errors.New("port is required for Kubernetes Service reference")
|
||||||
|
}
|
||||||
|
|
||||||
|
service, exists, err := p.client.GetService(namespace, string(ref.Name))
|
||||||
|
if err != nil {
|
||||||
|
return nil, corev1.ServicePort{}, fmt.Errorf("getting service: %w", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil, corev1.ServicePort{}, errors.New("service not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
var svcPort *corev1.ServicePort
|
||||||
|
for _, p := range service.Spec.Ports {
|
||||||
|
if p.Port == int32(*ref.Port) {
|
||||||
|
svcPort = &p
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if svcPort == nil {
|
||||||
|
return nil, corev1.ServicePort{}, fmt.Errorf("service port %d not found", *ref.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpointSlices, err := p.client.ListEndpointSlicesForService(namespace, string(ref.Name))
|
||||||
|
if err != nil {
|
||||||
|
return nil, corev1.ServicePort{}, fmt.Errorf("getting endpointslices: %w", err)
|
||||||
|
}
|
||||||
|
if len(endpointSlices) == 0 {
|
||||||
|
return nil, corev1.ServicePort{}, errors.New("endpointslices not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
uniqAddresses := map[string]struct{}{}
|
||||||
|
backendServers := make([]backendAddress, 0)
|
||||||
|
for _, endpointSlice := range endpointSlices {
|
||||||
|
var port int32
|
||||||
|
for _, p := range endpointSlice.Ports {
|
||||||
|
if svcPort.Name == *p.Name {
|
||||||
|
port = *p.Port
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if port == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, endpoint := range endpointSlice.Endpoints {
|
||||||
|
if endpoint.Conditions.Ready == nil || !*endpoint.Conditions.Ready {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, address := range endpoint.Addresses {
|
||||||
|
if _, ok := uniqAddresses[address]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
uniqAddresses[address] = struct{}{}
|
||||||
|
backendServers = append(backendServers, backendAddress{
|
||||||
|
Address: address,
|
||||||
|
Port: port,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return backendServers, *svcPort, nil
|
||||||
|
}
|
||||||
|
|
||||||
func supportedRouteKinds(protocol gatev1.ProtocolType, experimentalChannel bool) ([]gatev1.RouteGroupKind, []metav1.Condition) {
|
func supportedRouteKinds(protocol gatev1.ProtocolType, experimentalChannel bool) ([]gatev1.RouteGroupKind, []metav1.Condition) {
|
||||||
group := gatev1.Group(gatev1.GroupName)
|
group := gatev1.Group(gatev1.GroupName)
|
||||||
|
|
||||||
|
|
|
@ -2452,6 +2452,104 @@ func TestLoadHTTPRoutes_backendExtensionRef(t *testing.T) {
|
||||||
TLS: &dynamic.TLSConfiguration{},
|
TLS: &dynamic.TLSConfiguration{},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
desc: "Simple HTTPRoute, with appProtocol service",
|
||||||
|
paths: []string{"services.yml", "httproute/with_app_protocol_service.yml"},
|
||||||
|
groupKindBackendFuncs: map[string]map[string]BuildBackendFunc{
|
||||||
|
traefikv1alpha1.GroupName: {"TraefikService": func(name, namespace string) (string, *dynamic.Service, error) {
|
||||||
|
// func should never be executed in case of cross-provider reference.
|
||||||
|
return "", nil, errors.New("BOOM")
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
entryPoints: map[string]Entrypoint{"web": {
|
||||||
|
Address: ":80",
|
||||||
|
}},
|
||||||
|
expected: &dynamic.Configuration{
|
||||||
|
UDP: &dynamic.UDPConfiguration{
|
||||||
|
Routers: map[string]*dynamic.UDPRouter{},
|
||||||
|
Services: map[string]*dynamic.UDPService{},
|
||||||
|
},
|
||||||
|
TCP: &dynamic.TCPConfiguration{
|
||||||
|
Routers: map[string]*dynamic.TCPRouter{},
|
||||||
|
Middlewares: map[string]*dynamic.TCPMiddleware{},
|
||||||
|
Services: map[string]*dynamic.TCPService{},
|
||||||
|
ServersTransports: map[string]*dynamic.TCPServersTransport{},
|
||||||
|
},
|
||||||
|
HTTP: &dynamic.HTTPConfiguration{
|
||||||
|
Routers: map[string]*dynamic.Router{
|
||||||
|
"default-http-multi-protocols-my-gateway-web-0-1c0cf64bde37d9d0df06": {
|
||||||
|
EntryPoints: []string{"web"},
|
||||||
|
Service: "default-http-multi-protocols-my-gateway-web-0-1c0cf64bde37d9d0df06-wrr",
|
||||||
|
Rule: "Host(`foo.com`) && Path(`/bar`)",
|
||||||
|
Priority: 100008,
|
||||||
|
RuleSyntax: "v3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Middlewares: map[string]*dynamic.Middleware{},
|
||||||
|
Services: map[string]*dynamic.Service{
|
||||||
|
"default-http-multi-protocols-my-gateway-web-0-1c0cf64bde37d9d0df06-wrr": {
|
||||||
|
Weighted: &dynamic.WeightedRoundRobin{
|
||||||
|
Services: []dynamic.WRRService{
|
||||||
|
{
|
||||||
|
Name: "default-whoami-h2c-80",
|
||||||
|
Weight: ptr.To(1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "default-whoami-ws-80",
|
||||||
|
Weight: ptr.To(1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "default-whoami-wss-80",
|
||||||
|
Weight: ptr.To(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"default-whoami-h2c-80": {
|
||||||
|
LoadBalancer: &dynamic.ServersLoadBalancer{
|
||||||
|
Servers: []dynamic.Server{
|
||||||
|
{
|
||||||
|
URL: "h2c://10.10.0.13:80",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PassHostHeader: ptr.To(true),
|
||||||
|
ResponseForwarding: &dynamic.ResponseForwarding{
|
||||||
|
FlushInterval: ptypes.Duration(100 * time.Millisecond),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"default-whoami-ws-80": {
|
||||||
|
LoadBalancer: &dynamic.ServersLoadBalancer{
|
||||||
|
Servers: []dynamic.Server{
|
||||||
|
{
|
||||||
|
URL: "http://10.10.0.14:80",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PassHostHeader: ptr.To(true),
|
||||||
|
ResponseForwarding: &dynamic.ResponseForwarding{
|
||||||
|
FlushInterval: ptypes.Duration(100 * time.Millisecond),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"default-whoami-wss-80": {
|
||||||
|
LoadBalancer: &dynamic.ServersLoadBalancer{
|
||||||
|
Servers: []dynamic.Server{
|
||||||
|
{
|
||||||
|
URL: "https://10.10.0.15:80",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PassHostHeader: ptr.To(true),
|
||||||
|
ResponseForwarding: &dynamic.ResponseForwarding{
|
||||||
|
FlushInterval: ptypes.Duration(100 * time.Millisecond),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ServersTransports: map[string]*dynamic.ServersTransport{},
|
||||||
|
},
|
||||||
|
TLS: &dynamic.TLSConfiguration{},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range testCases {
|
for _, test := range testCases {
|
||||||
|
|
|
@ -2,7 +2,6 @@ package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
@ -252,87 +251,45 @@ func (p *Provider) loadTCPService(route *gatev1alpha2.TCPRoute, backendRef gatev
|
||||||
portStr := strconv.FormatInt(int64(port), 10)
|
portStr := strconv.FormatInt(int64(port), 10)
|
||||||
serviceName = provider.Normalize(serviceName + "-" + portStr)
|
serviceName = provider.Normalize(serviceName + "-" + portStr)
|
||||||
|
|
||||||
lb, err := p.loadTCPServers(namespace, backendRef)
|
lb, errCondition := p.loadTCPServers(namespace, route, backendRef)
|
||||||
if err != nil {
|
if errCondition != nil {
|
||||||
return serviceName, nil, &metav1.Condition{
|
return serviceName, nil, errCondition
|
||||||
Type: string(gatev1.RouteConditionResolvedRefs),
|
|
||||||
Status: metav1.ConditionFalse,
|
|
||||||
ObservedGeneration: route.Generation,
|
|
||||||
LastTransitionTime: metav1.Now(),
|
|
||||||
Reason: string(gatev1.RouteReasonBackendNotFound),
|
|
||||||
Message: fmt.Sprintf("Cannot load TCPRoute BackendRef %s/%s/%s/%s: %s", group, kind, namespace, backendRef.Name, err),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return serviceName, &dynamic.TCPService{LoadBalancer: lb}, nil
|
return serviceName, &dynamic.TCPService{LoadBalancer: lb}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Provider) loadTCPServers(namespace string, backendRef gatev1.BackendRef) (*dynamic.TCPServersLoadBalancer, error) {
|
func (p *Provider) loadTCPServers(namespace string, route *gatev1alpha2.TCPRoute, backendRef gatev1.BackendRef) (*dynamic.TCPServersLoadBalancer, *metav1.Condition) {
|
||||||
if backendRef.Port == nil {
|
backendAddresses, svcPort, err := p.getBackendAddresses(namespace, backendRef)
|
||||||
return nil, errors.New("port is required for Kubernetes Service reference")
|
|
||||||
}
|
|
||||||
|
|
||||||
service, exists, err := p.client.GetService(namespace, string(backendRef.Name))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("getting service: %w", err)
|
return nil, &metav1.Condition{
|
||||||
|
Type: string(gatev1.RouteConditionResolvedRefs),
|
||||||
|
Status: metav1.ConditionFalse,
|
||||||
|
ObservedGeneration: route.GetGeneration(),
|
||||||
|
LastTransitionTime: metav1.Now(),
|
||||||
|
Reason: string(gatev1.RouteReasonBackendNotFound),
|
||||||
|
Message: fmt.Sprintf("Cannot load TCPRoute BackendRef %s/%s: %s", namespace, backendRef.Name, err),
|
||||||
}
|
}
|
||||||
if !exists {
|
|
||||||
return nil, errors.New("service not found")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var svcPort *corev1.ServicePort
|
if svcPort.Protocol != corev1.ProtocolTCP {
|
||||||
for _, p := range service.Spec.Ports {
|
return nil, &metav1.Condition{
|
||||||
if p.Port == int32(*backendRef.Port) {
|
Type: string(gatev1.RouteConditionResolvedRefs),
|
||||||
svcPort = &p
|
Status: metav1.ConditionFalse,
|
||||||
break
|
ObservedGeneration: route.GetGeneration(),
|
||||||
|
LastTransitionTime: metav1.Now(),
|
||||||
|
Reason: string(gatev1.RouteReasonUnsupportedProtocol),
|
||||||
|
Message: fmt.Sprintf("Cannot load TCPRoute BackendRef %s/%s: only TCP protocol is supported", namespace, backendRef.Name),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if svcPort == nil {
|
|
||||||
return nil, fmt.Errorf("service port %d not found", *backendRef.Port)
|
|
||||||
}
|
|
||||||
|
|
||||||
endpointSlices, err := p.client.ListEndpointSlicesForService(namespace, string(backendRef.Name))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("getting endpointslices: %w", err)
|
|
||||||
}
|
|
||||||
if len(endpointSlices) == 0 {
|
|
||||||
return nil, errors.New("endpointslices not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
lb := &dynamic.TCPServersLoadBalancer{}
|
lb := &dynamic.TCPServersLoadBalancer{}
|
||||||
|
|
||||||
addresses := map[string]struct{}{}
|
for _, ba := range backendAddresses {
|
||||||
for _, endpointSlice := range endpointSlices {
|
|
||||||
var port int32
|
|
||||||
for _, p := range endpointSlice.Ports {
|
|
||||||
if svcPort.Name == *p.Name {
|
|
||||||
port = *p.Port
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if port == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, endpoint := range endpointSlice.Endpoints {
|
|
||||||
if endpoint.Conditions.Ready == nil || !*endpoint.Conditions.Ready {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, address := range endpoint.Addresses {
|
|
||||||
if _, ok := addresses[address]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
addresses[address] = struct{}{}
|
|
||||||
lb.Servers = append(lb.Servers, dynamic.TCPServer{
|
lb.Servers = append(lb.Servers, dynamic.TCPServer{
|
||||||
// TODO determine whether the servers needs TLS, from the port?
|
Address: net.JoinHostPort(ba.Address, strconv.Itoa(int(ba.Port))),
|
||||||
Address: net.JoinHostPort(address, strconv.Itoa(int(port))),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lb, nil
|
return lb, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ package gateway
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -10,6 +11,7 @@ import (
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/traefik/traefik/v3/pkg/config/dynamic"
|
"github.com/traefik/traefik/v3/pkg/config/dynamic"
|
||||||
"github.com/traefik/traefik/v3/pkg/provider"
|
"github.com/traefik/traefik/v3/pkg/provider"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
ktypes "k8s.io/apimachinery/pkg/types"
|
ktypes "k8s.io/apimachinery/pkg/types"
|
||||||
"k8s.io/utils/ptr"
|
"k8s.io/utils/ptr"
|
||||||
|
@ -251,21 +253,49 @@ func (p *Provider) loadTLSService(route *gatev1alpha2.TLSRoute, backendRef gatev
|
||||||
portStr := strconv.FormatInt(int64(port), 10)
|
portStr := strconv.FormatInt(int64(port), 10)
|
||||||
serviceName = provider.Normalize(serviceName + "-" + portStr)
|
serviceName = provider.Normalize(serviceName + "-" + portStr)
|
||||||
|
|
||||||
lb, err := p.loadTCPServers(namespace, backendRef)
|
lb, errCondition := p.loadTLSServers(namespace, route, backendRef)
|
||||||
if err != nil {
|
if errCondition != nil {
|
||||||
return serviceName, nil, &metav1.Condition{
|
return serviceName, nil, errCondition
|
||||||
Type: string(gatev1.RouteConditionResolvedRefs),
|
|
||||||
Status: metav1.ConditionFalse,
|
|
||||||
ObservedGeneration: route.Generation,
|
|
||||||
LastTransitionTime: metav1.Now(),
|
|
||||||
Reason: string(gatev1.RouteReasonBackendNotFound),
|
|
||||||
Message: fmt.Sprintf("Cannot load TLSRoute BackendRef %s/%s/%s/%s: %s", group, kind, namespace, backendRef.Name, err),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return serviceName, &dynamic.TCPService{LoadBalancer: lb}, nil
|
return serviceName, &dynamic.TCPService{LoadBalancer: lb}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Provider) loadTLSServers(namespace string, route *gatev1alpha2.TLSRoute, backendRef gatev1.BackendRef) (*dynamic.TCPServersLoadBalancer, *metav1.Condition) {
|
||||||
|
backendAddresses, svcPort, err := p.getBackendAddresses(namespace, backendRef)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &metav1.Condition{
|
||||||
|
Type: string(gatev1.RouteConditionResolvedRefs),
|
||||||
|
Status: metav1.ConditionFalse,
|
||||||
|
ObservedGeneration: route.GetGeneration(),
|
||||||
|
LastTransitionTime: metav1.Now(),
|
||||||
|
Reason: string(gatev1.RouteReasonBackendNotFound),
|
||||||
|
Message: fmt.Sprintf("Cannot load TLSRoute BackendRef %s/%s: %s", namespace, backendRef.Name, err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if svcPort.Protocol != corev1.ProtocolTCP {
|
||||||
|
return nil, &metav1.Condition{
|
||||||
|
Type: string(gatev1.RouteConditionResolvedRefs),
|
||||||
|
Status: metav1.ConditionFalse,
|
||||||
|
ObservedGeneration: route.GetGeneration(),
|
||||||
|
LastTransitionTime: metav1.Now(),
|
||||||
|
Reason: string(gatev1.RouteReasonUnsupportedProtocol),
|
||||||
|
Message: fmt.Sprintf("Cannot load TLSRoute BackendRef %s/%s: only TCP protocol is supported", namespace, backendRef.Name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lb := &dynamic.TCPServersLoadBalancer{}
|
||||||
|
|
||||||
|
for _, ba := range backendAddresses {
|
||||||
|
lb.Servers = append(lb.Servers, dynamic.TCPServer{
|
||||||
|
// TODO determine whether the servers needs TLS, from the port?
|
||||||
|
Address: net.JoinHostPort(ba.Address, strconv.Itoa(int(ba.Port))),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return lb, nil
|
||||||
|
}
|
||||||
|
|
||||||
func hostSNIRule(hostnames []gatev1.Hostname) string {
|
func hostSNIRule(hostnames []gatev1.Hostname) string {
|
||||||
rules := make([]string, 0, len(hostnames))
|
rules := make([]string, 0, len(hostnames))
|
||||||
uniqHostnames := map[gatev1.Hostname]struct{}{}
|
uniqHostnames := map[gatev1.Hostname]struct{}{}
|
||||||
|
|
Loading…
Reference in a new issue