diff --git a/Dockerfile b/Dockerfile index 721d598..c35c777 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,6 @@ -FROM nginx:1.9.2 +FROM nginx:1.9.5 MAINTAINER Jason Wilder jwilder@litl.com -# Set timezone -# The city selections might seem arbitrary, but they incorporate daylight savings -# time automatically based on time zone and are better then manually picking -# using the 'Etc/GMT+0' files. -ENV DEBIAN_FRONTEND noninteractive -ENV TIMEZONE America/Argentina/Buenos_Aires -RUN echo $TIMEZONE > /etc/timezone &&\ - cp /usr/share/zoneinfo/${TIMEZONE} /etc/localtime &&\ - dpkg-reconfigure tzdata -RUN env --unset=DEBIAN_FRONTEND - # Install wget and install/updates certificates RUN apt-get update \ && apt-get install -y -q --no-install-recommends \ @@ -28,7 +17,7 @@ RUN echo "daemon off;" >> /etc/nginx/nginx.conf \ RUN wget -P /usr/local/bin https://godist.herokuapp.com/projects/ddollar/forego/releases/current/linux-amd64/forego \ && chmod u+x /usr/local/bin/forego -ENV DOCKER_GEN_VERSION 0.4.0 +ENV DOCKER_GEN_VERSION 0.4.2 RUN wget https://github.com/jwilder/docker-gen/releases/download/$DOCKER_GEN_VERSION/docker-gen-linux-amd64-$DOCKER_GEN_VERSION.tar.gz \ && tar -C /usr/local/bin -xvzf docker-gen-linux-amd64-$DOCKER_GEN_VERSION.tar.gz \ @@ -41,4 +30,5 @@ ENV DOCKER_HOST unix:///tmp/docker.sock VOLUME ["/etc/nginx/certs"] +ENTRYPOINT ["/app/docker-entrypoint.sh"] CMD ["forego", "start", "-r"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3eda887 --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +.SILENT : +.PHONY : test + +test: + docker build -t jwilder/nginx-proxy:bats . + bats test diff --git a/README.md b/README.md index 3dcd67d..e619e35 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![nginx 1.9.0](https://img.shields.io/badge/nginx-1.9.0-brightgreen.svg) ![License MIT](https://img.shields.io/badge/license-MIT-blue.svg) +![nginx 1.9.5](https://img.shields.io/badge/nginx-1.9.5-brightgreen.svg) ![License MIT](https://img.shields.io/badge/license-MIT-blue.svg) [![Build](https://circleci.com/gh/jwilder/nginx-proxy.svg?&style=shield&circle-token=2da3ee844076a47371bd45da81cf27409ca7306a)](https://circleci.com/gh/jwilder/nginx-proxy) nginx-proxy sets up a container running nginx and [docker-gen][1]. docker-gen generates reverse proxy configs for nginx and reloads nginx when containers are started and stopped. @@ -14,6 +14,8 @@ Then start any containers you want proxied with an env var `VIRTUAL_HOST=subdoma $ docker run -e VIRTUAL_HOST=foo.bar.com ... +The containers being proxied must [expose](https://docs.docker.com/reference/run/#expose-incoming-ports) the port to be proxied, either by using the `EXPOSE` directive in their `Dockerfile` or by using the `--expose` flag to `docker run` or `docker create`. + Provided your DNS is setup to forward foo.bar.com to the a host running nginx-proxy, the request will be routed to a container with the VIRTUAL_HOST env var set. ### Multiple Ports @@ -138,6 +140,25 @@ You'll need apache2-utils on the machine where you plan to create the htpasswd f If you need to configure Nginx beyond what is possible using environment variables, you can provide custom configuration files on either a proxy-wide or per-`VIRTUAL_HOST` basis. +#### Replacing default proxy settings + +If you want to replace the default proxy settings for the nginx container, add a configuration file at `/etc/nginx/proxy.conf`. A file with the default settings would +look like this: + +```Nginx +# HTTP 1.1 support +proxy_http_version 1.1; +proxy_buffering off; +proxy_set_header Host $http_host; +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection $proxy_connection; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; +``` + +***NOTE***: If you provide this file it will replace the defaults; you may want to check the .tmpl file to make sure you have all of the needed options. + #### Proxy-wide To add settings on a proxy-wide basis, add your configuration file under `/etc/nginx/conf.d` using a name ending in `.conf`. @@ -158,7 +179,7 @@ Or it can be done by mounting in your custom configuration in your `docker run` #### Per-VIRTUAL_HOST -To add settings on a per-`VIRTUAL_HOST` basis, add your configuration file under `/etc/nginx/vhost.d`. Unlike in the proxy-wide case, which allows mutliple config files with any name ending in `.conf`, the per-`VIRTUAL_HOST` file must be named exactly after the `VIRTUAL_HOST`. +To add settings on a per-`VIRTUAL_HOST` basis, add your configuration file under `/etc/nginx/vhost.d`. Unlike in the proxy-wide case, which allows multiple config files with any name ending in `.conf`, the per-`VIRTUAL_HOST` file must be named exactly after the `VIRTUAL_HOST`. In order to allow virtual hosts to be dynamically configured as backends are added and removed, it makes the most sense to mount an external directory as `/etc/nginx/vhost.d` as opposed to using derived images or mounting individual configuration files. @@ -170,4 +191,40 @@ For example, if you have a virtual host named `app.example.com`, you could provi If you are using multiple hostnames for a single container (e.g. `VIRTUAL_HOST=example.com,www.example.com`), the virtual host configuration file must exist for each hostname. If you would like to use the same configuration for multiple virtual host names, you can use a symlink: $ { echo 'server_tokens off;'; echo 'client_max_body_size 100m;'; } > /path/to/vhost.d/www.example.com - $ ln -s www.example.com /path/to/vhost.d/example.com + $ ln -s /path/to/vhost.d/www.example.com /path/to/vhost.d/example.com + +#### Per-VIRTUAL_HOST default configuration + +If you want most of your virtual hosts to use a default single configuration and then override on a few specific ones, add those settings to the `/etc/nginx/vhost.d/default` file. This file +will be used on any virtual host which does not have a `/etc/nginx/vhost.d/{VIRTUAL_HOST}` file associated with it. + +#### Per-VIRTUAL_HOST location configuration + +To add settings to the "location" block on a per-`VIRTUAL_HOST` basis, add your configuration file under `/etc/nginx/vhost.d` +just like the previous section except with the suffix `_location`. + +For example, if you have a virtual host named `app.example.com` and you have configured a proxy_cache `my-cache` in another custom file, you could tell it to use a proxy cache as follows: + + $ docker run -d -p 80:80 -p 443:443 -v /path/to/vhost.d:/etc/nginx/vhost.d:ro -v /var/run/docker.sock:/tmp/docker.sock:ro jwilder/nginx-proxy + $ { echo 'proxy_cache my-cache;'; echo 'proxy_cache_valid 200 302 60m;'; echo 'proxy_cache_valid 404 1m;' } > /path/to/vhost.d/app.example.com_location + +If you are using multiple hostnames for a single container (e.g. `VIRTUAL_HOST=example.com,www.example.com`), the virtual host configuration file must exist for each hostname. If you would like to use the same configuration for multiple virtual host names, you can use a symlink: + + $ { echo 'proxy_cache my-cache;'; echo 'proxy_cache_valid 200 302 60m;'; echo 'proxy_cache_valid 404 1m;' } > /path/to/vhost.d/app.example.com_location + $ ln -s /path/to/vhost.d/www.example.com /path/to/vhost.d/example.com + +#### Per-VIRTUAL_HOST location default configuration + +If you want most of your virtual hosts to use a default single `location` block configuration and then override on a few specific ones, add those settings to the `/etc/nginx/vhost.d/default_location` file. This file +will be used on any virtual host which does not have a `/etc/nginx/vhost.d/{VIRTUAL_HOST}` file associated with it. + +### Contributing + +Before submitting pull requests or issues, please check github to make sure an existing issue or pull request is not already open. + +#### Running Tests Locally + +To run tests, you'll need to install [bats 0.4.0](https://github.com/sstephenson/bats). + + make test + diff --git a/circle.yml b/circle.yml new file mode 100644 index 0000000..e808c6e --- /dev/null +++ b/circle.yml @@ -0,0 +1,22 @@ +machine: + pre: + # install docker 1.7.1 + - sudo curl -L -o /usr/bin/docker 'https://s3-external-1.amazonaws.com/circle-downloads/docker-1.7.1-circleci'; sudo chmod 0755 /usr/bin/docker; true + services: + - docker + +dependencies: + override: + - sudo add-apt-repository ppa:duggan/bats --yes + - sudo apt-get update -qq + - sudo apt-get install -qq bats + - docker pull jwilder/docker-gen + - docker pull nginx + - docker pull python:3 + - docker pull rancher/socat-docker + - docker pull appropriate/curl + - docker pull docker:1.7 + +test: + override: + - make test diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..6353314 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -e + +# Warn if the DOCKER_HOST socket does not exist +if [[ $DOCKER_HOST == unix://* ]]; then + socket_file=${DOCKER_HOST#unix://} + if ! [ -S $socket_file ]; then + cat >&2 <<-EOT + ERROR: you need to share your Docker host socket with a volume at $socket_file + Typically you should run your jwilder/nginx-proxy with: \`-v /var/run/docker.sock:$socket_file:ro\` + See the documentation at http://git.io/vZaGJ + EOT + socketMissing=1 + fi +fi + +# If the user has run the default command and the socket doesn't exist, fail +if [ "$socketMissing" = 1 -a "$1" = forego -a "$2" = start -a "$3" = '-r' ]; then + exit 1 +fi + +exec "$@" diff --git a/nginx.tmpl b/nginx.tmpl index d31cc6a..b91257c 100644 --- a/nginx.tmpl +++ b/nginx.tmpl @@ -1,3 +1,20 @@ +{{ define "upstream" }} + {{ if .Address }} + {{/* If we got the containers from swarm and this container's port is published to host, use host IP:PORT */}} + {{ if and .Container.Node.ID .Address.HostPort }} + # {{ .Container.Node.Name }}/{{ .Container.Name }} + server {{ .Container.Node.Address.IP }}:{{ .Address.HostPort }}; + {{/* If there is no swarm node or the port is not published on host, use container's IP:PORT */}} + {{ else }} + # {{ .Container.Name }} + server {{ .Address.IP }}:{{ .Address.Port }}; + {{ end }} + {{ else }} + # {{ .Container.Name }} + server {{ .Container.IP }} down; + {{ end }} +{{ end }} + # If we receive X-Forwarded-Proto, pass it through; otherwise, pass along the # scheme used to connect to this server map $http_x_forwarded_proto $proxy_x_forwarded_proto { @@ -18,25 +35,41 @@ log_format vhost '$host $remote_addr - $remote_user [$time_local] ' '"$request" $status $body_bytes_sent ' '"$http_referer" "$http_user_agent"'; -access_log /proc/self/fd/1 vhost; -error_log /proc/self/fd/2; +access_log off; +{{ if (exists "/etc/nginx/proxy.conf") }} +include /etc/nginx/proxy.conf; +{{ else }} # HTTP 1.1 support -#proxy_http_version 1.1; -#proxy_buffering off; +proxy_http_version 1.1; +proxy_buffering off; proxy_set_header Host $http_host; -#proxy_set_header Upgrade $http_upgrade; -#proxy_set_header Connection $proxy_connection; +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection $proxy_connection; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -#proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; +proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; +{{ end }} server { - listen 80; server_name _; # This is just an invalid value which will never trigger on a real hostname. + listen 80; + access_log /var/log/nginx/access.log vhost; return 503; } +{{ if (and (exists "/etc/nginx/certs/default.crt") (exists "/etc/nginx/certs/default.key")) }} +server { + server_name _; # This is just an invalid value which will never trigger on a real hostname. + listen 443 ssl http2; + access_log /var/log/nginx/access.log vhost; + return 503; + + ssl_certificate /etc/nginx/certs/default.crt; + ssl_certificate_key /etc/nginx/certs/default.key; +} +{{ end }} + {{ range $host, $containers := groupByMulti $ "Env.VIRTUAL_HOST" "," }} upstream {{ $host }} { @@ -44,32 +77,20 @@ upstream {{ $host }} { {{ $addrLen := len $container.Addresses }} {{/* If only 1 port exposed, use that */}} {{ if eq $addrLen 1 }} - {{ with $address := index $container.Addresses 0 }} - # {{$container.Name}} - server {{ $address.IP }}:{{ $address.Port }}; - {{ end }} - {{/* If more than one port exposed, use the one matching VIRTUAL_PORT env var */}} - {{ else if $container.Env.VIRTUAL_PORT }} - {{ range $address := .Addresses }} - {{ if eq $address.Port $container.Env.VIRTUAL_PORT }} - # {{$container.Name}} - server {{ $address.IP }}:{{ $address.Port }}; - {{ end }} - {{ end }} - {{/* Else default to standard web port 80 */}} + {{ $address := index $container.Addresses 0 }} + {{ template "upstream" (dict "Container" $container "Address" $address) }} + {{/* If more than one port exposed, use the one matching VIRTUAL_PORT env var, falling back to standard web port 80 */}} {{ else }} - {{ $address := where $container.Addresses "Port" "80" | first }} - {{ if $address }} - # {{$container.Name}} - server {{ $address.IP }}:80; - {{ else }} - # {{$container.Name}} - server {{ $container.IP }} down; - {{ end }} + {{ $port := coalesce $container.Env.VIRTUAL_PORT "80" }} + {{ $address := where $container.Addresses "Port" $port | first }} + {{ template "upstream" (dict "Container" $container "Address" $address) }} {{ end }} {{ end }} } +{{ $default_host := or ($.Env.DEFAULT_HOST) "" }} +{{ $default_server := index (dict $host "" $default_host "default_server") $host }} + {{/* Get the VIRTUAL_PROTO defined by containers w/ the same vhost, falling back to "http" */}} {{ $proto := or (first (groupByKeys $containers "Env.VIRTUAL_PROTO")) "http" }} @@ -90,17 +111,20 @@ upstream {{ $host }} { server { server_name {{ $host }}; + listen 80 {{ $default_server }}; + access_log /var/log/nginx/access.log vhost; return 301 https://$host$request_uri; } server { server_name {{ $host }}; - listen 443 ssl spdy; + listen 443 ssl http2 {{ $default_server }}; + access_log /var/log/nginx/access.log vhost; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA; - ssl_prefer_server_ciphers on; + ssl_prefer_server_ciphers on; ssl_session_timeout 5m; ssl_session_cache shared:SSL:50m; @@ -117,54 +141,62 @@ server { {{ if (exists (printf "/etc/nginx/vhost.d/%s" $host)) }} include {{ printf "/etc/nginx/vhost.d/%s" $host }}; + {{ else if (exists "/etc/nginx/vhost.d/default") }} + include /etc/nginx/vhost.d/default; {{ end }} location / { - proxy_pass {{ $proto }}://{{ $host }}; - proxy_redirect {{ $proto }}://{{ $host }}/ /; + proxy_pass {{ trim $proto }}://{{ trim $host }}; + proxy_redirect {{ trim $proto }}://{{ trim $host }}/ /; proxy_read_timeout 60s; {{ if (exists (printf "/etc/nginx/htpasswd/%s" $host)) }} auth_basic "Restricted {{ $host }}"; auth_basic_user_file {{ (printf "/etc/nginx/htpasswd/%s" $host) }}; {{ end }} + {{ if (exists (printf "/etc/nginx/vhost.d/%s_location" $host)) }} + include {{ printf "/etc/nginx/vhost.d/%s_location" $host}}; + {{ else if (exists "/etc/nginx/vhost.d/default_location") }} + include /etc/nginx/vhost.d/default_location; + {{ end }} } } {{ else }} server { - {{ if $.Env.DEFAULT_HOST }} - {{ if eq $.Env.DEFAULT_HOST $host }} - listen 80 default_server; - server_name {{ $host }}; - {{ else }} - server_name {{ $host }}; - {{ end }} - {{ else }} - server_name {{ $host }}; - {{ end }} + server_name {{ $host }}; + listen 80 {{ $default_server }}; + access_log /var/log/nginx/access.log vhost; {{ if (exists (printf "/etc/nginx/vhost.d/%s" $host)) }} include {{ printf "/etc/nginx/vhost.d/%s" $host }}; + {{ else if (exists "/etc/nginx/vhost.d/default") }} + include /etc/nginx/vhost.d/default; {{ end }} client_max_body_size 20M; location / { - proxy_pass {{ $proto }}://{{ $host }}; - proxy_redirect {{ $proto }}://{{ $host }}/ /; + proxy_pass {{ trim $proto }}://{{ trim $host }}; + proxy_redirect {{ trim $proto }}://{{ trim $host }}/ /; proxy_read_timeout 60s; {{ if (exists (printf "/etc/nginx/htpasswd/%s" $host)) }} auth_basic "Restricted {{ $host }}"; auth_basic_user_file {{ (printf "/etc/nginx/htpasswd/%s" $host) }}; {{ end }} + {{ if (exists (printf "/etc/nginx/vhost.d/%s_location" $host)) }} + include {{ printf "/etc/nginx/vhost.d/%s_location" $host}}; + {{ else if (exists "/etc/nginx/vhost.d/default_location") }} + include /etc/nginx/vhost.d/default_location; + {{ end }} } } {{ if (and (exists "/etc/nginx/certs/default.crt") (exists "/etc/nginx/certs/default.key")) }} server { server_name {{ $host }}; - listen 443 ssl spdy; + listen 443 ssl http2 {{ $default_server }}; + access_log /var/log/nginx/access.log vhost; return 503; ssl_certificate /etc/nginx/certs/default.crt; diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..721d436 --- /dev/null +++ b/test/README.md @@ -0,0 +1,14 @@ +Test suite +========== + +This test suite is implemented on top of the [Bats](https://github.com/sstephenson/bats/blob/master/README.md) test framework. + +It is intended to verify the correct behavior of the Docker image `jwilder/nginx-proxy:bats`. + +Running the test suite +---------------------- + +Make sure you have Bats installed, then run: + + docker build -t jwilder/nginx-proxy:bats . + bats test/ \ No newline at end of file diff --git a/test/default-host.bats b/test/default-host.bats new file mode 100644 index 0000000..4e9d84e --- /dev/null +++ b/test/default-host.bats @@ -0,0 +1,32 @@ +#!/usr/bin/env bats +load test_helpers + +function setup { + # make sure to stop any web container before each test so we don't + # have any unexpected contaiener running with VIRTUAL_HOST or VIRUTAL_PORT set + CIDS=( $(docker ps -q --filter "label=bats-type=web") ) + if [ ${#CIDS[@]} -gt 0 ]; then + docker stop ${CIDS[@]} >&2 + fi +} + + +@test "[$TEST_FILE] DEFAULT_HOST=web1.bats" { + SUT_CONTAINER=bats-nginx-proxy-${TEST_FILE}-1 + + # GIVEN a webserver with VIRTUAL_HOST set to web.bats + prepare_web_container bats-web 80 -e VIRTUAL_HOST=web.bats + + # WHEN nginx-proxy runs with DEFAULT_HOST set to web.bats + run nginxproxy $SUT_CONTAINER -v /var/run/docker.sock:/tmp/docker.sock:ro -e DEFAULT_HOST=web.bats + assert_success + docker_wait_for_log $SUT_CONTAINER 3 "Watching docker events" + + # THEN querying the proxy without Host header → 200 + run curl_container $SUT_CONTAINER / --head + assert_output -l 0 $'HTTP/1.1 200 OK\r' + + # THEN querying the proxy with any other Host header → 200 + run curl_container $SUT_CONTAINER / --head --header "Host: something.I.just.made.up" + assert_output -l 0 $'HTTP/1.1 200 OK\r' +} diff --git a/test/docker.bats b/test/docker.bats new file mode 100644 index 0000000..f4ec665 --- /dev/null +++ b/test/docker.bats @@ -0,0 +1,117 @@ +#!/usr/bin/env bats +load test_helpers + + +@test "[$TEST_FILE] start 2 web containers" { + prepare_web_container bats-web1 81 -e VIRTUAL_HOST=web1.bats + prepare_web_container bats-web2 82 -e VIRTUAL_HOST=web2.bats +} + + +@test "[$TEST_FILE] -v /var/run/docker.sock:/tmp/docker.sock:ro" { + SUT_CONTAINER=bats-nginx-proxy-${TEST_FILE}-1 + + # WHEN nginx-proxy runs on our docker host using the default unix socket + run nginxproxy $SUT_CONTAINER -v /var/run/docker.sock:/tmp/docker.sock:ro + assert_success + docker_wait_for_log $SUT_CONTAINER 3 "Watching docker events" + + # THEN + assert_nginxproxy_behaves $SUT_CONTAINER +} + + +@test "[$TEST_FILE] -v /var/run/docker.sock:/f00.sock:ro -e DOCKER_HOST=unix:///f00.sock" { + SUT_CONTAINER=bats-nginx-proxy-${TEST_FILE}-2 + + # WHEN nginx-proxy runs on our docker host using a custom unix socket + run nginxproxy $SUT_CONTAINER -v /var/run/docker.sock:/f00.sock:ro -e DOCKER_HOST=unix:///f00.sock + assert_success + docker_wait_for_log $SUT_CONTAINER 3 "Watching docker events" + + # THEN + assert_nginxproxy_behaves $SUT_CONTAINER +} + + +@test "[$TEST_FILE] -e DOCKER_HOST=tcp://..." { + SUT_CONTAINER=bats-nginx-proxy-${TEST_FILE}-3 + # GIVEN a container exposing our docker host over TCP + run docker_tcp bats-docker-tcp + assert_success + sleep 1s + + # WHEN nginx-proxy runs on our docker host using tcp to connect to our docker host + run nginxproxy $SUT_CONTAINER -e DOCKER_HOST="tcp://bats-docker-tcp:2375" --link bats-docker-tcp:bats-docker-tcp + assert_success + docker_wait_for_log $SUT_CONTAINER 3 "Watching docker events" + + # THEN + assert_nginxproxy_behaves $SUT_CONTAINER +} + + +@test "[$TEST_FILE] separated containers (nginx + docker-gen + nginx.tmpl)" { + docker_clean bats-nginx + docker_clean bats-docker-gen + + # GIVEN a simple nginx container + run docker run -d \ + --name bats-nginx \ + -v /etc/nginx/conf.d/ \ + -v /etc/nginx/certs/ \ + nginx:latest + assert_success + run retry 5 1s docker run appropriate/curl --silent --fail --head http://$(docker_ip bats-nginx)/ + assert_output -l 0 $'HTTP/1.1 200 OK\r' + + # WHEN docker-gen runs on our docker host + run docker run -d \ + --name bats-docker-gen \ + -v /var/run/docker.sock:/tmp/docker.sock:ro \ + -v $BATS_TEST_DIRNAME/../nginx.tmpl:/etc/docker-gen/templates/nginx.tmpl:ro \ + --volumes-from bats-nginx \ + jwilder/docker-gen:latest \ + -notify-sighup bats-nginx \ + -watch \ + -only-exposed \ + /etc/docker-gen/templates/nginx.tmpl \ + /etc/nginx/conf.d/default.conf + assert_success + docker_wait_for_log bats-docker-gen 6 "Watching docker events" + + # Give some time to the docker-gen container to notify bats-nginx so it + # reloads its config + sleep 2s + + run docker_running_state bats-nginx + assert_output "true" || { + docker logs bats-docker-gen + false + } >&2 + + # THEN + assert_nginxproxy_behaves bats-nginx +} + + +# $1 nginx-proxy container +function assert_nginxproxy_behaves { + local -r container=$1 + + # Querying the proxy without Host header → 503 + run curl_container $container / --head + assert_output -l 0 $'HTTP/1.1 503 Service Temporarily Unavailable\r' + + # Querying the proxy with Host header → 200 + run curl_container $container /data --header "Host: web1.bats" + assert_output "answer from port 81" + + run curl_container $container /data --header "Host: web2.bats" + assert_output "answer from port 82" + + # Querying the proxy with unknown Host header → 503 + run curl_container $container /data --header "Host: webFOO.bats" --head + assert_output -l 0 $'HTTP/1.1 503 Service Temporarily Unavailable\r' +} + diff --git a/test/lib/README.md b/test/lib/README.md new file mode 100644 index 0000000..1021dc4 --- /dev/null +++ b/test/lib/README.md @@ -0,0 +1,6 @@ +bats lib +======== + +found on https://github.com/sstephenson/bats/pull/110 + +When that pull request will be merged, the `test/lib/bats` won't be necessary anymore. \ No newline at end of file diff --git a/test/lib/bats/batslib.bash b/test/lib/bats/batslib.bash new file mode 100644 index 0000000..003ada6 --- /dev/null +++ b/test/lib/bats/batslib.bash @@ -0,0 +1,596 @@ +# +# batslib.bash +# ------------ +# +# The Standard Library is a collection of test helpers intended to +# simplify testing. It contains the following types of test helpers. +# +# - Assertions are functions that perform a test and output relevant +# information on failure to help debugging. They return 1 on failure +# and 0 otherwise. +# +# All output is formatted for readability using the functions of +# `output.bash' and sent to the standard error. +# + +source "${BATS_LIB}/batslib/output.bash" + + +######################################################################## +# ASSERTIONS +######################################################################## + +# Fail and display a message. When no parameters are specified, the +# message is read from the standard input. Other functions use this to +# report failure. +# +# Globals: +# none +# Arguments: +# $@ - [=STDIN] message +# Returns: +# 1 - always +# Inputs: +# STDIN - [=$@] message +# Outputs: +# STDERR - message +fail() { + (( $# == 0 )) && batslib_err || batslib_err "$@" + return 1 +} + +# Fail and display details if the expression evaluates to false. Details +# include the expression, `$status' and `$output'. +# +# NOTE: The expression must be a simple command. Compound commands, such +# as `[[', can be used only when executed with `bash -c'. +# +# Globals: +# status +# output +# Arguments: +# $1 - expression +# Returns: +# 0 - expression evaluates to TRUE +# 1 - otherwise +# Outputs: +# STDERR - details, on failure +assert() { + if ! "$@"; then + { local -ar single=( + 'expression' "$*" + 'status' "$status" + ) + local -ar may_be_multi=( + 'output' "$output" + ) + local -ir width="$( batslib_get_max_single_line_key_width \ + "${single[@]}" "${may_be_multi[@]}" )" + batslib_print_kv_single "$width" "${single[@]}" + batslib_print_kv_single_or_multi "$width" "${may_be_multi[@]}" + } | batslib_decorate 'assertion failed' \ + | fail + fi +} + +# Fail and display details if the expected and actual values do not +# equal. Details include both values. +# +# Globals: +# none +# Arguments: +# $1 - actual value +# $2 - expected value +# Returns: +# 0 - values equal +# 1 - otherwise +# Outputs: +# STDERR - details, on failure +assert_equal() { + if [[ $1 != "$2" ]]; then + batslib_print_kv_single_or_multi 8 \ + 'expected' "$2" \ + 'actual' "$1" \ + | batslib_decorate 'values do not equal' \ + | fail + fi +} + +# Fail and display details if `$status' is not 0. Details include +# `$status' and `$output'. +# +# Globals: +# status +# output +# Arguments: +# none +# Returns: +# 0 - `$status' is 0 +# 1 - otherwise +# Outputs: +# STDERR - details, on failure +assert_success() { + if (( status != 0 )); then + { local -ir width=6 + batslib_print_kv_single "$width" 'status' "$status" + batslib_print_kv_single_or_multi "$width" 'output' "$output" + } | batslib_decorate 'command failed' \ + | fail + fi +} + +# Fail and display details if `$status' is 0. Details include `$output'. +# +# Optionally, when the expected status is specified, fail when it does +# not equal `$status'. In this case, details include the expected and +# actual status, and `$output'. +# +# Globals: +# status +# output +# Arguments: +# $1 - [opt] expected status +# Returns: +# 0 - `$status' is not 0, or +# `$status' equals the expected status +# 1 - otherwise +# Outputs: +# STDERR - details, on failure +assert_failure() { + (( $# > 0 )) && local -r expected="$1" + if (( status == 0 )); then + batslib_print_kv_single_or_multi 6 'output' "$output" \ + | batslib_decorate 'command succeeded, but it was expected to fail' \ + | fail + elif (( $# > 0 )) && (( status != expected )); then + { local -ir width=8 + batslib_print_kv_single "$width" \ + 'expected' "$expected" \ + 'actual' "$status" + batslib_print_kv_single_or_multi "$width" \ + 'output' "$output" + } | batslib_decorate 'command failed as expected, but status differs' \ + | fail + fi +} + +# Fail and display details if the expected does not match the actual +# output or a fragment of it. +# +# By default, the entire output is matched. The assertion fails if the +# expected output does not equal `$output'. Details include both values. +# +# When `-l ' is used, only the -th line is matched. The +# assertion fails if the expected line does not equal +# `${lines[}'. Details include the compared lines and . +# +# When `-l' is used without the argument, the output is searched +# for the expected line. The expected line is matched against each line +# in `${lines[@]}'. If no match is found the assertion fails. Details +# include the expected line and `$output'. +# +# By default, literal matching is performed. Options `-p' and `-r' +# enable partial (i.e. substring) and extended regular expression +# matching, respectively. Specifying an invalid extended regular +# expression with `-r' displays an error. +# +# Options `-p' and `-r' are mutually exclusive. When used +# simultaneously, an error is displayed. +# +# Globals: +# output +# lines +# Options: +# -l - match against the -th element of `${lines[@]}' +# -l - search `${lines[@]}' for the expected line +# -p - partial matching +# -r - extended regular expression matching +# Arguments: +# $1 - expected output +# Returns: +# 0 - expected matches the actual output +# 1 - otherwise +# Outputs: +# STDERR - details, on failure +# error message, on error +assert_output() { + local -i is_match_line=0 + local -i is_match_contained=0 + local -i is_mode_partial=0 + local -i is_mode_regex=0 + + # Handle options. + while (( $# > 0 )); do + case "$1" in + -l) + if (( $# > 2 )) && [[ $2 =~ ^([0-9]|[1-9][0-9]+)$ ]]; then + is_match_line=1 + local -ri idx="$2" + shift + else + is_match_contained=1; + fi + shift + ;; + -p) is_mode_partial=1; shift ;; + -r) is_mode_regex=1; shift ;; + --) break ;; + *) break ;; + esac + done + + if (( is_match_line )) && (( is_match_contained )); then + echo "\`-l' and \`-l ' are mutually exclusive" \ + | batslib_decorate 'ERROR: assert_output' \ + | fail + return $? + fi + + if (( is_mode_partial )) && (( is_mode_regex )); then + echo "\`-p' and \`-r' are mutually exclusive" \ + | batslib_decorate 'ERROR: assert_output' \ + | fail + return $? + fi + + # Arguments. + local -r expected="$1" + + if (( is_mode_regex == 1 )) && [[ '' =~ $expected ]] || (( $? == 2 )); then + echo "Invalid extended regular expression: \`$expected'" \ + | batslib_decorate 'ERROR: assert_output' \ + | fail + return $? + fi + + # Matching. + if (( is_match_contained )); then + # Line contained in output. + if (( is_mode_regex )); then + local -i idx + for (( idx = 0; idx < ${#lines[@]}; ++idx )); do + [[ ${lines[$idx]} =~ $expected ]] && return 0 + done + { local -ar single=( + 'regex' "$expected" + ) + local -ar may_be_multi=( + 'output' "$output" + ) + local -ir width="$( batslib_get_max_single_line_key_width \ + "${single[@]}" "${may_be_multi[@]}" )" + batslib_print_kv_single "$width" "${single[@]}" + batslib_print_kv_single_or_multi "$width" "${may_be_multi[@]}" + } | batslib_decorate 'no output line matches regular expression' \ + | fail + elif (( is_mode_partial )); then + local -i idx + for (( idx = 0; idx < ${#lines[@]}; ++idx )); do + [[ ${lines[$idx]} == *"$expected"* ]] && return 0 + done + { local -ar single=( + 'substring' "$expected" + ) + local -ar may_be_multi=( + 'output' "$output" + ) + local -ir width="$( batslib_get_max_single_line_key_width \ + "${single[@]}" "${may_be_multi[@]}" )" + batslib_print_kv_single "$width" "${single[@]}" + batslib_print_kv_single_or_multi "$width" "${may_be_multi[@]}" + } | batslib_decorate 'no output line contains substring' \ + | fail + else + local -i idx + for (( idx = 0; idx < ${#lines[@]}; ++idx )); do + [[ ${lines[$idx]} == "$expected" ]] && return 0 + done + { local -ar single=( + 'line' "$expected" + ) + local -ar may_be_multi=( + 'output' "$output" + ) + local -ir width="$( batslib_get_max_single_line_key_width \ + "${single[@]}" "${may_be_multi[@]}" )" + batslib_print_kv_single "$width" "${single[@]}" + batslib_print_kv_single_or_multi "$width" "${may_be_multi[@]}" + } | batslib_decorate 'output does not contain line' \ + | fail + fi + elif (( is_match_line )); then + # Specific line. + if (( is_mode_regex )); then + if ! [[ ${lines[$idx]} =~ $expected ]]; then + batslib_print_kv_single 5 \ + 'index' "$idx" \ + 'regex' "$expected" \ + 'line' "${lines[$idx]}" \ + | batslib_decorate 'regular expression does not match line' \ + | fail + fi + elif (( is_mode_partial )); then + if [[ ${lines[$idx]} != *"$expected"* ]]; then + batslib_print_kv_single 9 \ + 'index' "$idx" \ + 'substring' "$expected" \ + 'line' "${lines[$idx]}" \ + | batslib_decorate 'line does not contain substring' \ + | fail + fi + else + if [[ ${lines[$idx]} != "$expected" ]]; then + batslib_print_kv_single 8 \ + 'index' "$idx" \ + 'expected' "$expected" \ + 'actual' "${lines[$idx]}" \ + | batslib_decorate 'line differs' \ + | fail + fi + fi + else + # Entire output. + if (( is_mode_regex )); then + if ! [[ $output =~ $expected ]]; then + batslib_print_kv_single_or_multi 6 \ + 'regex' "$expected" \ + 'output' "$output" \ + | batslib_decorate 'regular expression does not match output' \ + | fail + fi + elif (( is_mode_partial )); then + if [[ $output != *"$expected"* ]]; then + batslib_print_kv_single_or_multi 9 \ + 'substring' "$expected" \ + 'output' "$output" \ + | batslib_decorate 'output does not contain substring' \ + | fail + fi + else + if [[ $output != "$expected" ]]; then + batslib_print_kv_single_or_multi 8 \ + 'expected' "$expected" \ + 'actual' "$output" \ + | batslib_decorate 'output differs' \ + | fail + fi + fi + fi +} + +# Fail and display details if the unexpected matches the actual output +# or a fragment of it. +# +# By default, the entire output is matched. The assertion fails if the +# unexpected output equals `$output'. Details include `$output'. +# +# When `-l ' is used, only the -th line is matched. The +# assertion fails if the unexpected line equals `${lines[}'. +# Details include the compared line and . +# +# When `-l' is used without the argument, the output is searched +# for the unexpected line. The unexpected line is matched against each +# line in `${lines[]}'. If a match is found the assertion fails. +# Details include the unexpected line, the index where it was found and +# `$output' (with the unexpected line highlighted in it if `$output` is +# longer than one line). +# +# By default, literal matching is performed. Options `-p' and `-r' +# enable partial (i.e. substring) and extended regular expression +# matching, respectively. On failure, the substring or the regular +# expression is added to the details (if not already displayed). +# Specifying an invalid extended regular expression with `-r' displays +# an error. +# +# Options `-p' and `-r' are mutually exclusive. When used +# simultaneously, an error is displayed. +# +# Globals: +# output +# lines +# Options: +# -l - match against the -th element of `${lines[@]}' +# -l - search `${lines[@]}' for the unexpected line +# -p - partial matching +# -r - extended regular expression matching +# Arguments: +# $1 - unexpected output +# Returns: +# 0 - unexpected matches the actual output +# 1 - otherwise +# Outputs: +# STDERR - details, on failure +# error message, on error +refute_output() { + local -i is_match_line=0 + local -i is_match_contained=0 + local -i is_mode_partial=0 + local -i is_mode_regex=0 + + # Handle options. + while (( $# > 0 )); do + case "$1" in + -l) + if (( $# > 2 )) && [[ $2 =~ ^([0-9]|[1-9][0-9]+)$ ]]; then + is_match_line=1 + local -ri idx="$2" + shift + else + is_match_contained=1; + fi + shift + ;; + -L) is_match_contained=1; shift ;; + -p) is_mode_partial=1; shift ;; + -r) is_mode_regex=1; shift ;; + --) break ;; + *) break ;; + esac + done + + if (( is_match_line )) && (( is_match_contained )); then + echo "\`-l' and \`-l ' are mutually exclusive" \ + | batslib_decorate 'ERROR: refute_output' \ + | fail + return $? + fi + + if (( is_mode_partial )) && (( is_mode_regex )); then + echo "\`-p' and \`-r' are mutually exclusive" \ + | batslib_decorate 'ERROR: refute_output' \ + | fail + return $? + fi + + # Arguments. + local -r unexpected="$1" + + if (( is_mode_regex == 1 )) && [[ '' =~ $unexpected ]] || (( $? == 2 )); then + echo "Invalid extended regular expression: \`$unexpected'" \ + | batslib_decorate 'ERROR: refute_output' \ + | fail + return $? + fi + + # Matching. + if (( is_match_contained )); then + # Line contained in output. + if (( is_mode_regex )); then + local -i idx + for (( idx = 0; idx < ${#lines[@]}; ++idx )); do + if [[ ${lines[$idx]} =~ $unexpected ]]; then + { local -ar single=( + 'regex' "$unexpected" + 'index' "$idx" + ) + local -a may_be_multi=( + 'output' "$output" + ) + local -ir width="$( batslib_get_max_single_line_key_width \ + "${single[@]}" "${may_be_multi[@]}" )" + batslib_print_kv_single "$width" "${single[@]}" + if batslib_is_single_line "${may_be_multi[1]}"; then + batslib_print_kv_single "$width" "${may_be_multi[@]}" + else + may_be_multi[1]="$( printf '%s' "${may_be_multi[1]}" \ + | batslib_prefix \ + | batslib_mark '>' "$idx" )" + batslib_print_kv_multi "${may_be_multi[@]}" + fi + } | batslib_decorate 'no line should match the regular expression' \ + | fail + return $? + fi + done + elif (( is_mode_partial )); then + local -i idx + for (( idx = 0; idx < ${#lines[@]}; ++idx )); do + if [[ ${lines[$idx]} == *"$unexpected"* ]]; then + { local -ar single=( + 'substring' "$unexpected" + 'index' "$idx" + ) + local -a may_be_multi=( + 'output' "$output" + ) + local -ir width="$( batslib_get_max_single_line_key_width \ + "${single[@]}" "${may_be_multi[@]}" )" + batslib_print_kv_single "$width" "${single[@]}" + if batslib_is_single_line "${may_be_multi[1]}"; then + batslib_print_kv_single "$width" "${may_be_multi[@]}" + else + may_be_multi[1]="$( printf '%s' "${may_be_multi[1]}" \ + | batslib_prefix \ + | batslib_mark '>' "$idx" )" + batslib_print_kv_multi "${may_be_multi[@]}" + fi + } | batslib_decorate 'no line should contain substring' \ + | fail + return $? + fi + done + else + local -i idx + for (( idx = 0; idx < ${#lines[@]}; ++idx )); do + if [[ ${lines[$idx]} == "$unexpected" ]]; then + { local -ar single=( + 'line' "$unexpected" + 'index' "$idx" + ) + local -a may_be_multi=( + 'output' "$output" + ) + local -ir width="$( batslib_get_max_single_line_key_width \ + "${single[@]}" "${may_be_multi[@]}" )" + batslib_print_kv_single "$width" "${single[@]}" + if batslib_is_single_line "${may_be_multi[1]}"; then + batslib_print_kv_single "$width" "${may_be_multi[@]}" + else + may_be_multi[1]="$( printf '%s' "${may_be_multi[1]}" \ + | batslib_prefix \ + | batslib_mark '>' "$idx" )" + batslib_print_kv_multi "${may_be_multi[@]}" + fi + } | batslib_decorate 'line should not be in output' \ + | fail + return $? + fi + done + fi + elif (( is_match_line )); then + # Specific line. + if (( is_mode_regex )); then + if [[ ${lines[$idx]} =~ $unexpected ]] || (( $? == 0 )); then + batslib_print_kv_single 5 \ + 'index' "$idx" \ + 'regex' "$unexpected" \ + 'line' "${lines[$idx]}" \ + | batslib_decorate 'regular expression should not match line' \ + | fail + fi + elif (( is_mode_partial )); then + if [[ ${lines[$idx]} == *"$unexpected"* ]]; then + batslib_print_kv_single 9 \ + 'index' "$idx" \ + 'substring' "$unexpected" \ + 'line' "${lines[$idx]}" \ + | batslib_decorate 'line should not contain substring' \ + | fail + fi + else + if [[ ${lines[$idx]} == "$unexpected" ]]; then + batslib_print_kv_single 5 \ + 'index' "$idx" \ + 'line' "${lines[$idx]}" \ + | batslib_decorate 'line should differ' \ + | fail + fi + fi + else + # Entire output. + if (( is_mode_regex )); then + if [[ $output =~ $unexpected ]] || (( $? == 0 )); then + batslib_print_kv_single_or_multi 6 \ + 'regex' "$unexpected" \ + 'output' "$output" \ + | batslib_decorate 'regular expression should not match output' \ + | fail + fi + elif (( is_mode_partial )); then + if [[ $output == *"$unexpected"* ]]; then + batslib_print_kv_single_or_multi 9 \ + 'substring' "$unexpected" \ + 'output' "$output" \ + | batslib_decorate 'output should not contain substring' \ + | fail + fi + else + if [[ $output == "$unexpected" ]]; then + batslib_print_kv_single_or_multi 6 \ + 'output' "$output" \ + | batslib_decorate 'output equals, but it was expected to differ' \ + | fail + fi + fi + fi +} \ No newline at end of file diff --git a/test/lib/bats/batslib/output.bash b/test/lib/bats/batslib/output.bash new file mode 100644 index 0000000..aa9cb87 --- /dev/null +++ b/test/lib/bats/batslib/output.bash @@ -0,0 +1,264 @@ +# +# output.bash +# ----------- +# +# Private functions implementing output formatting. Used by public +# helper functions. +# + +# Print a message to the standard error. When no parameters are +# specified, the message is read from the standard input. +# +# Globals: +# none +# Arguments: +# $@ - [=STDIN] message +# Returns: +# none +# Inputs: +# STDIN - [=$@] message +# Outputs: +# STDERR - message +batslib_err() { + { if (( $# > 0 )); then + echo "$@" + else + cat - + fi + } >&2 +} + +# Count the number of lines in the given string. +# +# TODO(ztombol): Fix tests and remove this note after #93 is resolved! +# NOTE: Due to a bug in Bats, `batslib_count_lines "$output"' does not +# give the same result as `${#lines[@]}' when the output contains +# empty lines. +# See PR #93 (https://github.com/sstephenson/bats/pull/93). +# +# Globals: +# none +# Arguments: +# $1 - string +# Returns: +# none +# Outputs: +# STDOUT - number of lines +batslib_count_lines() { + local -i n_lines=0 + local line + while IFS='' read -r line || [[ -n $line ]]; do + (( ++n_lines )) + done < <(printf '%s' "$1") + echo "$n_lines" +} + +# Determine whether all strings are single-line. +# +# Globals: +# none +# Arguments: +# $@ - strings +# Returns: +# 0 - all strings are single-line +# 1 - otherwise +batslib_is_single_line() { + for string in "$@"; do + (( $(batslib_count_lines "$string") > 1 )) && return 1 + done + return 0 +} + +# Determine the length of the longest key that has a single-line value. +# +# This function is useful in determining the correct width of the key +# column in two-column format when some keys may have multi-line values +# and thus should be excluded. +# +# Globals: +# none +# Arguments: +# $odd - key +# $even - value of the previous key +# Returns: +# none +# Outputs: +# STDOUT - length of longest key +batslib_get_max_single_line_key_width() { + local -i max_len=-1 + while (( $# != 0 )); do + local -i key_len="${#1}" + batslib_is_single_line "$2" && (( key_len > max_len )) && max_len="$key_len" + shift 2 + done + echo "$max_len" +} + +# Print key-value pairs in two-column format. +# +# Keys are displayed in the first column, and their corresponding values +# in the second. To evenly line up values, the key column is fixed-width +# and its width is specified with the first parameter (possibly computed +# using `batslib_get_max_single_line_key_width'). +# +# Globals: +# none +# Arguments: +# $1 - width of key column +# $even - key +# $odd - value of the previous key +# Returns: +# none +# Outputs: +# STDOUT - formatted key-value pairs +batslib_print_kv_single() { + local -ir col_width="$1"; shift + while (( $# != 0 )); do + printf '%-*s : %s\n' "$col_width" "$1" "$2" + shift 2 + done +} + +# Print key-value pairs in multi-line format. +# +# The key is displayed first with the number of lines of its +# corresponding value in parenthesis. Next, starting on the next line, +# the value is displayed. For better readability, it is recommended to +# indent values using `batslib_prefix'. +# +# Globals: +# none +# Arguments: +# $odd - key +# $even - value of the previous key +# Returns: +# none +# Outputs: +# STDOUT - formatted key-value pairs +batslib_print_kv_multi() { + while (( $# != 0 )); do + printf '%s (%d lines):\n' "$1" "$( batslib_count_lines "$2" )" + printf '%s\n' "$2" + shift 2 + done +} + +# Print all key-value pairs in either two-column or multi-line format +# depending on whether all values are single-line. +# +# If all values are single-line, print all pairs in two-column format +# with the specified key column width (identical to using +# `batslib_print_kv_single'). +# +# Otherwise, print all pairs in multi-line format after indenting values +# with two spaces for readability (identical to using `batslib_prefix' +# and `batslib_print_kv_multi') +# +# Globals: +# none +# Arguments: +# $1 - width of key column (for two-column format) +# $even - key +# $odd - value of the previous key +# Returns: +# none +# Outputs: +# STDOUT - formatted key-value pairs +batslib_print_kv_single_or_multi() { + local -ir width="$1"; shift + local -a pairs=( "$@" ) + + local -a values=() + local -i i + for (( i=1; i < ${#pairs[@]}; i+=2 )); do + values+=( "${pairs[$i]}" ) + done + + if batslib_is_single_line "${values[@]}"; then + batslib_print_kv_single "$width" "${pairs[@]}" + else + local -i i + for (( i=1; i < ${#pairs[@]}; i+=2 )); do + pairs[$i]="$( batslib_prefix < <(printf '%s' "${pairs[$i]}") )" + done + batslib_print_kv_multi "${pairs[@]}" + fi +} + +# Prefix each line read from the standard input with the given string. +# +# Globals: +# none +# Arguments: +# $1 - [= ] prefix string +# Returns: +# none +# Inputs: +# STDIN - lines +# Outputs: +# STDOUT - prefixed lines +batslib_prefix() { + local -r prefix="${1:- }" + local line + while IFS='' read -r line || [[ -n $line ]]; do + printf '%s%s\n' "$prefix" "$line" + done +} + +# Mark select lines of the text read from the standard input by +# overwriting their beginning with the given string. +# +# Usually the input is indented by a few spaces using `batslib_prefix' +# first. +# +# Globals: +# none +# Arguments: +# $1 - marking string +# $@ - indices (zero-based) of lines to mark +# Returns: +# none +# Inputs: +# STDIN - lines +# Outputs: +# STDOUT - lines after marking +batslib_mark() { + local -r symbol="$1"; shift + # Sort line numbers. + set -- $( sort -nu <<< "$( printf '%d\n' "$@" )" ) + + local line + local -i idx=0 + while IFS='' read -r line || [[ -n $line ]]; do + if (( ${1:--1} == idx )); then + printf '%s\n' "${symbol}${line:${#symbol}}" + shift + else + printf '%s\n' "$line" + fi + (( ++idx )) + done +} + +# Enclose the input text in header and footer lines. +# +# The header contains the given string as title. The output is preceded +# and followed by an additional newline to make it stand out more. +# +# Globals: +# none +# Arguments: +# $1 - title +# Returns: +# none +# Inputs: +# STDIN - text +# Outputs: +# STDOUT - decorated text +batslib_decorate() { + echo + echo "-- $1 --" + cat - + echo '--' + echo +} \ No newline at end of file diff --git a/test/lib/docker_helpers.bash b/test/lib/docker_helpers.bash new file mode 100644 index 0000000..b5165af --- /dev/null +++ b/test/lib/docker_helpers.bash @@ -0,0 +1,60 @@ +## functions to help deal with docker + +# Removes container $1 +function docker_clean { + docker kill $1 &>/dev/null ||: + sleep .25s + docker rm -vf $1 &>/dev/null ||: + sleep .25s +} + +# get the ip of docker container $1 +function docker_ip { + docker inspect --format '{{ .NetworkSettings.IPAddress }}' $1 +} + +# get the running state of container $1 +# → true/false +# fails if the container does not exist +function docker_running_state { + docker inspect -f {{.State.Running}} $1 +} + +# get the docker container $1 PID +function docker_pid { + docker inspect --format {{.State.Pid}} $1 +} + +# asserts logs from container $1 contains $2 +function docker_assert_log { + local -r container=$1 + shift + run docker logs $container + assert_output -p "$*" +} + +# wait for a container to produce a given text in its log +# $1 container +# $2 timeout in second +# $* text to wait for +function docker_wait_for_log { + local -r container=$1 + local -ir timeout_sec=$2 + shift 2 + retry $(( $timeout_sec * 2 )) .5s docker_assert_log $container "$*" +} + +# Create a docker container named $1 which exposes the docker host unix +# socket over tcp on port 2375. +# +# $1 container name +function docker_tcp { + local container_name="$1" + docker_clean $container_name + docker run -d \ + --name $container_name \ + --expose 2375 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + rancher/socat-docker + docker run --link "$container_name:docker" docker:1.7 version +} diff --git a/test/lib/helpers.bash b/test/lib/helpers.bash new file mode 100644 index 0000000..dffcd66 --- /dev/null +++ b/test/lib/helpers.bash @@ -0,0 +1,22 @@ +## add the retry function to bats + +# Retry a command $1 times until it succeeds. Wait $2 seconds between retries. +function retry { + local attempts=$1 + shift + local delay=$1 + shift + local i + + for ((i=0; i < attempts; i++)); do + run "$@" + if [ "$status" -eq 0 ]; then + echo "$output" + return 0 + fi + sleep $delay + done + + echo "Command \"$@\" failed $attempts times. Status: $status. Output: $output" >&2 + false +} diff --git a/test/multiple-hosts.bats b/test/multiple-hosts.bats new file mode 100644 index 0000000..695aec1 --- /dev/null +++ b/test/multiple-hosts.bats @@ -0,0 +1,40 @@ +#!/usr/bin/env bats +load test_helpers +SUT_CONTAINER=bats-nginx-proxy-${TEST_FILE} + +function setup { + # make sure to stop any web container before each test so we don't + # have any unexpected contaiener running with VIRTUAL_HOST or VIRUTAL_PORT set + CIDS=( $(docker ps -q --filter "label=bats-type=web") ) + if [ ${#CIDS[@]} -gt 0 ]; then + docker stop ${CIDS[@]} >&2 + fi +} + + +@test "[$TEST_FILE] start a nginx-proxy container" { + run nginxproxy $SUT_CONTAINER -v /var/run/docker.sock:/tmp/docker.sock:ro + assert_success + docker_wait_for_log $SUT_CONTAINER 3 "Watching docker events" +} + +@test "[$TEST_FILE] nginx-proxy forwards requests for 2 hosts" { + # WHEN a container runs a web server with VIRTUAL_HOST set for multiple hosts + prepare_web_container bats-multiple-hosts-1 80 -e VIRTUAL_HOST=multiple-hosts-1-A.bats,multiple-hosts-1-B.bats + + # THEN querying the proxy without Host header → 503 + run curl_container $SUT_CONTAINER / --head + assert_output -l 0 $'HTTP/1.1 503 Service Temporarily Unavailable\r' + + # THEN querying the proxy with unknown Host header → 503 + run curl_container $SUT_CONTAINER /data --header "Host: webFOO.bats" --head + assert_output -l 0 $'HTTP/1.1 503 Service Temporarily Unavailable\r' + + # THEN + run curl_container $SUT_CONTAINER /data --header 'Host: multiple-hosts-1-A.bats' + assert_output "answer from port 80" + + # THEN + run curl_container $SUT_CONTAINER /data --header 'Host: multiple-hosts-1-B.bats' + assert_output "answer from port 80" +} diff --git a/test/multiple-ports.bats b/test/multiple-ports.bats new file mode 100644 index 0000000..a520571 --- /dev/null +++ b/test/multiple-ports.bats @@ -0,0 +1,57 @@ +#!/usr/bin/env bats +load test_helpers +SUT_CONTAINER=bats-nginx-proxy-${TEST_FILE} + +function setup { + # make sure to stop any web container before each test so we don't + # have any unexpected contaiener running with VIRTUAL_HOST or VIRUTAL_PORT set + CIDS=( $(docker ps -q --filter "label=bats-type=web") ) + if [ ${#CIDS[@]} -gt 0 ]; then + docker stop ${CIDS[@]} >&2 + fi +} + + +@test "[$TEST_FILE] start a nginx-proxy container" { + # GIVEN nginx-proxy + run nginxproxy $SUT_CONTAINER -v /var/run/docker.sock:/tmp/docker.sock:ro + assert_success + docker_wait_for_log $SUT_CONTAINER 3 "Watching docker events" +} + + +@test "[$TEST_FILE] nginx-proxy defaults to the service running on port 80" { + # WHEN + prepare_web_container bats-web-${TEST_FILE}-1 "80 90" -e VIRTUAL_HOST=web.bats + + # THEN + assert_response_is_from_port 80 +} + + +@test "[$TEST_FILE] VIRTUAL_PORT=90 while port 80 is also exposed" { + # GIVEN + prepare_web_container bats-web-${TEST_FILE}-2 "80 90" -e VIRTUAL_HOST=web.bats -e VIRTUAL_PORT=90 + + # THEN + assert_response_is_from_port 90 +} + + +@test "[$TEST_FILE] single exposed port != 80" { + # GIVEN + prepare_web_container bats-web-${TEST_FILE}-3 1234 -e VIRTUAL_HOST=web.bats + + # THEN + assert_response_is_from_port 1234 +} + + +# assert querying nginx-proxy provides a response from the expected port of the web container +# $1 port we are expecting an response from +function assert_response_is_from_port { + local -r port=$1 + run curl_container $SUT_CONTAINER /data --header "Host: web.bats" + assert_output "answer from port $port" +} + diff --git a/test/test_helpers.bash b/test/test_helpers.bash new file mode 100644 index 0000000..5890677 --- /dev/null +++ b/test/test_helpers.bash @@ -0,0 +1,134 @@ +# Test if requirements are met +( + type docker &>/dev/null || ( echo "docker is not available"; exit 1 ) +)>&2 + + +# set a few global variables +SUT_IMAGE=jwilder/nginx-proxy:bats +TEST_FILE=$(basename $BATS_TEST_FILENAME .bats) + + +# load the Bats stdlib (see https://github.com/sstephenson/bats/pull/110) +DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) +export BATS_LIB="${DIR}/lib/bats" +load "${BATS_LIB}/batslib.bash" + + +# load additional bats helpers +load ${DIR}/lib/helpers.bash +load ${DIR}/lib/docker_helpers.bash + + +# Define functions specific to our test suite + +# run the SUT docker container +# and makes sure it remains started +# and displays the nginx-proxy start logs +# +# $1 container name +# $@ other options for the `docker run` command +function nginxproxy { + local -r container_name=$1 + shift + docker_clean $container_name \ + && docker run -d \ + --name $container_name \ + "$@" \ + $SUT_IMAGE \ + && wait_for_nginxproxy_container_to_start $container_name \ + && docker logs $container_name +} + + +# wait until the nginx-proxy container is ready to operate +# +# $1 container name +function wait_for_nginxproxy_container_to_start { + local -r container_name=$1 + sleep .5s # give time to eventually fail to initialize + + function is_running { + run docker_running_state $container_name + assert_output "true" + } + retry 3 1 is_running +} + + +# Send a HTTP request to container $1 for path $2 and +# Additional curl options can be passed as $@ +# +# $1 container name +# $2 HTTP path to query +# $@ additional options to pass to the curl command +function curl_container { + local -r container=$1 + local -r path=$2 + shift 2 + docker run appropriate/curl --silent \ + --connect-timeout 5 \ + --max-time 20 \ + "$@" \ + http://$(docker_ip $container)${path} +} + + +# start a container running (one or multiple) webservers listening on given ports +# +# $1 container name +# $2 container port(s). If multiple ports, provide them as a string: "80 90" with a space as a separator +# $@ `docker run` additional options +function prepare_web_container { + local -r container_name=$1 + local -r ports=$2 + shift 2 + local -r options="$@" + + local expose_option="" + IFS=$' \t\n' # See https://github.com/sstephenson/bats/issues/89 + for port in $ports; do + expose_option="${expose_option}--expose=$port " + done + + ( # used for debugging purpose. Will be display if test fails + echo "container_name: $container_name" + echo "ports: $ports" + echo "options: $options" + echo "expose_option: $expose_option" + )>&2 + + docker_clean $container_name + + # GIVEN a container exposing 1 webserver on ports 1234 + run docker run -d \ + --label bats-type="web" \ + --name $container_name \ + $expose_option \ + -w /var/www/ \ + $options \ + -e PYTHON_PORTS="$ports" \ + python:3 bash -c " + trap '[ \${#PIDS[@]} -gt 0 ] && kill -TERM \${PIDS[@]}' TERM + declare -a PIDS + for port in \$PYTHON_PORTS; do + echo starting a web server listening on port \$port; + mkdir /var/www/\$port + cd /var/www/\$port + echo \"answer from port \$port\" > data + python -m http.server \$port & + PIDS+=(\$!) + done + wait \${PIDS[@]} + trap - TERM + wait \${PIDS[@]} + " + assert_success + + # THEN querying directly port works + IFS=$' \t\n' # See https://github.com/sstephenson/bats/issues/89 + for port in $ports; do + run retry 5 1s docker run appropriate/curl --silent --fail http://$(docker_ip $container_name):$port/data + assert_output "answer from port $port" + done +} diff --git a/test/wildcard-hosts.bats b/test/wildcard-hosts.bats new file mode 100644 index 0000000..88ca1e7 --- /dev/null +++ b/test/wildcard-hosts.bats @@ -0,0 +1,71 @@ +#!/usr/bin/env bats +load test_helpers +SUT_CONTAINER=bats-nginx-proxy-${TEST_FILE} + +function setup { + # make sure to stop any web container before each test so we don't + # have any unexpected contaiener running with VIRTUAL_HOST or VIRUTAL_PORT set + CIDS=( $(docker ps -q --filter "label=bats-type=web") ) + if [ ${#CIDS[@]} -gt 0 ]; then + docker stop ${CIDS[@]} >&2 + fi +} + + +@test "[$TEST_FILE] start a nginx-proxy container" { + # GIVEN + run nginxproxy $SUT_CONTAINER -v /var/run/docker.sock:/tmp/docker.sock:ro + assert_success + docker_wait_for_log $SUT_CONTAINER 3 "Watching docker events" +} + + +@test "[$TEST_FILE] VIRTUAL_HOST=*.wildcard.bats" { + # WHEN + prepare_web_container bats-wildcard-hosts-1 80 -e VIRTUAL_HOST=*.wildcard.bats + + # THEN + assert_200 f00.wildcard.bats + assert_200 bar.wildcard.bats + assert_503 unexpected.host.bats +} + +@test "[$TEST_FILE] VIRTUAL_HOST=wildcard.bats.*" { + # WHEN + prepare_web_container bats-wildcard-hosts-2 80 -e VIRTUAL_HOST=wildcard.bats.* + + # THEN + assert_200 wildcard.bats.f00 + assert_200 wildcard.bats.bar + assert_503 unexpected.host.bats +} + +@test "[$TEST_FILE] VIRTUAL_HOST=~^foo\.bar\..*\.bats" { + # WHEN + prepare_web_container bats-wildcard-hosts-2 80 -e VIRTUAL_HOST=~^foo\.bar\..*\.bats + + # THEN + assert_200 foo.bar.whatever.bats + assert_200 foo.bar.why.not.bats + assert_503 unexpected.host.bats + +} + + +# assert that querying nginx-proxy with the given Host header produces a `HTTP 200` response +# $1 Host HTTP header to use when querying nginx-proxy +function assert_200 { + local -r host=$1 + + run curl_container $SUT_CONTAINER / --head --header "Host: $host" + assert_output -l 0 $'HTTP/1.1 200 OK\r' +} + +# assert that querying nginx-proxy with the given Host header produces a `HTTP 503` response +# $1 Host HTTP header to use when querying nginx-proxy +function assert_503 { + local -r host=$1 + + run curl_container $SUT_CONTAINER / --head --header "Host: $host" + assert_output -l 0 $'HTTP/1.1 503 Service Temporarily Unavailable\r' +}