From 72271e9313426812fc96df5e1b53df7975834c97 Mon Sep 17 00:00:00 2001 From: Charle Demers Date: Wed, 31 Jul 2019 10:39:21 -0400 Subject: [PATCH] FastCGI backend support (#2982) Co-authored-by: Pierrick Charron --- docs/user-guide/fcgi-services.md | 117 ++++++++ images/fastcgi-helloserver/Makefile | 105 +++++++ images/fastcgi-helloserver/main.go | 30 ++ images/fastcgi-helloserver/rootfs/Dockerfile | 21 ++ internal/ingress/annotations/annotations.go | 3 + .../annotations/backendprotocol/main.go | 2 +- internal/ingress/annotations/fastcgi/main.go | 107 +++++++ .../ingress/annotations/fastcgi/main_test.go | 263 ++++++++++++++++++ internal/ingress/controller/controller.go | 1 + .../ingress/controller/template/template.go | 3 + .../controller/template/template_test.go | 1 + internal/ingress/resolver/main.go | 3 + internal/ingress/resolver/mock.go | 5 + internal/ingress/types.go | 4 + internal/ingress/types_equals.go | 4 + mkdocs.yml | 1 + rootfs/Dockerfile | 3 +- rootfs/etc/nginx/template/nginx.tmpl | 10 + test/e2e/annotations/backendprotocol.go | 23 +- test/e2e/annotations/fastcgi.go | 130 +++++++++ test/e2e/framework/fastcgi_helloserver.go | 106 +++++++ test/e2e/run.sh | 2 + 22 files changed, 938 insertions(+), 6 deletions(-) create mode 100644 docs/user-guide/fcgi-services.md create mode 100644 images/fastcgi-helloserver/Makefile create mode 100644 images/fastcgi-helloserver/main.go create mode 100755 images/fastcgi-helloserver/rootfs/Dockerfile create mode 100644 internal/ingress/annotations/fastcgi/main.go create mode 100644 internal/ingress/annotations/fastcgi/main_test.go create mode 100644 test/e2e/annotations/fastcgi.go create mode 100644 test/e2e/framework/fastcgi_helloserver.go diff --git a/docs/user-guide/fcgi-services.md b/docs/user-guide/fcgi-services.md new file mode 100644 index 000000000..fba989f3c --- /dev/null +++ b/docs/user-guide/fcgi-services.md @@ -0,0 +1,117 @@ + + +# Exposing FastCGI Servers + +> **FastCGI** is a [binary protocol](https://en.wikipedia.org/wiki/Binary_protocol "Binary protocol") for interfacing interactive programs with a [web server](https://en.wikipedia.org/wiki/Web_server "Web server"). [...] (It's) aim is to reduce the overhead related to interfacing between web server and CGI programs, allowing a server to handle more web page requests per unit of time. +> +> — Wikipedia + +The _ingress-nginx_ ingress controller can be used to directly expose [FastCGI](https://en.wikipedia.org/wiki/FastCGI) servers. Enabling FastCGI in your Ingress only requires setting the _backend-protocol_ annotation to `FCGI`, and with a couple more annotations you can customize the way _ingress-nginx_ handles the communication with your FastCGI _server_. + + +## Example Objects to Expose a FastCGI Pod + +The _Pod_ example object below exposes port `9000`, which is the conventional FastCGI port. + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: example-app +labels: + app: example-app +spec: + containers: + - name: example-app + image: example-app:1.0 + ports: + - containerPort: 9000 + name: fastcgi +``` + +The _Service_ object example below matches port `9000` from the _Pod_ object above. + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: example-service +spec: + selector: + app: example-app + ports: + - port: 9000 + targetPort: 9000 + name: fastcgi +``` + +And the _Ingress_ and _ConfigMap_ objects below demonstrates the supported _FastCGI_ specific annotations (NGINX actually has 50 FastCGI directives, all of which have not been exposed in the ingress yet), and matches the service `example-service`, and the port named `fastcgi` from above. The _ConfigMap_ **must** be created first for the _Ingress Controller_ to be able to find it when the _Ingress_ object is created, otherwise you will need to restart the _Ingress Controller_ pods. + +```yaml +# The ConfigMap MUST be created first for the ingress controller to be able to +# find it when the Ingress object is created. + +apiVersion: v1 +kind: ConfigMap +metadata: + name: example-cm +data: + SCRIPT_FILENAME: "/example/index.php" + +--- + +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: "nginx" + nginx.ingress.kubernetes.io/backend-protocol: "FCGI" + nginx.ingress.kubernetes.io/fastcgi-index: "index.php" + nginx.ingress.kubernetes.io/fastcgi-params-configmap: "example-cm" + name: example-app +spec: + rules: + - host: app.example.com + http: + paths: + - backend: + serviceName: example-service + servicePort: fastcgi +``` + +## The FastCGI Ingress Annotations + +### The `nginx.ingress.kubernetes.io/backend-protocol` Annotation + +To enable FastCGI, the `backend-protocol` annotation needs to be set to `FCGI`, which overrides the default `HTTP` value. + +> `nginx.ingress.kubernetes.io/backend-protocol: "FCGI"` + +This enables the _FastCGI_ mode for the whole _Ingress_ object. + +### The `nginx.ingress.kubernetes.io/fastcgi-index` Annotation + +To specify an index file, the `fastcgi-index` annotation value can optionally be set. In the example below, the value is set to `index.php`. This annotation corresponds to [the _NGINX_ `fastcgi_index` directive](http://nginx.org/en/docs/http/ngx_http_fastcgi_module.html#fastcgi_index). + +> `nginx.ingress.kubernetes.io/fastcgi-index: "index.php"` + +### The `nginx.ingress.kubernetes.io/fastcgi-params-configmap` Annotation + +To specify [_NGINX_ `fastcgi_param` directives](http://nginx.org/en/docs/http/ngx_http_fastcgi_module.html#fastcgi_param), the `fastcgi-params-configmap` annotation is used, which in turn must lead to a _ConfigMap_ object containing the _NGINX_ `fastcgi_param` directives as key/values. + +> `nginx.ingress.kubernetes.io/fastcgi-params: "example-configmap"` + +And the _ConfigMap_ object to specify the `SCRIPT_FILENAME` and `HTTP_PROXY` _NGINX's_ `fastcgi_param` directives will look like the following: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: example-configmap +data: + SCRIPT_FILENAME: "/example/index.php" + HTTP_PROXY: "" +``` +Using the _namespace/_ prefix is also supported, for example: + +> `nginx.ingress.kubernetes.io/fastcgi-params: "example-namespace/example-configmap"` diff --git a/images/fastcgi-helloserver/Makefile b/images/fastcgi-helloserver/Makefile new file mode 100644 index 000000000..2486f6bc5 --- /dev/null +++ b/images/fastcgi-helloserver/Makefile @@ -0,0 +1,105 @@ +all: all-container + +BUILDTAGS= + +# Use the 0.0 tag for testing, it shouldn't clobber any release builds +TAG?=0.1 +REGISTRY?=kubernetes-ingress-controller +GOOS?=linux +DOCKER?=docker +SED_I?=sed -i +GOHOSTOS ?= $(shell go env GOHOSTOS) + +PKG=k8s.io/ingress-nginx/images/fastcgi-helloserver + +ifeq ($(GOHOSTOS),darwin) + SED_I=sed -i '' +endif + +REPO_INFO=$(shell git config --get remote.origin.url) + +ARCH ?= $(shell go env GOARCH) +GOARCH = ${ARCH} + +BASEIMAGE?=alpine:3.9 + +ALL_ARCH = amd64 arm arm64 ppc64le + +QEMUVERSION=v3.0.0 + +IMGNAME = fastcgi-helloserver +IMAGE = $(REGISTRY)/$(IMGNAME) +MULTI_ARCH_IMG = $(IMAGE)-$(ARCH) + +ifeq ($(ARCH),arm) + QEMUARCH=arm + GOARCH=arm +endif +ifeq ($(ARCH),arm64) + QEMUARCH=aarch64 +endif +ifeq ($(ARCH),ppc64le) + QEMUARCH=ppc64le + GOARCH=ppc64le +endif + +TEMP_DIR := $(shell mktemp -d) + +DOCKERFILE := $(TEMP_DIR)/rootfs/Dockerfile + +sub-container-%: + $(MAKE) ARCH=$* build container + +sub-push-%: + $(MAKE) ARCH=$* push + +all-container: $(addprefix sub-container-,$(ALL_ARCH)) + +all-push: $(addprefix sub-push-,$(ALL_ARCH)) + +container: .container-$(ARCH) +.container-$(ARCH): + cp -r ./* $(TEMP_DIR) + $(SED_I) 's|BASEIMAGE|$(BASEIMAGE)|g' $(DOCKERFILE) + $(SED_I) "s|QEMUARCH|$(QEMUARCH)|g" $(DOCKERFILE) + +ifeq ($(ARCH),amd64) + # When building "normally" for amd64, remove the whole line, it has no part in the amd64 image + $(SED_I) "/CROSS_BUILD_/d" $(DOCKERFILE) +else + # When cross-building, only the placeholder "CROSS_BUILD_" should be removed + # Register /usr/bin/qemu-ARCH-static as the handler for ARM binaries in the kernel + # $(DOCKER) run --rm --privileged multiarch/qemu-user-static:register --reset + curl -sSL https://github.com/multiarch/qemu-user-static/releases/download/$(QEMUVERSION)/x86_64_qemu-$(QEMUARCH)-static.tar.gz | tar -xz -C $(TEMP_DIR)/rootfs + $(SED_I) "s/CROSS_BUILD_//g" $(DOCKERFILE) +endif + + $(DOCKER) build -t $(MULTI_ARCH_IMG):$(TAG) $(TEMP_DIR)/rootfs + +ifeq ($(ARCH), amd64) + # This is for to maintain the backward compatibility + $(DOCKER) tag $(MULTI_ARCH_IMG):$(TAG) $(IMAGE):$(TAG) +endif + +push: .push-$(ARCH) +.push-$(ARCH): + $(DOCKER) push $(MULTI_ARCH_IMG):$(TAG) +ifeq ($(ARCH), amd64) + $(DOCKER) push $(IMAGE):$(TAG) +endif + +clean: + $(DOCKER) rmi -f $(MULTI_ARCH_IMG):$(TAG) || true + +build: clean + CGO_ENABLED=0 GOOS=${GOOS} GOARCH=${GOARCH} go build -a -installsuffix cgo \ + -ldflags "-s -w" \ + -o ${TEMP_DIR}/rootfs/fastcgi-helloserver ${PKG}/... + +release: all-container all-push + echo "done" + +.PHONY: register-qemu +register-qemu: + # Register /usr/bin/qemu-ARCH-static as the handler for binaries in multiple platforms + $(DOCKER) run --rm --privileged multiarch/qemu-user-static:register --reset diff --git a/images/fastcgi-helloserver/main.go b/images/fastcgi-helloserver/main.go new file mode 100644 index 000000000..91db60c26 --- /dev/null +++ b/images/fastcgi-helloserver/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" + "net" + "net/http" + "net/http/fcgi" +) + +func hello(w http.ResponseWriter, r *http.Request) { + keys, ok := r.URL.Query()["name"] + + if !ok || len(keys[0]) < 1 { + fmt.Fprintf(w, "Hello world!") + return + } + + key := keys[0] + fmt.Fprintf(w, "Hello "+string(key)+"!") +} + +func main() { + http.HandleFunc("/hello", hello) + + l, err := net.Listen("tcp", "0.0.0.0:9000") + if err != nil { + panic(err) + } + fcgi.Serve(l, nil) +} diff --git a/images/fastcgi-helloserver/rootfs/Dockerfile b/images/fastcgi-helloserver/rootfs/Dockerfile new file mode 100755 index 000000000..09e79964d --- /dev/null +++ b/images/fastcgi-helloserver/rootfs/Dockerfile @@ -0,0 +1,21 @@ +# Copyright 2017 The Kubernetes Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM BASEIMAGE + +CROSS_BUILD_COPY qemu-QEMUARCH-static /usr/bin/ + +COPY . / + +CMD ["/fastcgi-helloserver"] diff --git a/internal/ingress/annotations/annotations.go b/internal/ingress/annotations/annotations.go index 00080c840..64f3025f5 100644 --- a/internal/ingress/annotations/annotations.go +++ b/internal/ingress/annotations/annotations.go @@ -38,6 +38,7 @@ import ( "k8s.io/ingress-nginx/internal/ingress/annotations/cors" "k8s.io/ingress-nginx/internal/ingress/annotations/customhttperrors" "k8s.io/ingress-nginx/internal/ingress/annotations/defaultbackend" + "k8s.io/ingress-nginx/internal/ingress/annotations/fastcgi" "k8s.io/ingress-nginx/internal/ingress/annotations/http2pushpreload" "k8s.io/ingress-nginx/internal/ingress/annotations/influxdb" "k8s.io/ingress-nginx/internal/ingress/annotations/ipwhitelist" @@ -82,6 +83,7 @@ type Ingress struct { CustomHTTPErrors []int DefaultBackend *apiv1.Service //TODO: Change this back into an error when https://github.com/imdario/mergo/issues/100 is resolved + FastCGI fastcgi.Config Denied *string ExternalAuth authreq.Config EnableGlobalAuth bool @@ -128,6 +130,7 @@ func NewAnnotationExtractor(cfg resolver.Resolver) Extractor { "CorsConfig": cors.NewParser(cfg), "CustomHTTPErrors": customhttperrors.NewParser(cfg), "DefaultBackend": defaultbackend.NewParser(cfg), + "FastCGI": fastcgi.NewParser(cfg), "ExternalAuth": authreq.NewParser(cfg), "EnableGlobalAuth": authreqglobal.NewParser(cfg), "HTTP2PushPreload": http2pushpreload.NewParser(cfg), diff --git a/internal/ingress/annotations/backendprotocol/main.go b/internal/ingress/annotations/backendprotocol/main.go index 075a036e2..514f824a7 100644 --- a/internal/ingress/annotations/backendprotocol/main.go +++ b/internal/ingress/annotations/backendprotocol/main.go @@ -31,7 +31,7 @@ import ( const HTTP = "HTTP" var ( - validProtocols = regexp.MustCompile(`^(HTTP|HTTPS|AJP|GRPC|GRPCS)$`) + validProtocols = regexp.MustCompile(`^(HTTP|HTTPS|AJP|GRPC|GRPCS|FCGI)$`) ) type backendProtocol struct { diff --git a/internal/ingress/annotations/fastcgi/main.go b/internal/ingress/annotations/fastcgi/main.go new file mode 100644 index 000000000..174a2716c --- /dev/null +++ b/internal/ingress/annotations/fastcgi/main.go @@ -0,0 +1,107 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package fastcgi + +import ( + "fmt" + "reflect" + + "github.com/pkg/errors" + networking "k8s.io/api/networking/v1beta1" + "k8s.io/client-go/tools/cache" + + "k8s.io/ingress-nginx/internal/ingress/annotations/parser" + ing_errors "k8s.io/ingress-nginx/internal/ingress/errors" + "k8s.io/ingress-nginx/internal/ingress/resolver" +) + +type fastcgi struct { + r resolver.Resolver +} + +// Config describes the per location fastcgi config +type Config struct { + Index string `json:"index"` + Params map[string]string `json:"params"` +} + +// Equal tests for equality between two Configuration types +func (l1 *Config) Equal(l2 *Config) bool { + if l1 == l2 { + return true + } + + if l1 == nil || l2 == nil { + return false + } + + if l1.Index != l2.Index { + return false + } + + return reflect.DeepEqual(l1.Params, l2.Params) +} + +// NewParser creates a new fastcgiConfig protocol annotation parser +func NewParser(r resolver.Resolver) parser.IngressAnnotation { + return fastcgi{r} +} + +// ParseAnnotations parses the annotations contained in the ingress +// rule used to indicate the fastcgiConfig. +func (a fastcgi) Parse(ing *networking.Ingress) (interface{}, error) { + + fcgiConfig := Config{} + + if ing.GetAnnotations() == nil { + return fcgiConfig, nil + } + + index, err := parser.GetStringAnnotation("fastcgi-index", ing) + if err != nil { + index = "" + } + fcgiConfig.Index = index + + cm, err := parser.GetStringAnnotation("fastcgi-params-configmap", ing) + if err != nil { + return fcgiConfig, nil + } + + cmns, cmn, err := cache.SplitMetaNamespaceKey(cm) + if err != nil { + return fcgiConfig, ing_errors.LocationDenied{ + Reason: errors.Wrap(err, "error reading configmap name from annotation"), + } + } + + if cmns == "" { + cmns = ing.Namespace + } + + cm = fmt.Sprintf("%v/%v", cmns, cmn) + cmap, err := a.r.GetConfigMap(cm) + if err != nil { + return fcgiConfig, ing_errors.LocationDenied{ + Reason: errors.Wrapf(err, "unexpected error reading configmap %v", cm), + } + } + + fcgiConfig.Params = cmap.Data + + return fcgiConfig, nil +} diff --git a/internal/ingress/annotations/fastcgi/main_test.go b/internal/ingress/annotations/fastcgi/main_test.go new file mode 100644 index 000000000..3662bbbeb --- /dev/null +++ b/internal/ingress/annotations/fastcgi/main_test.go @@ -0,0 +1,263 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package fastcgi + +import ( + "testing" + + api "k8s.io/api/core/v1" + networking "k8s.io/api/networking/v1beta1" + meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/ingress-nginx/internal/ingress/annotations/parser" + "k8s.io/ingress-nginx/internal/ingress/errors" + "k8s.io/ingress-nginx/internal/ingress/resolver" + + "k8s.io/apimachinery/pkg/util/intstr" +) + +func buildIngress() *networking.Ingress { + return &networking.Ingress{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "foo", + Namespace: api.NamespaceDefault, + }, + Spec: networking.IngressSpec{ + Backend: &networking.IngressBackend{ + ServiceName: "fastcgi", + ServicePort: intstr.FromInt(80), + }, + }, + } +} + +type mockConfigMap struct { + resolver.Mock +} + +func (m mockConfigMap) GetConfigMap(name string) (*api.ConfigMap, error) { + if name != "default/demo-configmap" { + return nil, errors.Errorf("there is no configmap with name %v", name) + } + + return &api.ConfigMap{ + ObjectMeta: meta_v1.ObjectMeta{ + Namespace: api.NamespaceDefault, + Name: "demo-secret", + }, + Data: map[string]string{"REDIRECT_STATUS": "200", "SERVER_NAME": "$server_name"}, + }, nil +} + +func TestParseEmptyFastCGIAnnotations(t *testing.T) { + ing := buildIngress() + + i, err := NewParser(&mockConfigMap{}).Parse(ing) + if err != nil { + t.Errorf("unexpected error parsing ingress without fastcgi") + } + + config, ok := i.(Config) + if !ok { + t.Errorf("Parse do not return a Config object") + } + + if config.Index != "" { + t.Errorf("Index should be an empty string") + } + + if 0 != len(config.Params) { + t.Errorf("Params should be an empty slice") + } +} + +func TestParseFastCGIIndexAnnotation(t *testing.T) { + ing := buildIngress() + + const expectedAnnotation = "index.php" + + data := map[string]string{} + data[parser.GetAnnotationWithPrefix("fastcgi-index")] = expectedAnnotation + ing.SetAnnotations(data) + + i, err := NewParser(&mockConfigMap{}).Parse(ing) + if err != nil { + t.Errorf("unexpected error parsing ingress without fastcgi") + } + + config, ok := i.(Config) + if !ok { + t.Errorf("Parse do not return a Config object") + } + + if config.Index != "index.php" { + t.Errorf("expected %s but %v returned", expectedAnnotation, config.Index) + } +} + +func TestParseEmptyFastCGIParamsConfigMapAnnotation(t *testing.T) { + ing := buildIngress() + + data := map[string]string{} + data[parser.GetAnnotationWithPrefix("fastcgi-params-configmap")] = "" + ing.SetAnnotations(data) + + i, err := NewParser(&mockConfigMap{}).Parse(ing) + if err != nil { + t.Errorf("unexpected error parsing ingress without fastcgi") + } + + config, ok := i.(Config) + if !ok { + t.Errorf("Parse do not return a Config object") + } + + if 0 != len(config.Params) { + t.Errorf("Params should be an empty slice") + } +} + +func TestParseFastCGIInvalidParamsConfigMapAnnotation(t *testing.T) { + ing := buildIngress() + + invalidConfigMapList := []string{"unknown/configMap", "unknown/config/map"} + for _, configmap := range invalidConfigMapList { + + data := map[string]string{} + data[parser.GetAnnotationWithPrefix("fastcgi-params-configmap")] = configmap + ing.SetAnnotations(data) + + i, err := NewParser(&mockConfigMap{}).Parse(ing) + if err == nil { + t.Errorf("Reading an unexisting configmap should return an error") + } + + config, ok := i.(Config) + if !ok { + t.Errorf("Parse do not return a Config object") + } + + if 0 != len(config.Params) { + t.Errorf("Params should be an empty slice") + } + } +} + +func TestParseFastCGIParamsConfigMapAnnotationWithoutNS(t *testing.T) { + ing := buildIngress() + + data := map[string]string{} + data[parser.GetAnnotationWithPrefix("fastcgi-params-configmap")] = "demo-configmap" + ing.SetAnnotations(data) + + i, err := NewParser(&mockConfigMap{}).Parse(ing) + if err != nil { + t.Errorf("unexpected error parsing ingress without fastcgi") + } + + config, ok := i.(Config) + if !ok { + t.Errorf("Parse do not return a Config object") + } + + if 2 != len(config.Params) { + t.Errorf("Params should have a length of 2") + } + + if "200" != config.Params["REDIRECT_STATUS"] || "$server_name" != config.Params["SERVER_NAME"] { + t.Errorf("Params value is not the one expected") + } +} + +func TestParseFastCGIParamsConfigMapAnnotationWithNS(t *testing.T) { + ing := buildIngress() + + data := map[string]string{} + data[parser.GetAnnotationWithPrefix("fastcgi-params-configmap")] = "default/demo-configmap" + ing.SetAnnotations(data) + + i, err := NewParser(&mockConfigMap{}).Parse(ing) + if err != nil { + t.Errorf("unexpected error parsing ingress without fastcgi") + } + + config, ok := i.(Config) + if !ok { + t.Errorf("Parse do not return a Config object") + } + + if 2 != len(config.Params) { + t.Errorf("Params should have a length of 2") + } + + if "200" != config.Params["REDIRECT_STATUS"] || "$server_name" != config.Params["SERVER_NAME"] { + t.Errorf("Params value is not the one expected") + } +} + +func TestConfigEquality(t *testing.T) { + + var nilConfig *Config + + config := Config{ + Index: "index.php", + Params: map[string]string{"REDIRECT_STATUS": "200", "SERVER_NAME": "$server_name"}, + } + + configCopy := Config{ + Index: "index.php", + Params: map[string]string{"REDIRECT_STATUS": "200", "SERVER_NAME": "$server_name"}, + } + + config2 := Config{ + Index: "index.php", + Params: map[string]string{"REDIRECT_STATUS": "200"}, + } + + config3 := Config{ + Index: "index.py", + Params: map[string]string{"SERVER_NAME": "$server_name", "REDIRECT_STATUS": "200"}, + } + + config4 := Config{ + Index: "index.php", + Params: map[string]string{"SERVER_NAME": "$server_name", "REDIRECT_STATUS": "200"}, + } + + if !config.Equal(&config) { + t.Errorf("config should be equal to itself") + } + + if nilConfig.Equal(&config) { + t.Errorf("Foo") + } + + if !config.Equal(&configCopy) { + t.Errorf("config should be equal to configCopy") + } + + if config.Equal(&config2) { + t.Errorf("config2 should not be equal to config") + } + + if config.Equal(&config3) { + t.Errorf("config3 should not be equal to config") + } + + if !config.Equal(&config4) { + t.Errorf("config4 should be equal to config") + } +} diff --git a/internal/ingress/controller/controller.go b/internal/ingress/controller/controller.go index e37e08553..c6c2784e3 100644 --- a/internal/ingress/controller/controller.go +++ b/internal/ingress/controller/controller.go @@ -1165,6 +1165,7 @@ func locationApplyAnnotations(loc *ingress.Location, anns *annotations.Ingress) loc.InfluxDB = anns.InfluxDB loc.DefaultBackend = anns.DefaultBackend loc.BackendProtocol = anns.BackendProtocol + loc.FastCGI = anns.FastCGI loc.CustomHTTPErrors = anns.CustomHTTPErrors loc.ModSecurity = anns.ModSecurity loc.Satisfy = anns.Satisfy diff --git a/internal/ingress/controller/template/template.go b/internal/ingress/controller/template/template.go index b5039f53a..146b7d4aa 100644 --- a/internal/ingress/controller/template/template.go +++ b/internal/ingress/controller/template/template.go @@ -497,6 +497,9 @@ func buildProxyPass(host string, b interface{}, loc interface{}) string { case "AJP": proto = "" proxyPass = "ajp_pass" + case "FCGI": + proto = "" + proxyPass = "fastcgi_pass" } upstreamName := "upstream_balancer" diff --git a/internal/ingress/controller/template/template_test.go b/internal/ingress/controller/template/template_test.go index c0f7a221e..ab13efdd3 100644 --- a/internal/ingress/controller/template/template_test.go +++ b/internal/ingress/controller/template/template_test.go @@ -874,6 +874,7 @@ func TestOpentracingPropagateContext(t *testing.T) { &ingress.Location{BackendProtocol: "GRPC"}: "opentracing_grpc_propagate_context", &ingress.Location{BackendProtocol: "GRPCS"}: "opentracing_grpc_propagate_context", &ingress.Location{BackendProtocol: "AJP"}: "opentracing_propagate_context", + &ingress.Location{BackendProtocol: "FCGI"}: "opentracing_propagate_context", "not a location": "opentracing_propagate_context", } diff --git a/internal/ingress/resolver/main.go b/internal/ingress/resolver/main.go index 11af30d55..2b59cffeb 100644 --- a/internal/ingress/resolver/main.go +++ b/internal/ingress/resolver/main.go @@ -26,6 +26,9 @@ type Resolver interface { // GetDefaultBackend returns the backend that must be used as default GetDefaultBackend() defaults.Backend + // GetConfigMap searches for configmap containing the namespace and name usting the character / + GetConfigMap(string) (*apiv1.ConfigMap, error) + // GetSecret searches for secrets containing the namespace and name using a the character / GetSecret(string) (*apiv1.Secret, error) diff --git a/internal/ingress/resolver/mock.go b/internal/ingress/resolver/mock.go index 603a6593d..3d520ca84 100644 --- a/internal/ingress/resolver/mock.go +++ b/internal/ingress/resolver/mock.go @@ -31,6 +31,11 @@ func (m Mock) GetDefaultBackend() defaults.Backend { return defaults.Backend{} } +// GetConfigMap searches for configmap containing the namespace and name usting the character / +func (m Mock) GetConfigMap(string) (*apiv1.ConfigMap, error) { + return nil, nil +} + // GetSecret searches for secrets contenating the namespace and name using a the character / func (m Mock) GetSecret(string) (*apiv1.Secret, error) { return nil, nil diff --git a/internal/ingress/types.go b/internal/ingress/types.go index accc89c59..71f8a7121 100644 --- a/internal/ingress/types.go +++ b/internal/ingress/types.go @@ -27,6 +27,7 @@ import ( "k8s.io/ingress-nginx/internal/ingress/annotations/authtls" "k8s.io/ingress-nginx/internal/ingress/annotations/connection" "k8s.io/ingress-nginx/internal/ingress/annotations/cors" + "k8s.io/ingress-nginx/internal/ingress/annotations/fastcgi" "k8s.io/ingress-nginx/internal/ingress/annotations/influxdb" "k8s.io/ingress-nginx/internal/ingress/annotations/ipwhitelist" "k8s.io/ingress-nginx/internal/ingress/annotations/log" @@ -308,6 +309,9 @@ type Location struct { // BackendProtocol indicates which protocol should be used to communicate with the service // By default this is HTTP BackendProtocol string `json:"backend-protocol"` + // FastCGI allows the ingress to act as a FastCGI client for a given location. + // +optional + FastCGI fastcgi.Config `json:"fastcgi,omitempty"` // CustomHTTPErrors specifies the error codes that should be intercepted. // +optional CustomHTTPErrors []int `json:"custom-http-errors"` diff --git a/internal/ingress/types_equals.go b/internal/ingress/types_equals.go index b2813efed..5ee5b4afc 100644 --- a/internal/ingress/types_equals.go +++ b/internal/ingress/types_equals.go @@ -401,6 +401,10 @@ func (l1 *Location) Equal(l2 *Location) bool { return false } + if !(&l1.FastCGI).Equal(&l2.FastCGI) { + return false + } + match := compareInts(l1.CustomHTTPErrors, l2.CustomHTTPErrors) if !match { return false diff --git a/mkdocs.yml b/mkdocs.yml index d682f3313..9ab8a46ef 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -54,6 +54,7 @@ nav: - Custom errors: "user-guide/custom-errors.md" - Default backend: "user-guide/default-backend.md" - Exposing TCP and UDP services: "user-guide/exposing-tcp-udp-services.md" + - Exposing FCGI services: "user-guide/fcgi-services.md" - Regular expressions in paths: user-guide/ingress-path-matching.md - External Articles: "user-guide/external-articles.md" - Miscellaneous: "user-guide/miscellaneous.md" diff --git a/rootfs/Dockerfile b/rootfs/Dockerfile index 717558927..49ff77a7a 100644 --- a/rootfs/Dockerfile +++ b/rootfs/Dockerfile @@ -24,7 +24,8 @@ RUN clean-install \ COPY --chown=www-data:www-data . / -RUN cp /usr/local/openresty/nginx/conf/mime.types /etc/nginx/mime.types +RUN cp /usr/local/openresty/nginx/conf/mime.types /etc/nginx/mime.types \ + && cp /usr/local/openresty/nginx/conf/fastcgi_params /etc/nginx/fastcgi_params RUN ln -s /usr/local/openresty/nginx/modules /etc/nginx/modules # Add LuaRocks paths diff --git a/rootfs/etc/nginx/template/nginx.tmpl b/rootfs/etc/nginx/template/nginx.tmpl index 619ca4a7f..a00f6eaf0 100755 --- a/rootfs/etc/nginx/template/nginx.tmpl +++ b/rootfs/etc/nginx/template/nginx.tmpl @@ -1295,6 +1295,16 @@ stream { {{ range $errCode := $location.CustomHTTPErrors }} error_page {{ $errCode }} = @custom_{{ $location.DefaultBackendUpstreamName }}_{{ $errCode }};{{ end }} + {{ if (eq $location.BackendProtocol "FCGI") }} + include /etc/nginx/fastcgi_params; + {{ end }} + {{- if $location.FastCGI.Index -}} + fastcgi_index "{{ $location.FastCGI.Index }}"; + {{- end -}} + {{ range $k, $v := $location.FastCGI.Params }} + fastcgi_param {{ $k }} "{{ $v }}"; + {{ end }} + {{ buildProxyPass $server.Hostname $all.Backends $location }} {{ if (or (eq $location.Proxy.ProxyRedirectFrom "default") (eq $location.Proxy.ProxyRedirectFrom "off")) }} proxy_redirect {{ $location.Proxy.ProxyRedirectFrom }}; diff --git a/test/e2e/annotations/backendprotocol.go b/test/e2e/annotations/backendprotocol.go index 24b57cd48..2d2b5aace 100644 --- a/test/e2e/annotations/backendprotocol.go +++ b/test/e2e/annotations/backendprotocol.go @@ -32,7 +32,7 @@ var _ = framework.IngressNginxDescribe("Annotations - Backendprotocol", func() { AfterEach(func() { }) - It("should set backend protocol to https://", func() { + It("should set backend protocol to https:// and use proxy_pass", func() { host := "backendprotocol.foo.com" annotations := map[string]string{ "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS", @@ -47,7 +47,7 @@ var _ = framework.IngressNginxDescribe("Annotations - Backendprotocol", func() { }) }) - It("should set backend protocol to grpc://", func() { + It("should set backend protocol to grpc:// and use grpc_pass", func() { host := "backendprotocol.foo.com" annotations := map[string]string{ "nginx.ingress.kubernetes.io/backend-protocol": "GRPC", @@ -62,7 +62,7 @@ var _ = framework.IngressNginxDescribe("Annotations - Backendprotocol", func() { }) }) - It("should set backend protocol to grpcs://", func() { + It("should set backend protocol to grpcs:// and use grpc_pass", func() { host := "backendprotocol.foo.com" annotations := map[string]string{ "nginx.ingress.kubernetes.io/backend-protocol": "GRPCS", @@ -77,7 +77,22 @@ var _ = framework.IngressNginxDescribe("Annotations - Backendprotocol", func() { }) }) - It("should set backend protocol to ''", func() { + It("should set backend protocol to '' and use fastcgi_pass", func() { + host := "backendprotocol.foo.com" + annotations := map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "FCGI", + } + + ing := framework.NewSingleIngress(host, "/", host, f.Namespace, "http-svc", 80, &annotations) + f.EnsureIngress(ing) + + f.WaitForNginxServer(host, + func(server string) bool { + return Expect(server).Should(ContainSubstring("fastcgi_pass upstream_balancer;")) + }) + }) + + It("should set backend protocol to '' and use ajp_pass", func() { host := "backendprotocol.foo.com" annotations := map[string]string{ "nginx.ingress.kubernetes.io/backend-protocol": "AJP", diff --git a/test/e2e/annotations/fastcgi.go b/test/e2e/annotations/fastcgi.go new file mode 100644 index 000000000..a7c9e937f --- /dev/null +++ b/test/e2e/annotations/fastcgi.go @@ -0,0 +1,130 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package annotations + +import ( + "net/http" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "github.com/parnurzeal/gorequest" + corev1 "k8s.io/api/core/v1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/ingress-nginx/test/e2e/framework" +) + +var _ = framework.IngressNginxDescribe("Annotations - FastCGI", func() { + f := framework.NewDefaultFramework("fastcgi") + + BeforeEach(func() { + f.NewFastCGIHelloServerDeployment() + }) + + It("should use fastcgi_pass in the configuration file", func() { + host := "fastcgi" + + annotations := map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "FCGI", + } + + ing := framework.NewSingleIngress(host, "/hello", host, f.Namespace, "fastcgi-helloserver", 9000, &annotations) + f.EnsureIngress(ing) + + f.WaitForNginxServer(host, + func(server string) bool { + return Expect(server).Should(ContainSubstring("include /etc/nginx/fastcgi_params;")) && + Expect(server).Should(ContainSubstring("fastcgi_pass")) + }) + }) + + It("should add fastcgi_index in the configuration file", func() { + host := "fastcgi-index" + + annotations := map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "FCGI", + "nginx.ingress.kubernetes.io/fastcgi-index": "index.php", + } + + ing := framework.NewSingleIngress(host, "/hello", host, f.Namespace, "fastcgi-helloserver", 9000, &annotations) + f.EnsureIngress(ing) + + f.WaitForNginxServer(host, + func(server string) bool { + return Expect(server).Should(ContainSubstring("fastcgi_index \"index.php\";")) + }) + }) + + It("should add fastcgi_param in the configuration file", func() { + configuration := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fastcgi-configmap", + Namespace: f.Namespace, + }, + Data: map[string]string{ + "SCRIPT_FILENAME": "/home/www/scripts/php$fastcgi_script_name", + "REDIRECT_STATUS": "200", + }, + } + + cm, err := f.EnsureConfigMap(configuration) + Expect(err).NotTo(HaveOccurred(), "failed to create an the configmap") + Expect(cm).NotTo(BeNil(), "expected a configmap but none returned") + + host := "fastcgi-params-configmap" + + annotations := map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "FCGI", + "nginx.ingress.kubernetes.io/fastcgi-params-configmap": "fastcgi-configmap", + } + + ing := framework.NewSingleIngress(host, "/hello", host, f.Namespace, "fastcgi-helloserver", 9000, &annotations) + f.EnsureIngress(ing) + + f.WaitForNginxServer(host, + func(server string) bool { + return Expect(server).Should(ContainSubstring("fastcgi_param SCRIPT_FILENAME \"/home/www/scripts/php$fastcgi_script_name\";")) && + Expect(server).Should(ContainSubstring("fastcgi_param REDIRECT_STATUS \"200\";")) + }) + }) + + It("should return OK for service with backend protocol FastCGI", func() { + host := "fastcgi-helloserver" + path := "/hello" + + annotations := map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "FCGI", + } + + ing := framework.NewSingleIngress(host, path, host, f.Namespace, "fastcgi-helloserver", 9000, &annotations) + f.EnsureIngress(ing) + + f.WaitForNginxServer(host, + func(server string) bool { + return Expect(server).Should(ContainSubstring("fastcgi_pass")) + }) + + resp, body, errs := gorequest.New(). + Get(f.GetURL(framework.HTTP)+path). + Set("Host", host). + End() + + Expect(errs).Should(BeEmpty()) + Expect(resp.StatusCode).Should(Equal(http.StatusOK)) + Expect(body).Should(ContainSubstring("Hello world!")) + }) +}) diff --git a/test/e2e/framework/fastcgi_helloserver.go b/test/e2e/framework/fastcgi_helloserver.go new file mode 100644 index 000000000..5c1b0b31a --- /dev/null +++ b/test/e2e/framework/fastcgi_helloserver.go @@ -0,0 +1,106 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package framework + +import ( + . "github.com/onsi/gomega" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// NewFastCGIHelloServerDeployment creates a new single replica +// deployment of the fortune teller image in a particular namespace +func (f *Framework) NewFastCGIHelloServerDeployment() { + f.NewNewFastCGIHelloServerDeploymentWithReplicas(1) +} + +// NewNewFastCGIHelloServerDeploymentWithReplicas creates a new deployment of the +// fortune teller image in a particular namespace. Number of replicas is configurable +func (f *Framework) NewNewFastCGIHelloServerDeploymentWithReplicas(replicas int32) { + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fastcgi-helloserver", + Namespace: f.Namespace, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: NewInt32(replicas), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "fastcgi-helloserver", + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": "fastcgi-helloserver", + }, + }, + Spec: corev1.PodSpec{ + TerminationGracePeriodSeconds: NewInt64(0), + Containers: []corev1.Container{ + { + Name: "fastcgi-helloserver", + Image: "ingress-controller/fastcgi-helloserver:dev", + Env: []corev1.EnvVar{}, + Ports: []corev1.ContainerPort{ + { + Name: "fastcgi", + ContainerPort: 9000, + }, + }, + }, + }, + }, + }, + }, + } + + d, err := f.EnsureDeployment(deployment) + Expect(err).NotTo(HaveOccurred()) + Expect(d).NotTo(BeNil(), "expected a fastcgi-helloserver deployment") + + err = WaitForPodsReady(f.KubeClientSet, DefaultTimeout, int(replicas), f.Namespace, metav1.ListOptions{ + LabelSelector: fields.SelectorFromSet(fields.Set(d.Spec.Template.ObjectMeta.Labels)).String(), + }) + Expect(err).NotTo(HaveOccurred(), "failed to wait for to become ready") + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fastcgi-helloserver", + Namespace: f.Namespace, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "fastcgi", + Port: 9000, + TargetPort: intstr.FromInt(9000), + Protocol: "TCP", + }, + }, + Selector: map[string]string{ + "app": "fastcgi-helloserver", + }, + }, + } + + f.EnsureService(service) +} diff --git a/test/e2e/run.sh b/test/e2e/run.sh index a233560b8..f7f999674 100755 --- a/test/e2e/run.sh +++ b/test/e2e/run.sh @@ -55,6 +55,7 @@ kubectl config set-context kubernetes-admin@${KIND_CLUSTER_NAME} echo "[dev-env] building container" make -C ${DIR}/../../ build container make -C ${DIR}/../../ e2e-test-image +make -C ${DIR}/../../images/fastcgi-helloserver/ build container # Remove after https://github.com/kubernetes/ingress-nginx/pull/4271 is merged docker tag ${REGISTRY}/nginx-ingress-controller-${ARCH}:${TAG} ${REGISTRY}/nginx-ingress-controller:${TAG} @@ -62,6 +63,7 @@ docker tag ${REGISTRY}/nginx-ingress-controller-${ARCH}:${TAG} ${REGISTRY}/nginx echo "[dev-env] copying docker images to cluster..." kind load docker-image --name="${KIND_CLUSTER_NAME}" nginx-ingress-controller:e2e kind load docker-image --name="${KIND_CLUSTER_NAME}" ${REGISTRY}/nginx-ingress-controller:${TAG} +kind load docker-image --name="${KIND_CLUSTER_NAME}" ${REGISTRY}/fastcgi-helloserver:${TAG} echo "[dev-env] running e2e tests..." make -C ${DIR}/../../ e2e-test