mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-21 09:03:02 +00:00
feat: add proxy
This commit is contained in:
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Dockerfile
|
||||||
|
*.crt
|
||||||
|
*.key
|
||||||
17
.editorconfig
Normal file
17
.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# EditorConfig helps developers define and maintain consistent coding styles between different editors and IDEs
|
||||||
|
# editorconfig.org
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# We recommend you to keep these unchanged
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
lib/
|
||||||
|
start.sh
|
||||||
|
.idea/
|
||||||
|
node_modules/
|
||||||
|
docs/
|
||||||
52
.gitlab-ci.yml
Normal file
52
.gitlab-ci.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
image: registry.gitlab.com/openstapps/projectmanagement/node:latest
|
||||||
|
|
||||||
|
cache:
|
||||||
|
key: ${CI_COMMIT_REF_SLUG}
|
||||||
|
paths:
|
||||||
|
- lib
|
||||||
|
- node_modules
|
||||||
|
|
||||||
|
before_script:
|
||||||
|
- npm install
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- test
|
||||||
|
- publish
|
||||||
|
|
||||||
|
build:
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- npm run build
|
||||||
|
artifacts:
|
||||||
|
untracked: true
|
||||||
|
paths:
|
||||||
|
- node_modules/
|
||||||
|
only:
|
||||||
|
- /(^v[0-9]+\.[0-9]+\.[0-9]+$|^master$|^develop$)/
|
||||||
|
tags:
|
||||||
|
- docker
|
||||||
|
|
||||||
|
test:
|
||||||
|
stage: test
|
||||||
|
dependencies:
|
||||||
|
- build
|
||||||
|
script:
|
||||||
|
- npm test
|
||||||
|
tags:
|
||||||
|
- docker
|
||||||
|
|
||||||
|
publish:
|
||||||
|
stage: publish
|
||||||
|
variables:
|
||||||
|
DOCKER_DRIVER: overlay2
|
||||||
|
services:
|
||||||
|
- docker:dind
|
||||||
|
script:
|
||||||
|
- docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN $CI_REGISTRY
|
||||||
|
- docker build -t $CI_REGISTRY_IMAGE/$REGISTRY_BRANCH/$CI_COMMIT_REF_NAME:latest .
|
||||||
|
- docker push $CI_REGISTRY_IMAGE/$REGISTRY_BRANCH/$CI_COMMIT_REF_NAME:latest
|
||||||
|
only:
|
||||||
|
- /(^v[0-9]+\.[0-9]+\.[0-9]+$|^master$|^develop$)/
|
||||||
|
tags:
|
||||||
|
- docker
|
||||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM registry.gitlab.com/openstapps/projectmanagement/node
|
||||||
|
|
||||||
|
ADD . /app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --update nginx && \
|
||||||
|
rm -rf /var/cache/apk/* && \
|
||||||
|
mv /app/nginx.conf /etc/nginx/
|
||||||
|
|
||||||
|
CMD ["sh", "./bin/run-docker.sh"]
|
||||||
69
README.md
Normal file
69
README.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# proxy
|
||||||
|
This proxy is based on NGINX. The NGINX configuration is generated by a simple Node.js script which parses
|
||||||
|
the docker socket. NGINX acts as a reverse proxy server. The Node.js script reads the docker socket file to generate
|
||||||
|
a configuration for each running docker container. The base template for configuration is `nginx.conf.template`
|
||||||
|
All `*.template` files are written with [mustache-js](https://github.com/janl/mustache.js "GitHub") syntax.
|
||||||
|
The templates are assembled by the Node.js program.
|
||||||
|
|
||||||
|
## Docker Mapping
|
||||||
|
The Node.js script reads out the `/var/run/docker.sock` to get the containers of the host system via
|
||||||
|
[dockerode](https://github.com/apocas/dockerode "GitHub").
|
||||||
|
|
||||||
|
**Important:**
|
||||||
|
The proxy expects your backend containers to provide following structure:
|
||||||
|
* `stapps.version`-label of docker container to be set to a valid active version. See configuration....
|
||||||
|
* Service name for the backend container should be `backend` in docker-compose.yml. If you don't use docker-compose
|
||||||
|
set `com.docker.compose.service`-label to `backend`.
|
||||||
|
* The proxy container to run with `--net="host"`
|
||||||
|
* A port exposed to the host machine. If you want to expose it only to the host machines internal loopback use following
|
||||||
|
syntax: `127.0.0.1:3000-3500:3000` in docker-compose or docker ports configuration. This will attach the internal 3000
|
||||||
|
port to the host's loopback on any port between 3000-3500. The proxy will see in the docker.sock which
|
||||||
|
port and ip was chosen. Internal loopback should be 127.0.0.1 tho.
|
||||||
|
|
||||||
|
## Configuration (Status Codes)
|
||||||
|
Config files can be added by multiple universities (adding files like `config/default-b-tu`) and selected via the
|
||||||
|
`NODE_APP_INSTANCE` environment variable.
|
||||||
|
|
||||||
|
- OutdatedVersions return a `HTTP 404`
|
||||||
|
- ActiveVersions return a `HTTP 503` if currently unavailable or the given code by running backend-node
|
||||||
|
- Unsupported versions (not configured as outdated or active) return a `HTTP 404`
|
||||||
|
- No version header given returns a `HTTP 300`
|
||||||
|
|
||||||
|
**NOTE:** The default configuration expects the client to set a version header: `X-StApps-Version=<version of app>`
|
||||||
|
|
||||||
|
## Logger
|
||||||
|
The proxy uses [@stapps/logger](https://gitlab.tubit.tu-berlin.de/stapps/logger). You can provide `NODE_ENV=production`
|
||||||
|
and SMTP-Configuration via environment-variables for monitoring in production use.
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
Without ssl:
|
||||||
|
```sh
|
||||||
|
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock --net="host" gitlab-registry.tubit.tu-berlin.de/stapps/proxy/master
|
||||||
|
```
|
||||||
|
|
||||||
|
With ssl:
|
||||||
|
```sh
|
||||||
|
docker run --rm --net="host" \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v <path to *.crt-file>:/etc/nginx/certs/ssl.crt \
|
||||||
|
-v <path to *.key-file>:/etc/nginx/certs/ssl.key \
|
||||||
|
gitlab-registry.tubit.tu-berlin.de/stapps/proxy/master
|
||||||
|
```
|
||||||
|
|
||||||
|
# Static Folder (docker run option: `-v <path to static folder>:/static`)
|
||||||
|
Der Zugriff erfolgt über: `http(s)://<url>/_static/<path-to-file>`
|
||||||
|
|
||||||
|
Beispiel Proxy auf Localhost für die Datei `static/test.json`: `http://localhost/_static/test.json`
|
||||||
|
|
||||||
|
## Bilder im Static Folder
|
||||||
|
Bilder sollten folgendermaßen abgelegt und benannt werden:
|
||||||
|
|
||||||
|
`<pfad analog zur imageURL aus der App>/<type>/<uid>-<size>.[jpg|png|gif]`
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
`_static/images/Place/hautpmensa-thumbnail.jpg`
|
||||||
|
|
||||||
|
Aufgerufen wird das mit:
|
||||||
|
'https://server.deiner.uni.de/_static/images/Place/hauptmensa-thumbnail'
|
||||||
|
|
||||||
|
Bildgrößen sind: thumbnail, small, medium, large
|
||||||
2
bin/run-docker.sh
Executable file
2
bin/run-docker.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
nginx &
|
||||||
|
node ./lib/cli.js
|
||||||
12
config/default.ts
Normal file
12
config/default.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import {ConfigFile} from '../src/common';
|
||||||
|
|
||||||
|
const config: ConfigFile = {
|
||||||
|
activeVersions: ['1\\.0\\.\\d+'],
|
||||||
|
hiddenRoutes: ['/bulk'],
|
||||||
|
outdatedVersions: ['0\\.8\\.\\d+', '0\\.5\\.\\d+', '0\\.6\\.\\d+', '0\\.7\\.\\d+'],
|
||||||
|
output: '/etc/nginx/conf.d/default.conf',
|
||||||
|
sslFiles: ['/etc/nginx/certs/ssl.crt', '/etc/nginx/certs/ssl.key'],
|
||||||
|
visibleRoutes: ['/search', '/search/multi', '/', '/availabilityCreativework', '/feedback'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
6
fixtures/cors.template
Normal file
6
fixtures/cors.template
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
add_header 'Access-Control-Allow-Origin' '*';
|
||||||
|
add_header 'Access-Control-Allow-Credentials' 'true';
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,X-StApps-Version';
|
||||||
|
add_header 'Access-Control-Max-Age' 1728000;
|
||||||
|
add_header 'Content-Type' 'text/plain charset=UTF-8';
|
||||||
8
fixtures/hiddenRoute.template
Normal file
8
fixtures/hiddenRoute.template
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
location {{{ route }}} {
|
||||||
|
|
||||||
|
# use our custom request limit and allow bursts
|
||||||
|
# deliver them with no queuing delay
|
||||||
|
limit_req zone=customstappslimit burst=20;
|
||||||
|
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
40
fixtures/staticRoute.template
Normal file
40
fixtures/staticRoute.template
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
location /_static/ {
|
||||||
|
|
||||||
|
# use our custom request limit and allow bursts
|
||||||
|
limit_req zone=customstappslimit burst=20 nodelay;
|
||||||
|
|
||||||
|
{{{ cors }}}
|
||||||
|
|
||||||
|
if ($request_method = 'OPTIONS') {
|
||||||
|
{{{ cors }}}
|
||||||
|
add_header 'Content-Length' '0';
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
alias /static/;
|
||||||
|
expires 7d;
|
||||||
|
## Check for file existing and if there, stop ##
|
||||||
|
if (-f $request_filename) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
## Check for file existing and if there, stop ##
|
||||||
|
if (-d $request_filename) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
##Check for png
|
||||||
|
if (-e $request_filename.png) {
|
||||||
|
rewrite ^/(.*)$ /$1.png;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
##Check for gif
|
||||||
|
if (-e $request_filename.gif) {
|
||||||
|
rewrite ^/(.*)$ /$1.gif;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
##Check for jpg
|
||||||
|
if (-e $request_filename.jpg) {
|
||||||
|
rewrite ^/(.*)$ /$1.jpg;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
##Fallback rule if no match is found
|
||||||
|
rewrite ^/(.*/).*(small|medium|large|thumbnail)$ /$1default-$2.png;
|
||||||
|
}
|
||||||
41
fixtures/visibleRoute.template
Normal file
41
fixtures/visibleRoute.template
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
location {{{ route }}} {
|
||||||
|
|
||||||
|
# use our custom request limit and allow bursts
|
||||||
|
# deliver them with no queuing delay
|
||||||
|
limit_req zone=customstappslimit burst=20 nodelay;
|
||||||
|
|
||||||
|
# intercept OPTIONS request
|
||||||
|
# all other CORS headers are set by the backend(s)
|
||||||
|
# see https://gist.github.com/michiel/1064640/0dafeb1e8f71a26b94ea15e09e7e5f45bed14dda
|
||||||
|
if ($request_method = 'OPTIONS') {
|
||||||
|
{{{ cors }}}
|
||||||
|
add_header 'Content-Length' '0';
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
|
||||||
|
# if client doesn't set a version header, we don't know which backend to choose
|
||||||
|
if ($http_x_stapps_version = "") {
|
||||||
|
# return Multiple Choices
|
||||||
|
return 300 'You have to supply a client/app version via the X-StApps-Version header!';
|
||||||
|
}
|
||||||
|
|
||||||
|
# Version is unsupported or never existed
|
||||||
|
if ($proxyurl = unsupported) {
|
||||||
|
{{{ cors }}}
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
# The version existed, but is outdated now (App should update)
|
||||||
|
if ($proxyurl = outdated) {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
# The version is correct, but backend is not responding
|
||||||
|
if ($proxyurl = unavailable) {
|
||||||
|
{{{ cors }}}
|
||||||
|
return 503;
|
||||||
|
}
|
||||||
|
limit_except GET OPTIONS POST {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
# backend is available
|
||||||
|
proxy_pass http://$proxyurl;
|
||||||
|
}
|
||||||
29
nginx.conf
Normal file
29
nginx.conf
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
worker_processes 1;
|
||||||
|
|
||||||
|
error_log stderr;
|
||||||
|
pid nginx.pid;
|
||||||
|
daemon off;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
http {
|
||||||
|
include mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
access_log /dev/stdout;
|
||||||
|
|
||||||
|
proxy_temp_path /tmp/proxy;
|
||||||
|
client_body_temp_path /tmp/body;
|
||||||
|
fastcgi_temp_path /tmp/fastcgi;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
#tcp_nopush on;
|
||||||
|
|
||||||
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*;
|
||||||
|
}
|
||||||
17
nginx.conf.template
Normal file
17
nginx.conf.template
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{{{ dockerVersionMap }}}
|
||||||
|
|
||||||
|
# create a custom request limit zone which can handle 160,000 IP-Addresses at the same time
|
||||||
|
# routes using this limit zone will limit each client to not send more than one request in 50ms
|
||||||
|
# be sure to use burst handling when needed, because most clients will fire some requests in parallel
|
||||||
|
limit_req_zone $binary_remote_addr zone=customstappslimit:10m rate=20r/s;
|
||||||
|
|
||||||
|
server {
|
||||||
|
{{{ listener }}}
|
||||||
|
|
||||||
|
{{{ visibleRoutes }}}
|
||||||
|
|
||||||
|
{{{ hiddenRoutes }}}
|
||||||
|
|
||||||
|
{{{ staticRoute }}}
|
||||||
|
}
|
||||||
|
|
||||||
2776
package-lock.json
generated
Normal file
2776
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
56
package.json
Normal file
56
package.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "@openstapps/proxy",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Nginx proxy that is dynamically configured by a Node.js script",
|
||||||
|
"main": "./lib/cli.js",
|
||||||
|
"dependencies": {
|
||||||
|
"@openstapps/logger": "0.0.5",
|
||||||
|
"@types/config": "0.0.34",
|
||||||
|
"@types/dockerode": "2.5.10",
|
||||||
|
"@types/sha1": "1.1.1",
|
||||||
|
"config": "3.0.1",
|
||||||
|
"fs-extra": "7.0.1",
|
||||||
|
"mustache": "3.0.1",
|
||||||
|
"sha1": "1.1.1",
|
||||||
|
"typescript": "3.2.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@openstapps/configuration": "0.5.0",
|
||||||
|
"@types/chai": "4.1.7",
|
||||||
|
"@types/mustache": "0.8.32",
|
||||||
|
"@types/sinon": "7.0.3",
|
||||||
|
"chai": "4.2.0",
|
||||||
|
"conventional-changelog-cli": "2.0.11",
|
||||||
|
"dockerode": "2.5.8",
|
||||||
|
"mocha": "5.2.0",
|
||||||
|
"mocha-typescript": "1.1.17",
|
||||||
|
"prepend-file-cli": "1.0.6",
|
||||||
|
"rimraf": "2.6.3",
|
||||||
|
"sinon": "7.2.2",
|
||||||
|
"ts-node": "7.0.1",
|
||||||
|
"tslint": "5.12.1",
|
||||||
|
"typedoc": "0.14.1"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "npm run tslint && npm run compile && npm run documentation",
|
||||||
|
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0 && git add CHANGELOG.md && git commit -m 'docs: update changelog'",
|
||||||
|
"check-configuration": "openstapps-configuration",
|
||||||
|
"compile": "rimraf lib && tsc && prepend lib/cli.js '#!/usr/bin/env node\n'",
|
||||||
|
"documentation": "typedoc --includeDeclarations --excludeExternals --mode modules --out docs src",
|
||||||
|
"prepublishOnly": "npm run build",
|
||||||
|
"tslint": "tslint 'src/**/*.ts'",
|
||||||
|
"test": "node_modules/.bin/mocha --opts test/mocha.opts"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git@gitlab.com:openstapps/proxy.git"
|
||||||
|
},
|
||||||
|
"author": "Anselm Stordeur <anselmstordeur@gmail.com>",
|
||||||
|
"contributors": [
|
||||||
|
"André Bierlein <andre.mt.bierlein@gmail.com>",
|
||||||
|
"Karl-Philipp Wulfert <krlwlfrt@gmail.com>",
|
||||||
|
"Benjamin Joeckel",
|
||||||
|
"Jovan Krunic <jovan.krunic@gmail.com>"
|
||||||
|
],
|
||||||
|
"license": "AGPL-3.0-only"
|
||||||
|
}
|
||||||
65
src/cli.ts
Normal file
65
src/cli.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import {execSync} from 'child_process';
|
||||||
|
import * as config from 'config';
|
||||||
|
import * as Dockerode from 'dockerode';
|
||||||
|
import {readFile, writeFileSync} from 'fs-extra';
|
||||||
|
import {render} from 'mustache';
|
||||||
|
import {ConfigFile, logger} from './common';
|
||||||
|
import {getContainers, getTemplateView} from './main';
|
||||||
|
|
||||||
|
// handle unhandled promise rejections
|
||||||
|
process.on('unhandledRejection', (error: Error) => {
|
||||||
|
logger.error(error.message);
|
||||||
|
logger.info(error.stack);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
let containerHashCache = '';
|
||||||
|
const configFile: ConfigFile = config.util.toObject();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the container information from the docker socket and updates
|
||||||
|
* the nginx config if necessary
|
||||||
|
*
|
||||||
|
* The function will call itself again every 10s
|
||||||
|
*/
|
||||||
|
async function updateNginxConfig() {
|
||||||
|
|
||||||
|
const containers = await getContainers();
|
||||||
|
|
||||||
|
const containerHash = containers.map((container: Dockerode.ContainerInfo) => {
|
||||||
|
return container.Id;
|
||||||
|
}).join(',');
|
||||||
|
|
||||||
|
// if containers changed -> write config file, reload nginx
|
||||||
|
if (containerHash !== containerHashCache) {
|
||||||
|
logger.log('docker container changed');
|
||||||
|
logger.log('Generating new NGINX configuration');
|
||||||
|
|
||||||
|
// render nginx config file
|
||||||
|
const nginxConfig = render(await readFile('nginx.conf.template', 'utf8'), await getTemplateView(containers));
|
||||||
|
|
||||||
|
logger.log(`containers (${containerHash}) matched the configuration.`);
|
||||||
|
|
||||||
|
containerHashCache = containerHash;
|
||||||
|
|
||||||
|
logger.log(`Writing new config file "${configFile.output}"`);
|
||||||
|
// overwrite nginx config file with our rendered one
|
||||||
|
writeFileSync(configFile.output, nginxConfig, 'utf8');
|
||||||
|
|
||||||
|
logger.log('Executing "nginx -s reload" to tell nginx to reload the configuration file');
|
||||||
|
execSync('nginx -s reload');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function forever() {
|
||||||
|
// start the process of dynamic nginx configuration
|
||||||
|
updateNginxConfig().then(() => {
|
||||||
|
// check for changes again in 10 seconds
|
||||||
|
setTimeout(forever, 10000);
|
||||||
|
}).catch((err) => {
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// start the process that checks the docker socket periodically
|
||||||
|
forever();
|
||||||
28
src/common.ts
Normal file
28
src/common.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import {Logger} from '@openstapps/logger';
|
||||||
|
import {SMTP} from '@openstapps/logger/lib/SMTP';
|
||||||
|
|
||||||
|
// use SMTP as a default monitoring system for logger.error();
|
||||||
|
export const logger = new Logger(SMTP.getInstance());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A representation of the config file
|
||||||
|
*/
|
||||||
|
export interface ConfigFile {
|
||||||
|
activeVersions: string[];
|
||||||
|
hiddenRoutes: string[];
|
||||||
|
outdatedVersions: string[];
|
||||||
|
output: string;
|
||||||
|
sslFiles: string[];
|
||||||
|
visibleRoutes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A view object to render the nginx config template
|
||||||
|
*/
|
||||||
|
export interface TemplateView {
|
||||||
|
dockerVersionMap: string;
|
||||||
|
hiddenRoutes: string;
|
||||||
|
listener: string;
|
||||||
|
staticRoute: string;
|
||||||
|
visibleRoutes: string;
|
||||||
|
}
|
||||||
196
src/main.ts
Normal file
196
src/main.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import * as config from 'config';
|
||||||
|
import * as Dockerode from 'dockerode';
|
||||||
|
import {existsSync, readFile} from 'fs-extra';
|
||||||
|
import {render} from 'mustache';
|
||||||
|
import {join} from 'path';
|
||||||
|
import {ConfigFile, logger, TemplateView} from './common';
|
||||||
|
|
||||||
|
const configFile: ConfigFile = config.util.toObject();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a ContainerInfo matches a name and version regex
|
||||||
|
* @param {string} name
|
||||||
|
* @param {RegExp} versionRegex
|
||||||
|
* @param {Dockerode.ContainerInfo} container
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
export function containerMatchesRegex(name: string, versionRegex: RegExp, container: Dockerode.ContainerInfo): boolean {
|
||||||
|
return typeof container.Labels['stapps.version'] === 'string' &&
|
||||||
|
container.Labels['stapps.version'].match(versionRegex) !== null &&
|
||||||
|
typeof container.Labels['com.docker.compose.service'] === 'string' &&
|
||||||
|
container.Labels['com.docker.compose.service'] === name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets Gateway (ip:port) of given ContainerInfo. Returns an empty String if there is no Gateway.
|
||||||
|
* This assumes that a backend runs in the container and it exposes one port.
|
||||||
|
* @param {Dockerode.ContainerInfo} container
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
export function getGatewayOfStAppsBackend(container: Dockerode.ContainerInfo): string {
|
||||||
|
|
||||||
|
if (container.Ports.length === 0) {
|
||||||
|
logger.error(
|
||||||
|
'Container',
|
||||||
|
container.Id,
|
||||||
|
'does not advertise any Port. Please expose a Port if the container should be accessible by NGINX.',
|
||||||
|
);
|
||||||
|
return '';
|
||||||
|
} else {
|
||||||
|
// ip:port
|
||||||
|
return container.Ports[0].IP + ':' + container.Ports[0].PublicPort;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an upstream map. It maps all stapps-backend-containers to an gateway
|
||||||
|
* @param {string[]} activeVersions
|
||||||
|
* @param {string[]} outdatedVersions
|
||||||
|
* @param {Dockerode.ContainerInfo[]} containers
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
export function generateUpstreamMap(
|
||||||
|
activeVersions: string[],
|
||||||
|
outdatedVersions: string[],
|
||||||
|
containers: Dockerode.ContainerInfo[],
|
||||||
|
): string {
|
||||||
|
let result = 'map $http_x_stapps_version $proxyurl {\n default unsupported;\n';
|
||||||
|
let upstreams = '';
|
||||||
|
|
||||||
|
let foundMatchingContainer = false;
|
||||||
|
|
||||||
|
// active versions
|
||||||
|
result += activeVersions.map((activeVersionRegex) => {
|
||||||
|
const upstreamName = activeVersionRegex.replace(/[\\|\.|\+]/g, '_');
|
||||||
|
|
||||||
|
const activeBackends = containers.filter((container) => {
|
||||||
|
return containerMatchesRegex('backend', new RegExp(activeVersionRegex), container);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeBackends.length > 0) {
|
||||||
|
|
||||||
|
foundMatchingContainer = true;
|
||||||
|
|
||||||
|
if (activeBackends.length > 1) {
|
||||||
|
throw new Error('Multiple backends for one version found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const gateWayOfContainer = getGatewayOfStAppsBackend(activeBackends[0]);
|
||||||
|
|
||||||
|
if (gateWayOfContainer.length !== 0) {
|
||||||
|
upstreams += `\nupstream ${upstreamName} {\n server ${gateWayOfContainer};\n}`;
|
||||||
|
return ` \"~${activeVersionRegex}\" ${upstreamName};\n`;
|
||||||
|
} else {
|
||||||
|
return ` \"~${activeVersionRegex}\" unavailable;\n`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error('No backend for version', activeVersionRegex, 'found');
|
||||||
|
return ` \"~${activeVersionRegex}\" unavailable;\n`;
|
||||||
|
}
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// outdated versions
|
||||||
|
result += outdatedVersions.map((outdatedVersionRegex) => {
|
||||||
|
return ` \"~${outdatedVersionRegex}\" outdated;`;
|
||||||
|
}).join('') + '\n\}';
|
||||||
|
|
||||||
|
if (!foundMatchingContainer) {
|
||||||
|
logger.error(
|
||||||
|
'No container with matching version label found. Please start a container with a matching version Label.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${result}${upstreams}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates http or https listener
|
||||||
|
* @param sslFiles
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function generateListener(sslFiles: string[]) {
|
||||||
|
|
||||||
|
function isSSLCert(path: string) {
|
||||||
|
return existsSync(path) && /.*\.crt$/.test(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSSLKey(path: string) {
|
||||||
|
return existsSync(path) && /.*\.key$/.test(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let listener = '';
|
||||||
|
|
||||||
|
if (Array.isArray(sslFiles) && sslFiles.length === 2 && sslFiles.some(isSSLCert) && sslFiles.some(isSSLKey)) {
|
||||||
|
// https listener
|
||||||
|
listener = 'listen 443 ssl default_server;\n' +
|
||||||
|
`ssl_certificate ${sslFiles.find(isSSLCert)};\n` +
|
||||||
|
`ssl_certificate_key ${sslFiles.find(isSSLKey)};\n`;
|
||||||
|
} else {
|
||||||
|
// default http listener
|
||||||
|
listener = 'listen 80 default_server;';
|
||||||
|
}
|
||||||
|
return listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a mustache template file with given view object
|
||||||
|
* @param path (path to file)
|
||||||
|
* @param view
|
||||||
|
* @param callback
|
||||||
|
*/
|
||||||
|
async function renderTemplate(path: string, view: any): Promise<string> {
|
||||||
|
const content = await readFile(path, 'utf8');
|
||||||
|
return render(content, view);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns view for nginx config file
|
||||||
|
* @param containers
|
||||||
|
*/
|
||||||
|
export async function getTemplateView(containers: Dockerode.ContainerInfo[]): Promise<TemplateView> {
|
||||||
|
|
||||||
|
const cors = await readFile('./fixtures/cors.template', 'utf8');
|
||||||
|
|
||||||
|
const visibleRoutesPromises = configFile.visibleRoutes.map((route) => {
|
||||||
|
return renderTemplate(join('fixtures', 'visibleRoute.template'), {
|
||||||
|
cors,
|
||||||
|
route,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hiddenRoutesPromises = configFile.hiddenRoutes.map((route) => {
|
||||||
|
return renderTemplate(join('fixtures', 'hiddenRoute.template'), {
|
||||||
|
cors,
|
||||||
|
route,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dockerVersionMap: generateUpstreamMap(configFile.activeVersions, configFile.outdatedVersions, containers),
|
||||||
|
hiddenRoutes: (await Promise.all(hiddenRoutesPromises)).join(''),
|
||||||
|
listener: generateListener(configFile.sslFiles),
|
||||||
|
staticRoute: await renderTemplate(join('fixtures', 'staticRoute.template'), {cors}),
|
||||||
|
visibleRoutes: (await Promise.all(visibleRoutesPromises)).join(''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the list of docker containers
|
||||||
|
* @param pathToDockerSocket
|
||||||
|
*/
|
||||||
|
export async function getContainers(pathToDockerSocket = '/var/run/docker.sock'): Promise<Dockerode.ContainerInfo[]> {
|
||||||
|
const docker = new Dockerode({
|
||||||
|
socketPath: pathToDockerSocket,
|
||||||
|
});
|
||||||
|
|
||||||
|
const containers = await docker.listContainers();
|
||||||
|
|
||||||
|
if (containers.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
'No running docker containers found.' +
|
||||||
|
`Please check if docker is running and Node.js can access the docker socket (${pathToDockerSocket})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return containers;
|
||||||
|
}
|
||||||
208
test/Main.spec.ts
Normal file
208
test/Main.spec.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import {Logger} from '@openstapps/logger';
|
||||||
|
import {expect} from 'chai';
|
||||||
|
import {ContainerInfo} from 'dockerode';
|
||||||
|
import {slow, suite, test, timeout} from 'mocha-typescript';
|
||||||
|
import * as sinon from 'sinon';
|
||||||
|
import {containerMatchesRegex, generateUpstreamMap, getGatewayOfStAppsBackend} from '../src/main';
|
||||||
|
|
||||||
|
const logger = new Logger();
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (err) => {
|
||||||
|
logger.error('UNHANDLED REJECTION', err.stack);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
@suite(timeout(1000), slow(500))
|
||||||
|
// @ts-ignore
|
||||||
|
class ContainerInfoParsing {
|
||||||
|
|
||||||
|
static anyContainerWithExposedPorts: ContainerInfo;
|
||||||
|
static backendContainerWithExposedPorts: ContainerInfo;
|
||||||
|
|
||||||
|
static before(done: () => void) {
|
||||||
|
// tslint:disable:object-literal-sort-keys
|
||||||
|
this.backendContainerWithExposedPorts = {
|
||||||
|
Id: 'e3d3f4d18aceac2780bdb95523845d066ed25c04fc65168a5ddbd37a85671bb7',
|
||||||
|
Names: [
|
||||||
|
'/deployment_backend_1',
|
||||||
|
],
|
||||||
|
Image: 'gitlab-registry.tubit.tu-berlin.de/stapps/backend/b-tu-typescript-refactor-for-new-tslint-config',
|
||||||
|
ImageID: 'sha256:ef9f0c8c4b6f99dd208948c7aae1d042590aa18e05ebeae4f586e4b4beebeac9',
|
||||||
|
Command: 'node ./bin/www',
|
||||||
|
Created: 1524669882,
|
||||||
|
Ports: [
|
||||||
|
{
|
||||||
|
IP: '127.0.0.1',
|
||||||
|
PrivatePort: 3000,
|
||||||
|
PublicPort: 3000,
|
||||||
|
Type: 'tcp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Labels: {
|
||||||
|
'com.docker.compose.config-hash': '91c6e0cebad15951824162c93392b6880b69599692f07798ae8de659c1616a03',
|
||||||
|
'com.docker.compose.container-number': '1',
|
||||||
|
'com.docker.compose.oneoff': 'False',
|
||||||
|
'com.docker.compose.project': 'deployment',
|
||||||
|
'com.docker.compose.service': 'backend',
|
||||||
|
'com.docker.compose.version': '1.21.0',
|
||||||
|
'stapps.version': '1.0.0',
|
||||||
|
},
|
||||||
|
State: 'running',
|
||||||
|
Status: 'Up 3 minutes',
|
||||||
|
HostConfig: {
|
||||||
|
NetworkMode: 'deployment_default',
|
||||||
|
},
|
||||||
|
NetworkSettings: {
|
||||||
|
Networks: {
|
||||||
|
deployment_default: {
|
||||||
|
IPAMConfig: null,
|
||||||
|
Links: null,
|
||||||
|
Aliases: null,
|
||||||
|
NetworkID: '947ea5247cc7429e1fdebd5404fa4d15f7c05e6765f2b93ddb3bdb6aaffd1193',
|
||||||
|
EndpointID: 'da17549a086ff2c9f622e80de833e6f334afda52c8f07080428640c1716dcd14',
|
||||||
|
Gateway: '172.18.0.1',
|
||||||
|
IPAddress: '172.18.0.3',
|
||||||
|
IPPrefixLen: 16,
|
||||||
|
IPv6Gateway: '',
|
||||||
|
GlobalIPv6Address: '',
|
||||||
|
GlobalIPv6PrefixLen: 0,
|
||||||
|
MacAddress: '03:41:ac:11:00:23',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// tslint:disable:object-literal-sort-keys
|
||||||
|
this.anyContainerWithExposedPorts = {
|
||||||
|
Id: 'e3d3f4d18aceac2780bdb95523845d066ed25c04fc65168a5ddbd37a85671bb7',
|
||||||
|
Names: [
|
||||||
|
'/container_name_1',
|
||||||
|
],
|
||||||
|
Image: 'ubuntu:4',
|
||||||
|
ImageID: 'sha256:ef9f0c8c4b6f99dd208948c7aae1d042590aa18e05ebeae4f586e4b4beebeac9',
|
||||||
|
Command: 'sh',
|
||||||
|
Created: 1524669882,
|
||||||
|
Ports: [
|
||||||
|
{
|
||||||
|
IP: '0.0.0.0',
|
||||||
|
PrivatePort: 80,
|
||||||
|
PublicPort: 80,
|
||||||
|
Type: 'tcp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Labels: {},
|
||||||
|
State: 'running',
|
||||||
|
Status: 'Up 3 minutes',
|
||||||
|
HostConfig: {
|
||||||
|
NetworkMode: 'default',
|
||||||
|
},
|
||||||
|
NetworkSettings: {
|
||||||
|
Networks: {
|
||||||
|
bridge: {
|
||||||
|
IPAMConfig: null,
|
||||||
|
Links: null,
|
||||||
|
Aliases: null,
|
||||||
|
NetworkID: '947ea5247cc7429e1fdebd5404fa4d15f7c05e6765f2b93ddb3bdb6aaffd1193',
|
||||||
|
EndpointID: 'da17549a086ff2c9f622e80de833e6f334afda52c8f07080428640c1716dcd14',
|
||||||
|
Gateway: '172.18.0.1',
|
||||||
|
IPAddress: '172.18.0.3',
|
||||||
|
IPPrefixLen: 16,
|
||||||
|
IPv6Gateway: '',
|
||||||
|
GlobalIPv6Address: '',
|
||||||
|
GlobalIPv6PrefixLen: 0,
|
||||||
|
MacAddress: '03:41:ac:11:00:23',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
|
||||||
|
// tslint:disable:no-unused-expression
|
||||||
|
@test
|
||||||
|
checkIfContainerDoesNotMatchAnyContainer(done: () => void) {
|
||||||
|
expect(containerMatchesRegex(
|
||||||
|
'anyName',
|
||||||
|
new RegExp('d+'),
|
||||||
|
ContainerInfoParsing.anyContainerWithExposedPorts),
|
||||||
|
).to.be.false;
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
|
||||||
|
@test
|
||||||
|
checkIfContainerDoesNotMatchIfVersionIsIncorrect(done: () => void) {
|
||||||
|
expect(containerMatchesRegex(
|
||||||
|
'backend',
|
||||||
|
new RegExp('1\\.4\\.\\d+'),
|
||||||
|
ContainerInfoParsing.backendContainerWithExposedPorts),
|
||||||
|
).to.be.false;
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@test
|
||||||
|
checkIfContainerMatches(done: () => void) {
|
||||||
|
expect(containerMatchesRegex(
|
||||||
|
'backend',
|
||||||
|
new RegExp('1\\.0\\.\\d+'),
|
||||||
|
ContainerInfoParsing.backendContainerWithExposedPorts),
|
||||||
|
).to.be.true;
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
|
||||||
|
@test
|
||||||
|
getGatewayOfAnyContainerWithExposedPorts(done: () => void) {
|
||||||
|
expect(getGatewayOfStAppsBackend(ContainerInfoParsing.anyContainerWithExposedPorts)).to.be.equal('0.0.0.0:80');
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
|
||||||
|
@test
|
||||||
|
getGatewayOfBackendContainer(done: () => void) {
|
||||||
|
expect(getGatewayOfStAppsBackend(ContainerInfoParsing.backendContainerWithExposedPorts))
|
||||||
|
.to
|
||||||
|
.be
|
||||||
|
.equal('127.0.0.1:3000');
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
|
||||||
|
@test
|
||||||
|
upstreamMapCallsLoggerErrorWhenNoMatchingContainerIsFound(done: () => void) {
|
||||||
|
|
||||||
|
const stub = sinon.stub(console, 'error');
|
||||||
|
|
||||||
|
expect(generateUpstreamMap(
|
||||||
|
['0\\.8\\.\\d+'],
|
||||||
|
['1\\.1\\.\\d+'],
|
||||||
|
[ContainerInfoParsing.backendContainerWithExposedPorts],
|
||||||
|
)).to.be.equal('map $http_x_stapps_version $proxyurl {\n' +
|
||||||
|
' default unsupported;\n' +
|
||||||
|
' "~0\\.8\\.\\d+" unavailable;\n' +
|
||||||
|
' "~1\\.1\\.\\d+" outdated;\n' +
|
||||||
|
'}\n');
|
||||||
|
|
||||||
|
stub.restore();
|
||||||
|
expect(stub.args[0][0]).contains('[ERROR] No backend for version').and.contains('found');
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
|
||||||
|
@test
|
||||||
|
upstreamMapWithOneActiveVersionAndNoOutdatedOnes(done: () => void) {
|
||||||
|
expect(
|
||||||
|
generateUpstreamMap(
|
||||||
|
['1\\.0\\.\\d+'],
|
||||||
|
['0\\.8\\.\\d+'],
|
||||||
|
[ContainerInfoParsing.backendContainerWithExposedPorts],
|
||||||
|
),
|
||||||
|
).to.be.equal(
|
||||||
|
'map $http_x_stapps_version $proxyurl {\n' +
|
||||||
|
' default unsupported;\n' +
|
||||||
|
' "~1\\.0\\.\\d+" 1__0___d_;\n' +
|
||||||
|
' "~0\\.8\\.\\d+" outdated;\n' +
|
||||||
|
'}\n' +
|
||||||
|
'upstream 1__0___d_ {\n' +
|
||||||
|
' server 127.0.0.1:3000;\n' +
|
||||||
|
'}\n',
|
||||||
|
);
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
}
|
||||||
4
test/mocha.opts
Normal file
4
test/mocha.opts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
--ui mocha-typescript
|
||||||
|
--require ts-node/register
|
||||||
|
--require source-map-support/register
|
||||||
|
test/*.ts
|
||||||
8
tsconfig.json
Normal file
8
tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./node_modules/@openstapps/configuration/tsconfig.json",
|
||||||
|
"exclude": [
|
||||||
|
"./config/",
|
||||||
|
"./test/",
|
||||||
|
"./lib/"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
tslint.json
Normal file
3
tslint.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["./node_modules/@openstapps/configuration/tslint.json"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user