diff --git a/config/crd/bases/k8s.nginx.org_virtualserverroutes.yaml b/config/crd/bases/k8s.nginx.org_virtualserverroutes.yaml index d92b2cde2c..544718e877 100644 --- a/config/crd/bases/k8s.nginx.org_virtualserverroutes.yaml +++ b/config/crd/bases/k8s.nginx.org_virtualserverroutes.yaml @@ -1095,12 +1095,13 @@ spec: ConfigMap key. type: string service: - description: The name of a service. The service must belong - to the same namespace as the resource. If the service doesn’t - exist, NGINX will assume the service has zero endpoints and - return a 502 response for requests for this upstream. For - NGINX Plus only, services of type ExternalName are also supported - . + description: The name of a service. If the Service belongs to + a different namespace than the VirtualServer or VirtualServerRoute, + you need to include the namespace. For example, tea-namespace/tea. + If the service doesn’t exist, NGINX will assume the service + has zero endpoints and return a 502 response for requests + for this upstream. For NGINX Plus only, services of type ExternalName + are also supported in the same namespace. type: string sessionCookie: description: The SessionCookie field configures session persistence diff --git a/config/crd/bases/k8s.nginx.org_virtualservers.yaml b/config/crd/bases/k8s.nginx.org_virtualservers.yaml index a8bf0a26a7..ac5bdd456a 100644 --- a/config/crd/bases/k8s.nginx.org_virtualservers.yaml +++ b/config/crd/bases/k8s.nginx.org_virtualservers.yaml @@ -1284,12 +1284,13 @@ spec: ConfigMap key. type: string service: - description: The name of a service. The service must belong - to the same namespace as the resource. If the service doesn’t - exist, NGINX will assume the service has zero endpoints and - return a 502 response for requests for this upstream. For - NGINX Plus only, services of type ExternalName are also supported - . + description: The name of a service. If the Service belongs to + a different namespace than the VirtualServer or VirtualServerRoute, + you need to include the namespace. For example, tea-namespace/tea. + If the service doesn’t exist, NGINX will assume the service + has zero endpoints and return a 502 response for requests + for this upstream. For NGINX Plus only, services of type ExternalName + are also supported in the same namespace. type: string sessionCookie: description: The SessionCookie field configures session persistence diff --git a/deploy/crds.yaml b/deploy/crds.yaml index c39c2a0ab6..fd080f2d59 100644 --- a/deploy/crds.yaml +++ b/deploy/crds.yaml @@ -2133,12 +2133,13 @@ spec: ConfigMap key. type: string service: - description: The name of a service. The service must belong - to the same namespace as the resource. If the service doesn’t - exist, NGINX will assume the service has zero endpoints and - return a 502 response for requests for this upstream. For - NGINX Plus only, services of type ExternalName are also supported - . + description: The name of a service. If the Service belongs to + a different namespace than the VirtualServer or VirtualServerRoute, + you need to include the namespace. For example, tea-namespace/tea. + If the service doesn’t exist, NGINX will assume the service + has zero endpoints and return a 502 response for requests + for this upstream. For NGINX Plus only, services of type ExternalName + are also supported in the same namespace. type: string sessionCookie: description: The SessionCookie field configures session persistence @@ -3551,12 +3552,13 @@ spec: ConfigMap key. type: string service: - description: The name of a service. The service must belong - to the same namespace as the resource. If the service doesn’t - exist, NGINX will assume the service has zero endpoints and - return a 502 response for requests for this upstream. For - NGINX Plus only, services of type ExternalName are also supported - . + description: The name of a service. If the Service belongs to + a different namespace than the VirtualServer or VirtualServerRoute, + you need to include the namespace. For example, tea-namespace/tea. + If the service doesn’t exist, NGINX will assume the service + has zero endpoints and return a 502 response for requests + for this upstream. For NGINX Plus only, services of type ExternalName + are also supported in the same namespace. type: string sessionCookie: description: The SessionCookie field configures session persistence diff --git a/docs/crd/k8s.nginx.org_virtualserverroutes.md b/docs/crd/k8s.nginx.org_virtualserverroutes.md index bcf4c2a925..52426dacee 100644 --- a/docs/crd/k8s.nginx.org_virtualserverroutes.md +++ b/docs/crd/k8s.nginx.org_virtualserverroutes.md @@ -209,7 +209,7 @@ The `.spec` object supports the following fields: | `upstreams[].queue.timeout` | `string` | The timeout of the queue. A request cannot be queued for a period longer than the timeout. The default is 60s. | | `upstreams[].read-timeout` | `string` | The timeout for reading a response from an upstream server. The default is specified in the proxy-read-timeout ConfigMap key. | | `upstreams[].send-timeout` | `string` | The timeout for transmitting a request to an upstream server. The default is specified in the proxy-send-timeout ConfigMap key. | -| `upstreams[].service` | `string` | The name of a service. The service must belong to the same namespace as the resource. If the service doesn’t exist, NGINX will assume the service has zero endpoints and return a 502 response for requests for this upstream. For NGINX Plus only, services of type ExternalName are also supported . | +| `upstreams[].service` | `string` | The name of a service. If the Service belongs to a different namespace than the VirtualServer or VirtualServerRoute, you need to include the namespace. For example, tea-namespace/tea. If the service doesn’t exist, NGINX will assume the service has zero endpoints and return a 502 response for requests for this upstream. For NGINX Plus only, services of type ExternalName are also supported in the same namespace. | | `upstreams[].sessionCookie` | `object` | The SessionCookie field configures session persistence which allows requests from the same client to be passed to the same upstream server. The information about the designated upstream server is passed in a session cookie generated by NGINX Plus. | | `upstreams[].sessionCookie.domain` | `string` | The domain for which the cookie is set. | | `upstreams[].sessionCookie.enable` | `boolean` | Enables session persistence with a session cookie for an upstream server. The default is false. | diff --git a/docs/crd/k8s.nginx.org_virtualservers.md b/docs/crd/k8s.nginx.org_virtualservers.md index cc0268c773..280e1bb8b2 100644 --- a/docs/crd/k8s.nginx.org_virtualservers.md +++ b/docs/crd/k8s.nginx.org_virtualservers.md @@ -244,7 +244,7 @@ The `.spec` object supports the following fields: | `upstreams[].queue.timeout` | `string` | The timeout of the queue. A request cannot be queued for a period longer than the timeout. The default is 60s. | | `upstreams[].read-timeout` | `string` | The timeout for reading a response from an upstream server. The default is specified in the proxy-read-timeout ConfigMap key. | | `upstreams[].send-timeout` | `string` | The timeout for transmitting a request to an upstream server. The default is specified in the proxy-send-timeout ConfigMap key. | -| `upstreams[].service` | `string` | The name of a service. The service must belong to the same namespace as the resource. If the service doesn’t exist, NGINX will assume the service has zero endpoints and return a 502 response for requests for this upstream. For NGINX Plus only, services of type ExternalName are also supported . | +| `upstreams[].service` | `string` | The name of a service. If the Service belongs to a different namespace than the VirtualServer or VirtualServerRoute, you need to include the namespace. For example, tea-namespace/tea. If the service doesn’t exist, NGINX will assume the service has zero endpoints and return a 502 response for requests for this upstream. For NGINX Plus only, services of type ExternalName are also supported in the same namespace. | | `upstreams[].sessionCookie` | `object` | The SessionCookie field configures session persistence which allows requests from the same client to be passed to the same upstream server. The information about the designated upstream server is passed in a session cookie generated by NGINX Plus. | | `upstreams[].sessionCookie.domain` | `string` | The domain for which the cookie is set. | | `upstreams[].sessionCookie.enable` | `boolean` | Enables session persistence with a session cookie for an upstream server. The default is false. | diff --git a/examples/custom-resources/foreign-namespace-upstreams/README.md b/examples/custom-resources/foreign-namespace-upstreams/README.md new file mode 100644 index 0000000000..d46b59e2bd --- /dev/null +++ b/examples/custom-resources/foreign-namespace-upstreams/README.md @@ -0,0 +1,109 @@ +# Upstreams in foreign namespaces + +In this example we use the [VirtualServer and +VirtualServerRoute](https://docs.nginx.com/nginx-ingress-controller/configuration/virtualserver-and-virtualserverroute-resources/) +resources to configure load balancing for the modified cafe application from the [Basic +Configuration](../basic-configuration/) example. We have put the load balancing configuration as well as the deployments +and services into multiple namespaces. Instead of one namespace, we now use three: `tea`, `coffee`, and `cafe`. + +- In the tea namespace, we create the tea deployment and service. +- In the coffee namespace, we create the coffee deployment and service. +- In the cafe namespace, we create the cafe secret with the TLS certificate and key and the load-balancing configuration + for the cafe application. That configuration references the coffee and tea configurations. + +**Note:** When using upstreams in foreign namespaces, ensure that the NGINX Ingress Controller is configured to watch all the relevant namespaces. If you are using the `-watch-namespace` flag, make sure to include all namespaces that contain services referenced by your VirtualServer resources (in this case: `tea`, `coffee`, and `cafe`). + +## Prerequisites + +1. Follow the [installation](https://docs.nginx.com/nginx-ingress-controller/installation/installation-with-manifests/) + instructions to deploy the Ingress Controller with custom resources enabled. +1. Save the public IP address of the Ingress Controller into a shell variable: + + ```console + IC_IP=XXX.YYY.ZZZ.III + ``` + +1. Save the HTTPS port of the Ingress Controller into a shell variable: + + ```console + IC_HTTPS_PORT= + ``` + +## Step 1 - Create Namespaces + +Create the required tea, coffee, and cafe namespaces: + +```console +kubectl create -f namespaces.yaml +``` + +## Step 2 - Deploy the Cafe Application + +1. Create the tea deployment and service in the tea namespace: + + ```console + kubectl create -f tea.yaml + ``` + +1. Create the coffee deployment and service in the coffee namespace: + + ```console + kubectl create -f coffee.yaml + ``` + +## Step 3 - Configure Load Balancing and TLS Termination + +1. Create the secret with the TLS certificate and key in the cafe namespace: + + ```console + kubectl create -f cafe-secret.yaml + ``` + +1. Create the VirtualServer resource for the cafe app in the cafe namespace: + + ```console + kubectl create -f cafe-virtual-server.yaml + ``` + +## Step 4 - Test the Configuration + +1. Check that the configuration has been successfully applied by inspecting the events of the VirtualServer: + + ```console + kubectl describe virtualserver cafe -n cafe + ``` + + ```text + . . . + Events: + Type Reason Age From Message + ---- ------ ---- ---- ------- + Normal AddedOrUpdated 1m nginx-ingress-controller Configuration for cafe/cafe was added or updated + ``` + +1. Access the application using curl. We'll use curl's `--insecure` option to turn off certificate verification of our + self-signed certificate and `--resolve` option to set the IP address and HTTPS port of the Ingress Controller to the + domain name of the cafe application: + + To get coffee: + + ```console + curl --resolve cafe.example.com:$IC_HTTPS_PORT:$IC_IP https://cafe.example.com:$IC_HTTPS_PORT/coffee --insecure + ``` + + ```text + Server address: 10.16.1.193:80 + Server name: coffee-7dbb5795f6-mltpf + ... + ``` + + If you prefer tea: + + ```console + curl --resolve cafe.example.com:$IC_HTTPS_PORT:$IC_IP https://cafe.example.com:$IC_HTTPS_PORT/tea --insecure + ``` + + ```text + Server address: 10.16.0.157:80 + Server name: tea-7d57856c44-674b8 + ... diff --git a/examples/custom-resources/foreign-namespace-upstreams/cafe-secret.yaml b/examples/custom-resources/foreign-namespace-upstreams/cafe-secret.yaml new file mode 120000 index 0000000000..6d8cd13e70 --- /dev/null +++ b/examples/custom-resources/foreign-namespace-upstreams/cafe-secret.yaml @@ -0,0 +1 @@ +../../common-secrets/cafe-secret-cafe-ns.example.com.yaml \ No newline at end of file diff --git a/examples/custom-resources/foreign-namespace-upstreams/cafe-virtual-server.yaml b/examples/custom-resources/foreign-namespace-upstreams/cafe-virtual-server.yaml new file mode 100644 index 0000000000..48c1d76117 --- /dev/null +++ b/examples/custom-resources/foreign-namespace-upstreams/cafe-virtual-server.yaml @@ -0,0 +1,23 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: cafe + namespace: cafe +spec: + host: cafe.example.com + tls: + secret: cafe-secret + routes: + - path: /coffee + action: + pass: coffee + - path: /tea + action: + pass: tea + upstreams: + - name: coffee + service: coffee/coffee-svc + port: 80 + - name: tea + service: tea/tea-svc + port: 80 diff --git a/examples/custom-resources/foreign-namespace-upstreams/coffee.yaml b/examples/custom-resources/foreign-namespace-upstreams/coffee.yaml new file mode 100644 index 0000000000..df52a95049 --- /dev/null +++ b/examples/custom-resources/foreign-namespace-upstreams/coffee.yaml @@ -0,0 +1,70 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: coffee + namespace: coffee +spec: + replicas: 3 + selector: + matchLabels: + app: coffee + template: + metadata: + labels: + app: coffee + spec: + containers: + - name: coffee + image: nginxdemos/nginx-hello:plain-text + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: coffee-svc + namespace: coffee +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: coffee + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: coffee + namespace: coffee2 +spec: + replicas: 3 + selector: + matchLabels: + app: coffee + template: + metadata: + labels: + app: coffee + spec: + containers: + - name: coffee + image: nginxdemos/nginx-hello:plain-text + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: coffee-svc + namespace: coffee2 +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: coffee diff --git a/examples/custom-resources/foreign-namespace-upstreams/namespaces.yaml b/examples/custom-resources/foreign-namespace-upstreams/namespaces.yaml new file mode 100644 index 0000000000..6c47c83f34 --- /dev/null +++ b/examples/custom-resources/foreign-namespace-upstreams/namespaces.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: cafe +--- +apiVersion: v1 +kind: Namespace +metadata: + name: tea +--- +apiVersion: v1 +kind: Namespace +metadata: + name: coffee diff --git a/examples/custom-resources/foreign-namespace-upstreams/tea.yaml b/examples/custom-resources/foreign-namespace-upstreams/tea.yaml new file mode 100644 index 0000000000..615e2fd436 --- /dev/null +++ b/examples/custom-resources/foreign-namespace-upstreams/tea.yaml @@ -0,0 +1,34 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tea + namespace: tea +spec: + replicas: 1 + selector: + matchLabels: + app: tea + template: + metadata: + labels: + app: tea + spec: + containers: + - name: tea + image: nginxdemos/nginx-hello:plain-text + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: tea-svc + namespace: tea +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: tea diff --git a/internal/configs/virtualserver.go b/internal/configs/virtualserver.go index eedc6a48e7..8dc42d007a 100644 --- a/internal/configs/virtualserver.go +++ b/internal/configs/virtualserver.go @@ -145,6 +145,17 @@ func GenerateEndpointsKey( return fmt.Sprintf("%s/%s:%d", serviceNamespace, serviceName, port) } +// ParseServiceReference returns the namespace and name from a service reference. +func ParseServiceReference(serviceRef, defaultNamespace string) (namespace, serviceName string) { + if strings.Contains(serviceRef, "/") { + parts := strings.Split(serviceRef, "/") + if len(parts) == 2 { + return parts[0], parts[1] + } + } + return defaultNamespace, serviceRef +} + type upstreamNamer struct { prefix string namespace string @@ -353,7 +364,8 @@ func (vsc *virtualServerConfigurator) generateEndpointsForUpstream( upstream conf_v1.Upstream, virtualServerEx *VirtualServerEx, ) []string { - endpointsKey := GenerateEndpointsKey(namespace, upstream.Service, upstream.Subselector, upstream.Port) + serviceNamespace, serviceName := ParseServiceReference(upstream.Service, namespace) + endpointsKey := GenerateEndpointsKey(serviceNamespace, serviceName, upstream.Subselector, upstream.Port) externalNameSvcKey := GenerateExternalNameSvcKey(namespace, upstream.Service) endpoints := virtualServerEx.Endpoints[endpointsKey] if !vsc.isPlus && len(endpoints) == 0 { @@ -659,7 +671,8 @@ func (vsc *virtualServerConfigurator) GenerateVirtualServerConfig( upstreamName := virtualServerUpstreamNamer.GetNameForUpstreamFromAction(r.Action) upstream := crUpstreams[upstreamName] - proxySSLName := generateProxySSLName(upstream.Service, vsEx.VirtualServer.Namespace) + serviceNamespace, serviceName := ParseServiceReference(upstream.Service, vsEx.VirtualServer.Namespace) + proxySSLName := generateProxySSLName(serviceName, serviceNamespace) loc, returnLoc := generateLocation(r.Path, upstreamName, upstream, r.Action, vsc.cfgParams, errorPages, false, proxySSLName, r.Path, vsLocSnippets, vsc.enableSnippets, len(returnLocations), isVSR, "", "", vsc.warnings) @@ -812,7 +825,8 @@ func (vsc *virtualServerConfigurator) GenerateVirtualServerConfig( } else { upstreamName := upstreamNamer.GetNameForUpstreamFromAction(r.Action) upstream := crUpstreams[upstreamName] - proxySSLName := generateProxySSLName(upstream.Service, vsr.Namespace) + serviceNamespace, serviceName := ParseServiceReference(upstream.Service, vsr.Namespace) + proxySSLName := generateProxySSLName(serviceName, serviceNamespace) loc, returnLoc := generateLocation(r.Path, upstreamName, upstream, r.Action, vsc.cfgParams, errorPages, false, proxySSLName, r.Path, locSnippets, vsc.enableSnippets, len(returnLocations), isVSR, vsr.Name, vsr.Namespace, vsc.warnings) @@ -2535,8 +2549,10 @@ func generateLocation(path string, upstreamName string, upstream conf_v1.Upstrea checkGrpcErrorPageCodes(errorPages, isGRPC(upstream.Type), upstream.Name, vscWarnings) + _, serviceName := ParseServiceReference(upstream.Service, "") + return generateLocationForProxying(path, upstreamName, upstream, cfgParams, errorPages.pages, internal, - errorPages.index, proxySSLName, action.Proxy, originalPath, locationSnippets, isVSR, vsrName, vsrNamespace), nil + errorPages.index, proxySSLName, action.Proxy, originalPath, locationSnippets, isVSR, vsrName, vsrNamespace, serviceName), nil } func generateProxySetHeaders(proxy *conf_v1.ActionProxy) []version2.Header { @@ -2621,7 +2637,7 @@ func generateProxyAddHeaders(proxy *conf_v1.ActionProxy) []version2.AddHeader { func generateLocationForProxying(path string, upstreamName string, upstream conf_v1.Upstream, cfgParams *ConfigParams, errorPages []conf_v1.ErrorPage, internal bool, errPageIndex int, - proxySSLName string, proxy *conf_v1.ActionProxy, originalPath string, locationSnippets []string, isVSR bool, vsrName string, vsrNamespace string, + proxySSLName string, proxy *conf_v1.ActionProxy, originalPath string, locationSnippets []string, isVSR bool, vsrName string, vsrNamespace string, serviceName string, ) version2.Location { return version2.Location{ Path: generatePath(path), @@ -2652,7 +2668,7 @@ func generateLocationForProxying(path string, upstreamName string, upstream conf HasKeepalive: upstreamHasKeepalive(upstream, cfgParams), ErrorPages: generateErrorPages(errPageIndex, errorPages), ProxySSLName: proxySSLName, - ServiceName: upstream.Service, + ServiceName: serviceName, IsVSR: isVSR, VSRName: vsrName, VSRNamespace: vsrNamespace, @@ -2823,7 +2839,8 @@ func generateSplits( path := fmt.Sprintf("/%vsplits_%d_split_%d", internalLocationPrefix, scIndex, i) upstreamName := upstreamNamer.GetNameForUpstreamFromAction(s.Action) upstream := crUpstreams[upstreamName] - proxySSLName := generateProxySSLName(upstream.Service, upstreamNamer.namespace) + serviceNamespace, serviceName := ParseServiceReference(upstream.Service, upstreamNamer.namespace) + proxySSLName := generateProxySSLName(serviceName, serviceNamespace) newRetLocIndex := retLocIndex + len(returnLocations) loc, returnLoc := generateLocation(path, upstreamName, upstream, s.Action, cfgParams, errorPages, true, proxySSLName, originalPath, locSnippets, enableSnippets, newRetLocIndex, isVSR, vsrName, vsrNamespace, vscWarnings) @@ -3056,7 +3073,8 @@ func generateMatchesConfig(route conf_v1.Route, upstreamNamer *upstreamNamer, cr path := fmt.Sprintf("/%vmatches_%d_match_%d", internalLocationPrefix, index, i) upstreamName := upstreamNamer.GetNameForUpstreamFromAction(m.Action) upstream := crUpstreams[upstreamName] - proxySSLName := generateProxySSLName(upstream.Service, upstreamNamer.namespace) + serviceNamespace, serviceName := ParseServiceReference(upstream.Service, upstreamNamer.namespace) + proxySSLName := generateProxySSLName(serviceName, serviceNamespace) newRetLocIndex := retLocIndex + len(returnLocations) loc, returnLoc := generateLocation(path, upstreamName, upstream, m.Action, cfgParams, errorPages, true, proxySSLName, route.Path, locSnippets, enableSnippets, newRetLocIndex, isVSR, vsrName, vsrNamespace, vscWarnings) @@ -3099,7 +3117,8 @@ func generateMatchesConfig(route conf_v1.Route, upstreamNamer *upstreamNamer, cr path := fmt.Sprintf("/%vmatches_%d_default", internalLocationPrefix, index) upstreamName := upstreamNamer.GetNameForUpstreamFromAction(route.Action) upstream := crUpstreams[upstreamName] - proxySSLName := generateProxySSLName(upstream.Service, upstreamNamer.namespace) + serviceNamespace, serviceName := ParseServiceReference(upstream.Service, upstreamNamer.namespace) + proxySSLName := generateProxySSLName(serviceName, serviceNamespace) newRetLocIndex := retLocIndex + len(returnLocations) loc, returnLoc := generateLocation(path, upstreamName, upstream, route.Action, cfgParams, errorPages, true, proxySSLName, route.Path, locSnippets, enableSnippets, newRetLocIndex, isVSR, vsrName, vsrNamespace, vscWarnings) @@ -3288,9 +3307,9 @@ func createUpstreamsForPlus( } upstreamName := upstreamNamer.GetNameForUpstream(u.Name) - upstreamNamespace := virtualServerEx.VirtualServer.Namespace + upstreamNamespace, upstreamServiceName := ParseServiceReference(u.Service, virtualServerEx.VirtualServer.Namespace) - endpointsKey := GenerateEndpointsKey(upstreamNamespace, u.Service, u.Subselector, u.Port) + endpointsKey := GenerateEndpointsKey(upstreamNamespace, upstreamServiceName, u.Subselector, u.Port) endpoints := virtualServerEx.Endpoints[endpointsKey] backupEndpoints := []string{} @@ -3312,15 +3331,15 @@ func createUpstreamsForPlus( } upstreamName := upstreamNamer.GetNameForUpstream(u.Name) - upstreamNamespace := vsr.Namespace + serviceNamespace, serviceName := ParseServiceReference(u.Service, vsr.Namespace) - endpointsKey := GenerateEndpointsKey(upstreamNamespace, u.Service, u.Subselector, u.Port) + endpointsKey := GenerateEndpointsKey(serviceNamespace, serviceName, u.Subselector, u.Port) endpoints := virtualServerEx.Endpoints[endpointsKey] // BackupService backupEndpoints := []string{} if u.Backup != "" { - backupEndpointsKey := GenerateEndpointsKey(upstreamNamespace, u.Backup, u.Subselector, *u.BackupPort) + backupEndpointsKey := GenerateEndpointsKey(vsr.Namespace, u.Backup, u.Subselector, *u.BackupPort) backupEndpoints = virtualServerEx.Endpoints[backupEndpointsKey] } ups := vsc.generateUpstream(vsr, upstreamName, u, isExternalNameSvc, endpoints, backupEndpoints) diff --git a/internal/configs/virtualserver_test.go b/internal/configs/virtualserver_test.go index d3d0f6702d..bf5f8d78d1 100644 --- a/internal/configs/virtualserver_test.go +++ b/internal/configs/virtualserver_test.go @@ -100,6 +100,13 @@ func TestGenerateEndpointsKey(t *testing.T) { subselector: nil, expected: "default/backup-svc:8090", }, + { + serviceNamespace: "tea", + serviceName: "tea-svc", + port: 8080, + subselector: nil, + expected: "tea/tea-svc:8080", + }, } for _, test := range tests { @@ -110,6 +117,38 @@ func TestGenerateEndpointsKey(t *testing.T) { } } +func TestParseServiceReference(t *testing.T) { + t.Parallel() + + tests := []struct { + serviceRef string + defaultNamespace string + expectedNS string + expectedSvc string + }{ + { + serviceRef: "coffee-svc", + defaultNamespace: "coffee", + expectedNS: "coffee", + expectedSvc: "coffee-svc", + }, + { + serviceRef: "tea/tea-svc", + defaultNamespace: "cafe", + expectedNS: "tea", + expectedSvc: "tea-svc", + }, + } + + for _, test := range tests { + namespace, serviceName := ParseServiceReference(test.serviceRef, test.defaultNamespace) + if namespace != test.expectedNS || serviceName != test.expectedSvc { + t.Errorf("parseServiceReference(%q, %q) returned (%q, %q) but expected (%q, %q)", + test.serviceRef, test.defaultNamespace, namespace, serviceName, test.expectedNS, test.expectedSvc) + } + } +} + func TestUpstreamNamerForVirtualServer(t *testing.T) { t.Parallel() virtualServer := conf_v1.VirtualServer{ @@ -16216,7 +16255,7 @@ func TestGenerateLocationForProxying(t *testing.T) { VSRNamespace: "", } - result := generateLocationForProxying(path, upstreamName, conf_v1.Upstream{}, &cfgParams, nil, false, 0, "", nil, "", vsLocSnippets, false, "", "") + result := generateLocationForProxying(path, upstreamName, conf_v1.Upstream{}, &cfgParams, nil, false, 0, "", nil, "", vsLocSnippets, false, "", "", "") if diff := cmp.Diff(expected, result); diff != "" { t.Errorf("generateLocationForProxying() mismatch (-want +got):\n%s", diff) } @@ -16263,7 +16302,7 @@ func TestGenerateLocationForGrpcProxying(t *testing.T) { GRPCPass: "grpc://test-upstream", } - result := generateLocationForProxying(path, upstreamName, conf_v1.Upstream{Type: "grpc"}, &cfgParams, nil, false, 0, "", nil, "", vsLocSnippets, false, "", "") + result := generateLocationForProxying(path, upstreamName, conf_v1.Upstream{Type: "grpc"}, &cfgParams, nil, false, 0, "", nil, "", vsLocSnippets, false, "", "", "") if diff := cmp.Diff(expected, result); diff != "" { t.Errorf("generateLocationForForGrpcProxying() mismatch (-want +got):\n%s", diff) } @@ -22708,3 +22747,220 @@ func TestRFC1123ToSnake(t *testing.T) { }) } } + +func TestGenerateVirtualServerConfigWithForeignNamespaceService(t *testing.T) { + t.Parallel() + virtualServerEx := VirtualServerEx{ + VirtualServer: &conf_v1.VirtualServer{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "cafe", + Namespace: "default", + }, + Spec: conf_v1.VirtualServerSpec{ + Host: "cafe.example.com", + Upstreams: []conf_v1.Upstream{ + { + Name: "coffee", + Service: "coffee/coffee-svc", + Port: 80, + }, + }, + Routes: []conf_v1.Route{ + { + Path: "/coffee", + Action: &conf_v1.Action{ + Pass: "coffee", + }, + }, + }, + }, + }, + Endpoints: map[string][]string{ + "coffee/coffee-svc:80": { + "10.0.0.20:80", + }, + }, + VirtualServerRoutes: []*conf_v1.VirtualServerRoute{}, + } + + vsc := newVirtualServerConfigurator(&baseCfgParams, false, false, &StaticConfigParams{}, false, nil) + result, warnings := vsc.GenerateVirtualServerConfig(&virtualServerEx, nil, nil) + if len(warnings) != 0 { + t.Errorf("GenerateVirtualServerConfig returned warnings: %v", warnings) + } + + expected := version2.VirtualServerConfig{ + Upstreams: []version2.Upstream{ + { + UpstreamLabels: version2.UpstreamLabels{ + Service: "coffee/coffee-svc", + ResourceType: "virtualserver", + ResourceName: "cafe", + ResourceNamespace: "default", + }, + Name: "vs_default_cafe_coffee", + Servers: []version2.UpstreamServer{ + { + Address: "10.0.0.20:80", + }, + }, + Keepalive: 16, + }, + }, + HTTPSnippets: []string{}, + LimitReqZones: []version2.LimitReqZone{}, + Server: version2.Server{ + ServerName: "cafe.example.com", + StatusZone: "cafe.example.com", + VSNamespace: "default", + VSName: "cafe", + ProxyProtocol: true, + ServerTokens: "off", + SetRealIPFrom: []string{"0.0.0.0/0"}, + RealIPHeader: "X-Real-IP", + RealIPRecursive: true, + Snippets: []string{"# server snippet"}, + Locations: []version2.Location{ + { + Path: "/coffee", + ProxyPass: "http://vs_default_cafe_coffee", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "0s", + ProxyPassRequestHeaders: true, + ProxySetHeaders: []version2.Header{ + { + Name: "Host", + Value: "$host", + }, + }, + HasKeepalive: true, + ProxySSLName: "coffee-svc.coffee.svc", + ServiceName: "coffee-svc", + }, + }, + }, + SpiffeClientCerts: false, + } + + if !cmp.Equal(expected, result) { + t.Error(cmp.Diff(expected, result)) + } +} + +func TestGenerateVirtualServerConfigWithForeignNamespaceServiceInVSR(t *testing.T) { + t.Parallel() + virtualServerEx := VirtualServerEx{ + VirtualServer: &conf_v1.VirtualServer{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "cafe", + Namespace: "default", + }, + Spec: conf_v1.VirtualServerSpec{ + Host: "cafe.example.com", + Routes: []conf_v1.Route{ + { + Path: "/tea", + Route: "default/tea", + }, + }, + }, + }, + Endpoints: map[string][]string{ + "tea/tea-svc:80": { + "10.0.0.30:80", + }, + }, + VirtualServerRoutes: []*conf_v1.VirtualServerRoute{ + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "tea", + Namespace: "default", + }, + Spec: conf_v1.VirtualServerRouteSpec{ + Host: "cafe.example.com", + Upstreams: []conf_v1.Upstream{ + { + Name: "tea", + Service: "tea/tea-svc", + Port: 80, + }, + }, + Subroutes: []conf_v1.Route{ + { + Path: "/tea", + Action: &conf_v1.Action{ + Pass: "tea", + }, + }, + }, + }, + }, + }, + } + + vsc := newVirtualServerConfigurator(&baseCfgParams, false, false, &StaticConfigParams{}, false, nil) + result, warnings := vsc.GenerateVirtualServerConfig(&virtualServerEx, nil, nil) + if len(warnings) != 0 { + t.Errorf("GenerateVirtualServerConfig returned warnings: %v", warnings) + } + + expected := version2.VirtualServerConfig{ + Upstreams: []version2.Upstream{ + { + UpstreamLabels: version2.UpstreamLabels{ + Service: "tea/tea-svc", + ResourceType: "virtualserverroute", + ResourceName: "tea", + ResourceNamespace: "default", + }, + Name: "vs_default_cafe_vsr_default_tea_tea", + Servers: []version2.UpstreamServer{ + { + Address: "10.0.0.30:80", + }, + }, + Keepalive: 16, + }, + }, + HTTPSnippets: []string{}, + LimitReqZones: []version2.LimitReqZone{}, + Server: version2.Server{ + ServerName: "cafe.example.com", + StatusZone: "cafe.example.com", + VSNamespace: "default", + VSName: "cafe", + ProxyProtocol: true, + ServerTokens: "off", + SetRealIPFrom: []string{"0.0.0.0/0"}, + RealIPHeader: "X-Real-IP", + RealIPRecursive: true, + Snippets: []string{"# server snippet"}, + Locations: []version2.Location{ + { + Path: "/tea", + ProxyPass: "http://vs_default_cafe_vsr_default_tea_tea", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "0s", + ProxyPassRequestHeaders: true, + ProxySetHeaders: []version2.Header{ + { + Name: "Host", + Value: "$host", + }, + }, + HasKeepalive: true, + ProxySSLName: "tea-svc.tea.svc", + ServiceName: "tea-svc", + IsVSR: true, + VSRName: "tea", + VSRNamespace: "default", + }, + }, + }, + SpiffeClientCerts: false, + } + + if !cmp.Equal(expected, result) { + t.Error(cmp.Diff(expected, result)) + } +} diff --git a/internal/k8s/controller.go b/internal/k8s/controller.go index c3ac934671..58ad41d198 100644 --- a/internal/k8s/controller.go +++ b/internal/k8s/controller.go @@ -2467,11 +2467,12 @@ func (lbc *LoadBalancerController) createVirtualServerEx(virtualServer *conf_v1. } for _, u := range virtualServer.Spec.Upstreams { - endpointsKey := configs.GenerateEndpointsKey(virtualServer.Namespace, u.Service, u.Subselector, u.Port) + serviceNamespace, serviceName := configs.ParseServiceReference(u.Service, virtualServer.Namespace) + endpointsKey := configs.GenerateEndpointsKey(serviceNamespace, serviceName, u.Subselector, u.Port) var endps []string if u.UseClusterIP { - s, err := lbc.getServiceForUpstream(virtualServer.Namespace, u.Service, u.Port) + s, err := lbc.getServiceForUpstream(serviceNamespace, serviceName, u.Port) if err != nil { nl.Warnf(lbc.Logger, "Error getting Service for Upstream %v: %v", u.Service, err) } else { @@ -2483,13 +2484,13 @@ func (lbc *LoadBalancerController) createVirtualServerEx(virtualServer *conf_v1. var err error if len(u.Subselector) > 0 { - podEndps, err = lbc.getEndpointsForSubselector(virtualServer.Namespace, u) + podEndps, err = lbc.getEndpointsForSubselector(serviceNamespace, serviceName, u.Port, u.Subselector) } else { var external bool - podEndps, external, err = lbc.getEndpointsForUpstream(virtualServer.Namespace, u.Service, u.Port) + podEndps, external, err = lbc.getEndpointsForUpstream(serviceNamespace, serviceName, u.Port) if err == nil && external && lbc.isNginxPlus { - externalNameSvcs[configs.GenerateExternalNameSvcKey(virtualServer.Namespace, u.Service)] = true + externalNameSvcs[configs.GenerateExternalNameSvcKey(serviceNamespace, serviceName)] = true } } @@ -2614,11 +2615,12 @@ func (lbc *LoadBalancerController) createVirtualServerEx(virtualServer *conf_v1. } for _, u := range vsr.Spec.Upstreams { - endpointsKey := configs.GenerateEndpointsKey(vsr.Namespace, u.Service, u.Subselector, u.Port) + serviceNamespace, serviceName := configs.ParseServiceReference(u.Service, vsr.Namespace) + endpointsKey := configs.GenerateEndpointsKey(serviceNamespace, serviceName, u.Subselector, u.Port) var endps []string if u.UseClusterIP { - s, err := lbc.getServiceForUpstream(vsr.Namespace, u.Service, u.Port) + s, err := lbc.getServiceForUpstream(serviceNamespace, serviceName, u.Port) if err != nil { nl.Warnf(lbc.Logger, "Error getting Service for Upstream %v: %v", u.Service, err) } else { @@ -2629,13 +2631,13 @@ func (lbc *LoadBalancerController) createVirtualServerEx(virtualServer *conf_v1. var podEndps []podEndpoint var err error if len(u.Subselector) > 0 { - podEndps, err = lbc.getEndpointsForSubselector(vsr.Namespace, u) + podEndps, err = lbc.getEndpointsForSubselector(serviceNamespace, serviceName, u.Port, u.Subselector) } else { var external bool - podEndps, external, err = lbc.getEndpointsForUpstream(vsr.Namespace, u.Service, u.Port) + podEndps, external, err = lbc.getEndpointsForUpstream(serviceNamespace, serviceName, u.Port) if err == nil && external && lbc.isNginxPlus { - externalNameSvcs[configs.GenerateExternalNameSvcKey(vsr.Namespace, u.Service)] = true + externalNameSvcs[configs.GenerateExternalNameSvcKey(serviceNamespace, serviceName)] = true } } if err != nil { @@ -2967,31 +2969,31 @@ func (lbc *LoadBalancerController) getEndpointsForUpstream(namespace string, ups return endps, isExternal, err } -func (lbc *LoadBalancerController) getEndpointsForSubselector(namespace string, upstream conf_v1.Upstream) (endps []podEndpoint, err error) { - svc, err := lbc.getServiceForUpstream(namespace, upstream.Service, upstream.Port) +func (lbc *LoadBalancerController) getEndpointsForSubselector(namespace string, serviceName string, servicePort uint16, subselector map[string]string) (endps []podEndpoint, err error) { + svc, err := lbc.getServiceForUpstream(namespace, serviceName, servicePort) if err != nil { - return nil, fmt.Errorf("error getting service %v: %w", upstream.Service, err) + return nil, fmt.Errorf("error getting service %v: %w", serviceName, err) } var targetPort int32 for _, port := range svc.Spec.Ports { - if port.Port == int32(upstream.Port) { + if port.Port == int32(servicePort) { targetPort, err = lbc.getTargetPort(port, svc) if err != nil { - return nil, fmt.Errorf("error determining target port for port %v in service %v: %w", upstream.Port, svc.Name, err) + return nil, fmt.Errorf("error determining target port for port %v in service %v: %w", servicePort, svc.Name, err) } break } } if targetPort == 0 { - return nil, fmt.Errorf("no port %v in service %s", upstream.Port, svc.Name) + return nil, fmt.Errorf("no port %v in service %s", servicePort, svc.Name) } - endps, err = lbc.getEndpointsForServiceWithSubselector(targetPort, upstream.Subselector, svc) + endps, err = lbc.getEndpointsForServiceWithSubselector(targetPort, subselector, svc) if err != nil { - return nil, fmt.Errorf("error retrieving endpoints for the service %v: %w", upstream.Service, err) + return nil, fmt.Errorf("error retrieving endpoints for the service %v: %w", serviceName, err) } return endps, err diff --git a/pkg/apis/configuration/v1/types.go b/pkg/apis/configuration/v1/types.go index 5c0c387196..1f3dd4a6b7 100644 --- a/pkg/apis/configuration/v1/types.go +++ b/pkg/apis/configuration/v1/types.go @@ -119,7 +119,7 @@ type PolicyReference struct { type Upstream struct { // The name of the upstream. Must be a valid DNS label as defined in RFC 1035. For example, hello and upstream-123 are valid. The name must be unique among all upstreams of the resource. Name string `json:"name"` - // The name of a service. The service must belong to the same namespace as the resource. If the service doesn’t exist, NGINX will assume the service has zero endpoints and return a 502 response for requests for this upstream. For NGINX Plus only, services of type ExternalName are also supported . + // The name of a service. If the Service belongs to a different namespace than the VirtualServer or VirtualServerRoute, you need to include the namespace. For example, tea-namespace/tea. If the service doesn’t exist, NGINX will assume the service has zero endpoints and return a 502 response for requests for this upstream. For NGINX Plus only, services of type ExternalName are also supported in the same namespace. Service string `json:"service"` // Selects the pods within the service using label keys and values. By default, all pods of the service are selected. Note: the specified labels are expected to be present in the pods when they are created. If the pod labels are updated, NGINX Ingress Controller will not see that change until the number of the pods is changed. Subselector map[string]string `json:"subselector"` diff --git a/pkg/apis/configuration/validation/virtualserver.go b/pkg/apis/configuration/validation/virtualserver.go index 136966ec5d..e5edfff294 100644 --- a/pkg/apis/configuration/validation/virtualserver.go +++ b/pkg/apis/configuration/validation/virtualserver.go @@ -621,7 +621,7 @@ func (vsv *VirtualServerValidator) validateUpstreams(upstreams []v1.Upstream, fi allErrs = append(allErrs, validateLabels(u.Subselector, idxPath.Child("subselector"))...) } - allErrs = append(allErrs, validateServiceName(u.Service, idxPath.Child("service"))...) + allErrs = append(allErrs, validateVirtualServerServiceName(u.Service, idxPath.Child("service"))...) allErrs = append(allErrs, validateTime(u.ProxyConnectTimeout, idxPath.Child("connect-timeout"))...) allErrs = append(allErrs, validateTime(u.ProxyReadTimeout, idxPath.Child("read-timeout"))...) allErrs = append(allErrs, validateTime(u.ProxySendTimeout, idxPath.Child("send-timeout"))...) @@ -735,6 +735,30 @@ func validateServiceName(name string, fieldPath *field.Path) field.ErrorList { return validateDNS1035Label(name, fieldPath) } +// validateVirtualServerServiceName checks if a namespaced service name is valid for VirtualServer upstreams. +func validateVirtualServerServiceName(name string, fieldPath *field.Path) field.ErrorList { + if strings.Contains(name, "/") { + parts := strings.Split(name, "/") + if len(parts) != 2 { + return field.ErrorList{field.Invalid(fieldPath, name, " service reference must be in the format namespace/service-name")} + } + + namespaceErrs := validateDNS1123Label(parts[0], fieldPath) + if len(namespaceErrs) > 0 { + return field.ErrorList{field.Invalid(fieldPath, name, "invalid namespace in service reference")} + } + + serviceErrs := validateServiceName(parts[1], fieldPath) + if len(serviceErrs) > 0 { + return field.ErrorList{field.Invalid(fieldPath, name, "invalid service name in service reference")} + } + + return field.ErrorList{} + } + + return validateServiceName(name, fieldPath) +} + func validateDNS1035Label(name string, fieldPath *field.Path) field.ErrorList { if name == "" { return field.ErrorList{field.Required(fieldPath, "")} @@ -747,6 +771,18 @@ func validateDNS1035Label(name string, fieldPath *field.Path) field.ErrorList { return allErrs } +func validateDNS1123Label(name string, fieldPath *field.Path) field.ErrorList { + if name == "" { + return field.ErrorList{field.Required(fieldPath, "")} + } + + allErrs := field.ErrorList{} + for _, msg := range validation.IsDNS1123Label(name) { + allErrs = append(allErrs, field.Invalid(fieldPath, name, msg)) + } + return allErrs +} + func (vsv *VirtualServerValidator) validateVirtualServerRoutes(routes []v1.Route, fieldPath *field.Path, upstreamNames sets.Set[string], namespace string) field.ErrorList { allErrs := field.ErrorList{} diff --git a/pkg/apis/configuration/validation/virtualserver_test.go b/pkg/apis/configuration/validation/virtualserver_test.go index 381ccad42a..3a2c2edcda 100644 --- a/pkg/apis/configuration/validation/virtualserver_test.go +++ b/pkg/apis/configuration/validation/virtualserver_test.go @@ -190,6 +190,20 @@ func TestValidateBackup(t *testing.T) { } } +func TestValidateBackupRejectsCrossNamespace(t *testing.T) { + t.Parallel() + + vs := makeVirtualServer() + vs.Spec.Upstreams[1].Backup = "external-ns/backup-service" + vs.Spec.Upstreams[1].BackupPort = createPointerFromUInt16(8080) + + vsv := &VirtualServerValidator{isPlus: true, isDosEnabled: true} + err := vsv.ValidateVirtualServer(&vs) + if err == nil { + t.Error("ValidateVirtualServer() returned no error for cross-namespace backup service, expected error") + } +} + func TestValidateHost(t *testing.T) { t.Parallel() validHosts := []string{ @@ -901,6 +915,36 @@ func TestValidateDNS1035Label(t *testing.T) { } } +func TestValidateDNS1123Label(t *testing.T) { + t.Parallel() + validNames := []string{ + "my-namespace", + "namespace-123", + "123namespace", + } + + for _, name := range validNames { + allErrs := validateDNS1123Label(name, field.NewPath("namespace")) + if len(allErrs) != 0 { + t.Errorf("validateDNS1123Label(%v) returned errors %v for valid input", name, allErrs) + } + } + + invalidNames := []string{ + "", + "UPPERCASE", + "Test", + "very-long-label-name-that-exceeds-the-maximum-allowed-length-of-63-characters", + } + + for _, name := range invalidNames { + allErrs := validateDNS1123Label(name, field.NewPath("namespace")) + if len(allErrs) == 0 { + t.Errorf("validateDNS1123Label(%v) returned no errors for invalid input", name) + } + } +} + func TestValidateVirtualServerRoutes(t *testing.T) { t.Parallel() tests := []struct { @@ -4563,3 +4607,62 @@ func TestValidateErrorPageHeaderFails(t *testing.T) { } } } + +func TestValidateVirtualServerServiceName(t *testing.T) { + t.Parallel() + + validTests := []string{ + "coffee-svc", + "coffee/coffee-svc", + } + + for _, test := range validTests { + allErrs := validateVirtualServerServiceName(test, field.NewPath("service")) + if len(allErrs) != 0 { + t.Errorf("validateVirtualServerServiceName(%v) returned errors %v for valid input", test, allErrs) + } + } + + invalidTests := []string{ + "", + "namespace/service/extra", + "namespace//service", + "/service", + } + + for _, test := range invalidTests { + allErrs := validateVirtualServerServiceName(test, field.NewPath("service")) + if len(allErrs) == 0 { + t.Errorf("validateVirtualServerServiceName(%v) returned no errors for invalid input", test) + } + } +} + +func TestValidateServiceName(t *testing.T) { + t.Parallel() + + validTests := []string{ + "my-service", + "service-name", + "service123", + } + + for _, test := range validTests { + allErrs := validateServiceName(test, field.NewPath("service")) + if len(allErrs) != 0 { + t.Errorf("validateServiceName(%v) returned errors %v for valid input", test, allErrs) + } + } + + invalidTests := []string{ + "", + "123service", + } + + for _, test := range invalidTests { + allErrs := validateServiceName(test, field.NewPath("service")) + if len(allErrs) == 0 { + t.Errorf("validateServiceName(%v) returned no errors for invalid input", test) + } + } +} diff --git a/tests/data/common/app/simple-namespaced-upstream/backend1.yaml b/tests/data/common/app/simple-namespaced-upstream/backend1.yaml new file mode 100644 index 0000000000..2c8a6ade77 --- /dev/null +++ b/tests/data/common/app/simple-namespaced-upstream/backend1.yaml @@ -0,0 +1,32 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend1 +spec: + replicas: 2 + selector: + matchLabels: + app: backend1 + template: + metadata: + labels: + app: backend1 + spec: + containers: + - name: backend1 + image: nginxdemos/nginx-hello:plain-text + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: backend1-svc +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: backend1 diff --git a/tests/data/common/app/simple-namespaced-upstream/backend2.yaml b/tests/data/common/app/simple-namespaced-upstream/backend2.yaml new file mode 100644 index 0000000000..0319d4b488 --- /dev/null +++ b/tests/data/common/app/simple-namespaced-upstream/backend2.yaml @@ -0,0 +1,34 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend2 + namespace: backend2-namespace +spec: + replicas: 1 + selector: + matchLabels: + app: backend2 + template: + metadata: + labels: + app: backend2 + spec: + containers: + - name: backend2 + image: nginxdemos/nginx-hello:plain-text + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: backend2-svc + namespace: backend2-namespace +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: backend2 diff --git a/tests/data/virtual-server-foreign-upstream/route-backend2.yaml b/tests/data/virtual-server-foreign-upstream/route-backend2.yaml new file mode 100644 index 0000000000..fa093f7605 --- /dev/null +++ b/tests/data/virtual-server-foreign-upstream/route-backend2.yaml @@ -0,0 +1,14 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServerRoute +metadata: + name: backend2 +spec: + host: virtual-server-vsr.example.com + upstreams: + - name: backend2 + service: backend2-namespace/backend2-svc + port: 80 + subroutes: + - path: "/backend2" + action: + pass: backend2 diff --git a/tests/data/virtual-server-foreign-upstream/standard/virtual-server-regex.yaml b/tests/data/virtual-server-foreign-upstream/standard/virtual-server-regex.yaml new file mode 100644 index 0000000000..c6731faf7e --- /dev/null +++ b/tests/data/virtual-server-foreign-upstream/standard/virtual-server-regex.yaml @@ -0,0 +1,20 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: virtual-server +spec: + host: virtual-server-regex.example.com + upstreams: + - name: backend1 + service: backend1-svc + port: 80 + - name: backend2 + service: backend2-namespace/backend2-svc + port: 80 + routes: + - path: "~ ^/[a-z]+1" + action: + pass: backend1 + - path: "~ ^/[a-z]+2" + action: + pass: backend2 diff --git a/tests/data/virtual-server-foreign-upstream/standard/virtual-server-vsr.yaml b/tests/data/virtual-server-foreign-upstream/standard/virtual-server-vsr.yaml new file mode 100644 index 0000000000..87baaa9f70 --- /dev/null +++ b/tests/data/virtual-server-foreign-upstream/standard/virtual-server-vsr.yaml @@ -0,0 +1,16 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: virtual-server +spec: + host: virtual-server-vsr.example.com + upstreams: + - name: backend1 + service: backend1-svc + port: 80 + routes: + - path: "/backend1" + action: + pass: backend1 + - path: "/backend2" + route: backend2 diff --git a/tests/data/virtual-server-foreign-upstream/standard/virtual-server.yaml b/tests/data/virtual-server-foreign-upstream/standard/virtual-server.yaml new file mode 100644 index 0000000000..48228bafc5 --- /dev/null +++ b/tests/data/virtual-server-foreign-upstream/standard/virtual-server.yaml @@ -0,0 +1,20 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: virtual-server +spec: + host: virtual-server.example.com + upstreams: + - name: backend1 + service: backend1-svc + port: 80 + - name: backend2 + service: backend2-namespace/backend2-svc + port: 80 + routes: + - path: "/backend1" + action: + pass: backend1 + - path: "/backend2" + action: + pass: backend2 diff --git a/tests/suite/test_virtual_server_foreign_upstream.py b/tests/suite/test_virtual_server_foreign_upstream.py new file mode 100644 index 0000000000..11ac55eca9 --- /dev/null +++ b/tests/suite/test_virtual_server_foreign_upstream.py @@ -0,0 +1,236 @@ +import pytest +from settings import TEST_DATA +from suite.fixtures.custom_resource_fixtures import VirtualServerSetup +from suite.utils.custom_assertions import wait_and_assert_status_code +from suite.utils.resources_utils import ( + create_items_from_yaml, + create_namespace_with_name_from_yaml, + delete_items_from_yaml, + delete_namespace, + wait_before_test, + wait_until_all_pods_are_ready, +) +from suite.utils.vs_vsr_resources_utils import ( + create_v_s_route_from_yaml, + create_virtual_server_from_yaml, + delete_v_s_route, + delete_virtual_server, + patch_virtual_server_from_yaml, +) +from suite.utils.yaml_utils import ( + get_first_host_from_yaml, + get_paths_from_vs_yaml, + get_upstream_namespace_from_vs_yaml, +) + + +@pytest.fixture(scope="class") +def virtual_server_foreign_upstream_app_setup( + request, kube_apis, ingress_controller_endpoint, test_namespace +) -> VirtualServerSetup: + """ + Prepare Virtual Server Example with backends in foreign namespaces: + + 1st namespace with backend1-svc and deployment in the same namespace as VS, + and 2nd namespace with backend2-svc and deployment in another namespace. + + :param request: internal pytest fixture to parametrize this method: + {example: virtual-server|virtual-server-tls|..., app_type: simple|split|...} + 'example' is a directory name in TEST_DATA, + 'app_type' is a directory name in TEST_DATA/common/app + :param kube_apis: client apis + :param crd_ingress_controller: + :param ingress_controller_endpoint: + :param test_namespace: + :return: VirtualServerSetup + """ + print("------------------------- Deploy Virtual Server Example -----------------------------------") + vs_source = f"{TEST_DATA}/{request.param['example']}/standard/virtual-server.yaml" + vs_name = create_virtual_server_from_yaml(kube_apis.custom_objects, vs_source, test_namespace) + vs_host = get_first_host_from_yaml(vs_source) + vs_paths = get_paths_from_vs_yaml(vs_source) + upstream_namespaces = get_upstream_namespace_from_vs_yaml(vs_source, test_namespace) + print(f"Upstream namespaces detected in the VS yaml: {upstream_namespaces}") + ns_1 = ( + create_namespace_with_name_from_yaml(kube_apis.v1, upstream_namespaces[0], f"{TEST_DATA}/common/ns.yaml") + if upstream_namespaces[0] != test_namespace + else test_namespace + ) + ns_2 = ( + create_namespace_with_name_from_yaml(kube_apis.v1, upstream_namespaces[1], f"{TEST_DATA}/common/ns.yaml") + if upstream_namespaces[1] != test_namespace + else test_namespace + ) + create_items_from_yaml(kube_apis, f"{TEST_DATA}/common/app/{request.param['app_type']}/backend1.yaml", ns_1) + create_items_from_yaml(kube_apis, f"{TEST_DATA}/common/app/{request.param['app_type']}/backend2.yaml", ns_2) + + wait_until_all_pods_are_ready(kube_apis.v1, ns_1) + wait_until_all_pods_are_ready(kube_apis.v1, ns_2) + + def fin(): + if request.config.getoption("--skip-fixture-teardown") == "no": + print("Clean up Virtual Server Example:") + delete_virtual_server(kube_apis.custom_objects, vs_name, test_namespace) + print("Clean up the Application:") + if request.param.get("app_type"): + delete_items_from_yaml( + kube_apis, f"{TEST_DATA}/common/app/{request.param["app_type"]}/backend1.yaml", ns_1 + ) + delete_items_from_yaml( + kube_apis, f"{TEST_DATA}/common/app/{request.param["app_type"]}/backend2.yaml", ns_2 + ) + + try: + delete_namespace(kube_apis.v1, ns_1) + delete_namespace(kube_apis.v1, ns_2) + + except Exception as ex: + print(f"Exception during teardown: {ex}") + + request.addfinalizer(fin) + + return VirtualServerSetup(ingress_controller_endpoint, test_namespace, vs_host, vs_name, vs_paths) + + +@pytest.mark.vs +@pytest.mark.vs_responses +@pytest.mark.smoke +@pytest.mark.parametrize( + "crd_ingress_controller, virtual_server_foreign_upstream_app_setup", + [ + ( + {"type": "complete", "extra_args": [f"-enable-custom-resources"]}, + {"example": "virtual-server-foreign-upstream", "app_type": "simple-namespaced-upstream"}, + ), + ], + indirect=True, +) +class TestVirtualServerForeignUpstream: + def test_responses_after_setup(self, kube_apis, crd_ingress_controller, virtual_server_foreign_upstream_app_setup): + print(f"\nStep 1: initial check") + wait_before_test() + wait_and_assert_status_code( + 200, + virtual_server_foreign_upstream_app_setup.backend_1_url, + virtual_server_foreign_upstream_app_setup.vs_host, + ) + wait_and_assert_status_code( + 200, + virtual_server_foreign_upstream_app_setup.backend_2_url, + virtual_server_foreign_upstream_app_setup.vs_host, + ) + + def test_responses_regex_path(self, kube_apis, crd_ingress_controller, virtual_server_foreign_upstream_app_setup): + print(f"\nStep 2: patch VS with regex path and check") + vs_source = f"{TEST_DATA}/virtual-server-foreign-upstream/standard/virtual-server-regex.yaml" + patch_virtual_server_from_yaml( + kube_apis.custom_objects, + virtual_server_foreign_upstream_app_setup.vs_name, + vs_source, + virtual_server_foreign_upstream_app_setup.namespace, + ) + + new_host = get_first_host_from_yaml( + f"{TEST_DATA}/virtual-server-foreign-upstream/standard/virtual-server-regex.yaml" + ) + + wait_before_test() + wait_and_assert_status_code( + 404, + virtual_server_foreign_upstream_app_setup.backend_1_url, + virtual_server_foreign_upstream_app_setup.vs_host, + ) + wait_and_assert_status_code( + 404, + virtual_server_foreign_upstream_app_setup.backend_2_url, + virtual_server_foreign_upstream_app_setup.vs_host, + ) + + wait_and_assert_status_code(200, virtual_server_foreign_upstream_app_setup.backend_1_url, new_host) + wait_and_assert_status_code(200, virtual_server_foreign_upstream_app_setup.backend_2_url, new_host) + + print("\nStep 3: restore VS and check") + patch_virtual_server_from_yaml( + kube_apis.custom_objects, + virtual_server_foreign_upstream_app_setup.vs_name, + f"{TEST_DATA}/virtual-server-foreign-upstream/standard/virtual-server.yaml", + virtual_server_foreign_upstream_app_setup.namespace, + ) + wait_before_test() + + wait_and_assert_status_code(404, virtual_server_foreign_upstream_app_setup.backend_1_url, new_host) + wait_and_assert_status_code(404, virtual_server_foreign_upstream_app_setup.backend_2_url, new_host) + + wait_and_assert_status_code( + 200, + virtual_server_foreign_upstream_app_setup.backend_1_url, + virtual_server_foreign_upstream_app_setup.vs_host, + ) + wait_and_assert_status_code( + 200, + virtual_server_foreign_upstream_app_setup.backend_2_url, + virtual_server_foreign_upstream_app_setup.vs_host, + ) + + def test_responses_vsr_foreign_upstream( + self, kube_apis, crd_ingress_controller, virtual_server_foreign_upstream_app_setup + ): + print(f"\nStep 4: create VS Route in the same namespace and check") + vs_source = f"{TEST_DATA}/virtual-server-foreign-upstream/standard/virtual-server-vsr.yaml" + patch_virtual_server_from_yaml( + kube_apis.custom_objects, + virtual_server_foreign_upstream_app_setup.vs_name, + vs_source, + virtual_server_foreign_upstream_app_setup.namespace, + ) + + vs_route = create_v_s_route_from_yaml( + kube_apis.custom_objects, + f"{TEST_DATA}/virtual-server-foreign-upstream/route-backend2.yaml", + virtual_server_foreign_upstream_app_setup.namespace, + ) + + new_host = get_first_host_from_yaml( + f"{TEST_DATA}/virtual-server-foreign-upstream/standard/virtual-server-vsr.yaml" + ) + + wait_before_test() + + wait_and_assert_status_code( + 404, + virtual_server_foreign_upstream_app_setup.backend_1_url, + virtual_server_foreign_upstream_app_setup.vs_host, + ) + wait_and_assert_status_code( + 404, + virtual_server_foreign_upstream_app_setup.backend_2_url, + virtual_server_foreign_upstream_app_setup.vs_host, + ) + + wait_and_assert_status_code(200, virtual_server_foreign_upstream_app_setup.backend_1_url, new_host) + wait_and_assert_status_code(200, virtual_server_foreign_upstream_app_setup.backend_2_url, new_host) + + print("\nStep 5: remove VSR, restore VS and check") + delete_v_s_route(kube_apis.custom_objects, vs_route, virtual_server_foreign_upstream_app_setup.namespace) + + patch_virtual_server_from_yaml( + kube_apis.custom_objects, + virtual_server_foreign_upstream_app_setup.vs_name, + f"{TEST_DATA}/virtual-server-foreign-upstream/standard/virtual-server.yaml", + virtual_server_foreign_upstream_app_setup.namespace, + ) + wait_before_test() + + wait_and_assert_status_code(404, virtual_server_foreign_upstream_app_setup.backend_1_url, new_host) + wait_and_assert_status_code(404, virtual_server_foreign_upstream_app_setup.backend_2_url, new_host) + + wait_and_assert_status_code( + 200, + virtual_server_foreign_upstream_app_setup.backend_1_url, + virtual_server_foreign_upstream_app_setup.vs_host, + ) + wait_and_assert_status_code( + 200, + virtual_server_foreign_upstream_app_setup.backend_2_url, + virtual_server_foreign_upstream_app_setup.vs_host, + ) diff --git a/tests/suite/utils/yaml_utils.py b/tests/suite/utils/yaml_utils.py index 7c7858df0e..65e03379eb 100644 --- a/tests/suite/utils/yaml_utils.py +++ b/tests/suite/utils/yaml_utils.py @@ -107,6 +107,28 @@ def get_route_namespace_from_vs_yaml(file) -> []: return res +def get_upstream_namespace_from_vs_yaml(file, default_namespace) -> []: + """ + Parse yaml file and return namespaces of all spec.upstreams.service that contain namespace references. + + :param file: an absolute path to file + :return: [] + """ + res = [] + with open(file) as f: + docs = yaml.safe_load_all(f) + for dep in docs: + if "upstreams" in dep["spec"]: + for upstream in dep["spec"]["upstreams"]: + service = upstream["service"] + if "/" in service: # namespace/service format + namespace = service.split("/")[0] + else: + namespace = default_namespace + res.append(namespace) + return res + + def get_paths_from_vsr_yaml(file) -> []: """ Parse yaml file and return all the found spec.subroutes.path.