From 718f52919270611b5c9c5eb2835156822837dc56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Mass=C3=A9?= Date: Thu, 14 Sep 2017 20:02:33 +0200 Subject: [PATCH] first commit --- LICENSE | 21 +++++ README.md | 47 +++++++++++ apicast-module/README.md | 54 +++++++++++++ apicast-module/dynamic-router-upstream.conf | 11 +++ apicast-module/dynamic-router.conf | 24 ++++++ apicast-module/dynamic-router.lua | 89 +++++++++++++++++++++ catalog/README.md | 65 +++++++++++++++ catalog/catalog.conf | 35 ++++++++ catalog/catalog.lua | 22 +++++ catalog/config.json | 33 ++++++++ 10 files changed, 401 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 apicast-module/README.md create mode 100644 apicast-module/dynamic-router-upstream.conf create mode 100644 apicast-module/dynamic-router.conf create mode 100644 apicast-module/dynamic-router.lua create mode 100644 catalog/README.md create mode 100644 catalog/catalog.conf create mode 100644 catalog/catalog.lua create mode 100644 catalog/config.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3153fe0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Nicolas MASSE + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfa620f --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Dynamic Routing for Apicast + +## Introduction + +This project provides a dynamic routing module for Apicast. It routes the +API request to the appropriate backend, based on an HTTP Header of the request. + +A sample use case could be: + - A Load Balancer in front of Apicast identifies the source of the request (`internal`/`external` or `dev`/`prod`) + - The LB add the corresponding header (`x-env: dev` or `x-env: prod` for instance) + - Based on this header, the Apicast routes the API request to the corresponding backend + +The API Backends are discovered by querying a Service Catalog. A sample service +catalog is given with this project. + +It is designed to be hosted on Apicast itself (or any nginx instance) in order +to simplify the deployment. + +## Deployment + +Put `dynamic-router.conf` in `/opt/app-root/src/apicast.d/dynamic-router.conf`: +``` +oc create configmap apicast.d --from-file=apicast-module/dynamic-router.conf +oc volume dc/apicast-staging --add --name=apicastd --mount-path /opt/app-root/src/apicast.d/ --type=configmap --configmap-name=apicast.d +``` + +Put `dynamic-router-upstream.conf` and `catalog.conf` in `/opt/app-root/src/sites.d/`: +``` +oc create configmap sites.d --from-file=apicast-module/dynamic-router-upstream.conf --from-file=catalog/catalog.conf +oc volume dc/apicast-staging --add --name=sitesd --mount-path /opt/app-root/src/sites.d/ --type=configmap --configmap-name=sites.d +``` + +Put `catalog.lua` and `dynamic-router.lua` in `/opt/app-root/src/src/custom/`: +``` +oc create configmap apicast-custom-module --from-file=apicast-module/dynamic-router.lua --from-file=catalog/catalog.lua +oc volume dc/apicast-staging --add --name=apicast-custom-module --mount-path /opt/app-root/src/src/custom/ --type=configmap --configmap-name=apicast-custom-module +``` + +Set the configuration required by the catalog and the dynamic routing module as environment variables and re-deploy apicast: +``` +oc env dc/apicast-staging APICAST_CUSTOM_CONFIG=custom/dynamic-router +oc env dc/apicast-staging DYNAMIC_ROUTER_CATALOG_URL=http://127.0.0.1:8082 +oc env dc/apicast-staging DYNAMIC_ROUTER_ENVIRONMENT_HEADER_NAME=x-env +oc rollout latest apicast-staging +``` + +Once, you get it to work on `apicast-staging`, you can do the same on `apicast-production`. diff --git a/apicast-module/README.md b/apicast-module/README.md new file mode 100644 index 0000000..81560d9 --- /dev/null +++ b/apicast-module/README.md @@ -0,0 +1,54 @@ +# A dynamic routing module for Apicast + +## Introduction + +This project provides a dynamic routing module for Apicast. It routes the +API request to the appropriate backend, based on an HTTP Header of the request. + +A sample use case could be: + - A Load Balancer identifies the source of the request (internal / external for instance) + - The LB add the corresponding header (`x-env: dev` or `x-env: prod`) + - Based on this header, the Apicast routes the API request to the corresponding backend + +The API Backends are discovered by querying a Service Catalog. A sample service +catalog is given with this project. + +## Development + +First of all, setup your development environment as explained [here](https://github.com/3scale/apicast/tree/master#development--testing). + +Then, issue the following commands: +``` +git clone https://github.com/nmasse-itix/apicast-dynamic-router.git +git clone https://github.com/3scale/apicast.git +cd apicast +luarocks make apicast/*.rockspec --local +ln -s $PWD/../apicast-dynamic-router/apicast-module/dynamic-router.conf apicast/apicast.d/dynamic-router.conf +ln -s $PWD/../apicast-dynamic-router/apicast-module/dynamic-router-upstream.conf apicast/sites.d/dynamic-router-upstream.conf +mkdir -p custom +ln -s $PWD/../apicast-dynamic-router/apicast-module/dynamic-router.lua custom/dynamic-router.lua +``` + +Configure your apicast as explained [here](https://github.com/3scale/apicast/blob/master/doc/parameters.md) +and [here](https://github.com/3scale/apicast/blob/master/doc/configuration.md). +``` +export APICAST_CUSTOM_CONFIG=custom/dynamic-router +export DYNAMIC_ROUTER_CATALOG_URL=http://127.0.0.1:8082 +export DYNAMIC_ROUTER_ENVIRONMENT_HEADER_NAME=x-env +``` + +Finally, launch apicast: +``` +bin/apicast -i 0 -m off +``` + +## Testing + +The default catalog (`catalog.lua`) and the default configuration (`config.json`) +provide a few examples that you can test: +``` +curl -D - http://localhost:8080/echo +curl -D - http://localhost:8080/echo -H "x-env: prod" +curl -D - http://localhost:8080/echo -H "x-env: dev" +curl -D - http://localhost:8080/echo -H "x-env: bogus" +``` diff --git a/apicast-module/dynamic-router-upstream.conf b/apicast-module/dynamic-router-upstream.conf new file mode 100644 index 0000000..51dc04b --- /dev/null +++ b/apicast-module/dynamic-router-upstream.conf @@ -0,0 +1,11 @@ + +upstream catalog_upstream { + server 0.0.0.1:1; + + balancer_by_lua_block { + local balancer = require "balancer" + balancer.call() + } + + keepalive 1024; +} diff --git a/apicast-module/dynamic-router.conf b/apicast-module/dynamic-router.conf new file mode 100644 index 0000000..f63d7aa --- /dev/null +++ b/apicast-module/dynamic-router.conf @@ -0,0 +1,24 @@ +location = /dummy { + set $catalog_url "http://catalog_upstream"; + set $catalog_host "catalog_upstream"; + set $service_name "dummy"; + set $environment "bogus"; +} + +location = /dynamic-router { + internal; + + set $path /catalog/services/$service_name/environments/$environment/target; + proxy_pass $catalog_url$path; + proxy_pass_request_headers off; + proxy_pass_request_body off; + proxy_http_version 1.1; + proxy_set_header Host "$catalog_host"; + proxy_set_header Connection ""; + proxy_set_header Content-Length ""; + + #rewrite_by_lua_block { + # ngx.log(ngx.WARN, "service_name = " .. ngx.var.service_name) + # ngx.var.real_url = ngx.var.catalog_url .. ngx.var.path + #} +} diff --git a/apicast-module/dynamic-router.lua b/apicast-module/dynamic-router.lua new file mode 100644 index 0000000..1dec4f4 --- /dev/null +++ b/apicast-module/dynamic-router.lua @@ -0,0 +1,89 @@ +local resty_resolver = require 'resty.resolver' +local resty_url = require 'resty.url' + +local _M = { } + +local service_catalog_url +local environment_header_name + +function _M.setup(proxy) + -- In case of error during initialization, we will fallback to the default behavior + local error = false + + -- Get configuration from Environment Variables + service_catalog_url = os.getenv('DYNAMIC_ROUTER_CATALOG_URL') + if (service_catalog_url == nil) then + ngx.log(ngx.ERR, "No environment variable DYNAMIC_ROUTER_CATALOG_URL.") + error = true + else + ngx.log(ngx.INFO, "Using the catalog at " .. (service_catalog_url or "nil")) + end + + environment_header_name = os.getenv('DYNAMIC_ROUTER_ENVIRONMENT_HEADER_NAME') + if (environment_header_name == nil) then + ngx.log(ngx.ERR, "No environment variable DYNAMIC_ROUTER_ENVIRONMENT_HEADER_NAME.") + error = true + else + ngx.log(ngx.INFO, "Using the header " .. (environment_header_name or "nil") .. " as environment") + end + + if not error then + -- Update the Proxy Metatable with our custom function + proxy.get_upstream = get_upstream + else + ngx.log(ngx.ERR, "Errors during initialization. Dynamic Routing disabled.") + end +end + +function get_upstream(service) + service = service or ngx.ctx.service + ngx.log(ngx.DEBUG, "Dynamically routing service " .. (service.id or "none")) + + local environment = ngx.req.get_headers()[string.lower(environment_header_name)] + if (environment == nil) then + ngx.log(ngx.WARN, "No header " .. environment_header_name .. " found, defaulting to '_default'.") + environment = "_default" + end + + -- Split the Catalog URL into components + local url = resty_url.split(service_catalog_url) + local scheme, _, _, server, port, path = + url[1], url[2], url[3], url[4], url[5] or resty_url.default_port(url[1]), url[6] or '' + + -- Resolve the DNS name of the Catalog Server + ngx.ctx.catalog_upstream = resty_resolver:instance():get_servers(server, { port = port }) + + -- Share those variables with the Nginx sub-request + local subrequest_vars = { + catalog_url = service_catalog_url, + catalog_host = server, + service_name = service.id, + environment = environment + } + ngx.log(ngx.INFO, 'Querying the Service Catalog at ', service_catalog_url, " for service id ", service.id, " and environment ", environment) + local res = ngx.location.capture("/dynamic-router", { vars = subrequest_vars, ctx = { catalog_upstream = ngx.ctx.catalog_upstream } }) + + local new_backend + if (res.status == 200) then + new_backend = res.body + ngx.log(ngx.INFO, "Found a backend for service " .. (service.id or "none") .. ": " .. new_backend) + else + -- In case we cannot get a positive answer from the Service Catalog, use the default API Backend. + new_backend = service.api_backend + ngx.log(ngx.ERR, "Could not get a positive response from the service catalog for service " .. (service.id or "none") .. ": HTTP " .. res.status) + end + + -- Split the new Backend URL into components + local url = resty_url.split(new_backend) + local scheme, _, _, server, port, path = + url[1], url[2], url[3], url[4], url[5] or resty_url.default_port(url[1]), url[6] or '' + + return { + server = server, + host = service.hostname_rewrite or server, + uri = scheme .. '://upstream' .. path, + port = tonumber(port) + } +end + +return _M diff --git a/catalog/README.md b/catalog/README.md new file mode 100644 index 0000000..d18183f --- /dev/null +++ b/catalog/README.md @@ -0,0 +1,65 @@ +# A "Self-Contained" Service Catalog for Apicast + +## Introduction + +This project provides a Service Catalog (list of services with their associated +environments and the matching URLs. + +It is designed to be hosted on Apicast itself (or any nginx instance) in order +to simplify the deployment. + +## How it works + +API Services, Environments and their backends are declared in the `catalog.lua`. +The service catalog listens on port 8082. + +You can query the Service Catalog by issuing an HTTP Request: +``` +GET /catalog/services/{service_id}/environments/{environment}/target +``` + +For instance, to query the `prod` environment of service `123`: +``` +curl -D - http://localhost:8082/catalog/services/123/environments/prod/target +``` + +## Development + +First of all, setup your development environment as explained [here](https://github.com/3scale/apicast/tree/master#development--testing). + +Then, issue the following commands: +``` +git clone https://github.com/nmasse-itix/apicast-dynamic-router.git +git clone https://github.com/3scale/apicast.git +cd apicast +luarocks make apicast/*.rockspec --local +ln -s $PWD/../apicast-dynamic-router/catalog/catalog.conf apicast/sites.d/catalog.conf +ln -s $PWD/../apicast-dynamic-router/catalog/config.json config.json +mkdir -p custom +ln -s $PWD/../apicast-dynamic-router/catalog/catalog.lua custom/catalog.lua +``` + +Configure your apicast as explained [here](https://github.com/3scale/apicast/blob/master/doc/parameters.md) +and [here](https://github.com/3scale/apicast/blob/master/doc/configuration.md). +``` +export THREESCALE_DEPLOYMENT_ENV=sandbox +export THREESCALE_CONFIG_FILE=config.json +export APICAST_LOG_LEVEL=debug +``` + +Finally, launch apicast: +``` +bin/apicast -i 0 -m off +``` + +## Testing + +The default catalog (`catalog.lua`) provides a few examples that you can test: +``` +curl -D - http://localhost:8082/catalog/services/123/environments/prod/target +curl -D - http://localhost:8082/catalog/services/123/environments/dev/target +curl -D - http://localhost:8082/catalog/services/123/environments/bogus/target +curl -D - http://localhost:8082/catalog/services/456/environments/prod/target +curl -D - http://localhost:8082/catalog/services/456/environments/dev/target +curl -D - http://localhost:8082/catalog/services/456/environments/bogus_but_should_work/target +``` diff --git a/catalog/catalog.conf b/catalog/catalog.conf new file mode 100644 index 0000000..9e3486c --- /dev/null +++ b/catalog/catalog.conf @@ -0,0 +1,35 @@ +server { + listen 8082; + + location ~ ^/catalog/services/([^/]+)/environments/([^/]+)/target$ { + content_by_lua_block { + local service = ngx.var[1] + local variant = ngx.var[2] + ngx.log(ngx.DEBUG, "Service Catalog request for service = " .. (ngx.var[1] or "") .. " and variant = " .. (ngx.var[2] or "")) + + local catalog = require "custom/catalog" + if (catalog[service] ~= nil) then + local variants = catalog[service] + if (variants[variant] ~= nil) then + ngx.print(variants[variant]) + ngx.exit(ngx.HTTP_OK) + else + -- is there a default environment ? + if (variants._default ~= nil) then + ngx.print(variants._default) + ngx.exit(ngx.HTTP_OK) + else + ngx.status = 404 -- Status needs to be set before the body + ngx.print("") -- Prevent default NGINX Error Page + ngx.exit(ngx.HTTP_NOT_FOUND) + end + end + else + ngx.status = 404 -- Status needs to be set before the body + ngx.print("") -- Prevent default NGINX Error Page + ngx.exit(ngx.HTTP_NOT_FOUND) + end + } + } + +} diff --git a/catalog/catalog.lua b/catalog/catalog.lua new file mode 100644 index 0000000..dc45c61 --- /dev/null +++ b/catalog/catalog.lua @@ -0,0 +1,22 @@ +return { + -- First service + ["123"] = { + -- Prod environment + prod = "https://echo-api.3scale.net", + + -- Dev environment + dev = "http://echo-api.3scale.net" + }, + + -- Second service + ["456"] = { + -- Prod environment + prod = "http://prod.myservice.corp", + + -- Dev environment + dev = "http://dev.myservice.corp", + + -- Default variant (if nothing matches): sandbox environment + _default = "http://sandbox.myservice.corp" + } +} diff --git a/catalog/config.json b/catalog/config.json new file mode 100644 index 0000000..c797e5b --- /dev/null +++ b/catalog/config.json @@ -0,0 +1,33 @@ +{ + "id": 1234567890987, + "provider_key": "provider-key", + "services": [ + { + "id": 123, + "backend_version": "1", + "proxy": { + "api_backend": "http://127.0.0.1:8081", + "hosts": [ + "localhost", + "127.0.0.1" + ], + "backend": { + "endpoint": "http://127.0.0.1:8081", + "host": "backend" + }, + "auth_user_key": "user_key", + "credentials_location": "query", + "proxy_rules": [ + { + "http_method": "GET", + "pattern": "/", + "metric_system_name": "hits", + "delta": 1, + "parameters": [], + "querystring_parameters": {} + } + ] + } + } + ] +}