diff --git a/.npmignore b/.npmignore index c5a836587..aa35cea6b 100644 --- a/.npmignore +++ b/.npmignore @@ -18,3 +18,4 @@ # Additional .npmignore entries (not in .gitignore) /test +/docker-image diff --git a/docker-image/.dockerignore b/docker-image/.dockerignore new file mode 100644 index 000000000..26396170f --- /dev/null +++ b/docker-image/.dockerignore @@ -0,0 +1,3 @@ +test/ +.pytest_cache/ +.idea \ No newline at end of file diff --git a/docker-image/.gitignore b/docker-image/.gitignore new file mode 100644 index 000000000..b2aff4d47 --- /dev/null +++ b/docker-image/.gitignore @@ -0,0 +1,3 @@ +.pytest_cache/ +__pycache__ +data/ \ No newline at end of file diff --git a/docker-image/CONTRIBUTING.md b/docker-image/CONTRIBUTING.md new file mode 100644 index 000000000..f9f8e1142 --- /dev/null +++ b/docker-image/CONTRIBUTING.md @@ -0,0 +1,33 @@ +# How to contribute + +If you want to experiment with the image and/or contribute to its development, +please read this document. + +## Run tests + +```bash +make test +``` + +The first run might take a while, since the image has to be build. Follow up test runs will be faster. + +## Start & stop locally + +Build and run a local container named solid-server via + +```bash +make start +``` + +and stop it via + +```bash +make stop +``` + +## Inspect & debug + +To start a shell in a running container (started with `make start`) run `make attach`. + +To just run a shell in the built image (without starting solid) run `make inspect`. + diff --git a/docker-image/Makefile b/docker-image/Makefile new file mode 100644 index 000000000..fabcda629 --- /dev/null +++ b/docker-image/Makefile @@ -0,0 +1,31 @@ +test: ## run testinfra tests against the project + docker run --rm -t \ + -v $(shell pwd):/project \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ + aveltens/docker-testinfra + +lint: ## run hadolint against the Dockerfile + docker run --rm -i hadolint/hadolint < src/Dockerfile + +build: ## build the docker image + cd src && docker build --tag nodesolidserver/node-solid-server . + +inspect: build ## run a shell in the docker image + docker run --rm -it --entrypoint sh nodesolidserver/node-solid-server + +start: build ## start solid-server docker container + docker run --rm \ + -it -d \ + -p 8443:8443 \ + -u "$(id -u):$(id -g)" \ + -v $(shell pwd)/data:/opt/solid/data \ + --name solid-server \ + nodesolidserver/node-solid-server + +stop: ## stop the solid-server docker container + docker stop solid-server + +attach: ## execute a shell in the running solid-server docker container + docker exec -it solid-server sh + +.PHONY: test build inspect run attach diff --git a/docker-image/README.md b/docker-image/README.md new file mode 100644 index 000000000..7cc5bc622 --- /dev/null +++ b/docker-image/README.md @@ -0,0 +1,51 @@ +# NSS Docker image + +Containerized version of node-solid-server + +## How to use + +For quickly trying out this image or solid-server in general you can run: +```bash +docker run -p 8443:8443 nodesolidserver/node-solid-server +``` + +You will be able to access the server via `https://localhost:8443` then. It will use auto-generated self-signed certificates and is **not suited for production use**. For a production server you will have to create some real certificates and configure environment variables, like SOLID_SERVER_URI, SOLID_SSL_KEY and SOLID_SSL_CERT. Take a look at the examples folder [at GitHub](https://github.com/angelo-v/docker-solid-server/tree/master/examples) for details. + +### Environment variables + +All solid configuration flags can be set by an equivalent environment variable. +The official solid-server documentation +[explains them in detail](https://github.com/solid/node-solid-server#extra-flags-expert). + +### Docker compose + +For a productive setup you may want to use docker-compose. Example setups can be found +in the [examples folder](https://github.com/angelo-v/docker-solid-server/tree/master/examples). Here is an overview of what is in there: + +#### Simple setup without proxy + +`./examples/docker-compose.simple.yml` + +Run solid-server directly on HTTPS port 443 without a proxy in between. +You will need to have your certificates ready and mount them into the container. + +#### Running solid behind nginx proxy + +`./examples/docker-compose.nginx.yml` + +Run solid-server on port 8443 behind a nginx proxy on 443. You will need to setup an nginx container with letsencrypt companion [as described here](https://github.com/JrCs/docker-letsencrypt-nginx-proxy-companion). + +#### Other setups + +The setup you need is not presented here? Feel free to ask, or provide a Pull Request +with your solution. + +## Feedback & Discussion + +There is a [topic in the Solid Forum](https://forum.solidproject.org/t/official-solid-docker-image/748/5), +you are welcome to join in. + +## Contributing + +If you would like to contribute to the development of this image, +see [CONTRIBUTING.md](./CONTRIBUTING.md) diff --git a/docker-image/examples/docker-compose.nginx.yml b/docker-image/examples/docker-compose.nginx.yml new file mode 100644 index 000000000..34b42bc2a --- /dev/null +++ b/docker-image/examples/docker-compose.nginx.yml @@ -0,0 +1,50 @@ +# This example assumes, that you are running a jwilders/nginx proxy +# with certificate generation by a letsencrypt companion container +# as described here: +# +# https://github.com/JrCs/docker-letsencrypt-nginx-proxy-companion/blob/master/docs/Docker-Compose.md +# +# This should provide a docker volume containing the generated certificates. +# We will use the same cert and key as the webproxy for the actual solid server. While it seems to +# work, I am not sure if it is actually a good idea. Please file an issue if you want to discuss this. + +# Adjust any line that is commented with (!): +# 1. Change any occurrence of the domain `solid.example` to your actual domain +# 2. Adjust the `latest` tag to a specific version you want to use. + +version: '3.7' +services: + server: + image: nodesolidserver/node-solid-server:latest # (!) use specific version tag here + + # this ensures automatic container start, when host reboots + restart: always + + expose: + - 8443 + + volumes: + # mount local directories to the container + # (!) the host directories have to exist and be owned by UID 1000 + - /opt/solid/data:/opt/solid/data + - /opt/solid/.db:/opt/solid/.db + - /opt/solid/config:/opt/solid/config + - nginxproxy_certs:/opt/solid/certs + + environment: + # (!) use your actual SOLID_SERVER_URI + - "SOLID_SERVER_URI=https://solid.example" + # (!) adjust path to the letsencrypt key and cert + - "SOLID_SSL_KEY=/opt/solid/certs/solid.example/key.pem" + - "SOLID_SSL_CERT=/opt/solid/certs/solid.example/fullchain.pem" + # (!) use your actual host name + - "VIRTUAL_HOST=solid.example" + - "VIRTUAL_PORT=8443" + - "VIRTUAL_PROTO=https" + # (!) use your actual host name + - "LETSENCRYPT_HOST=solid.example" + - "LETSENCRYPT_EMAIL=your@mail.example" +volumes: + # (!) mount certificates from an external volume from your nginx setup + nginxproxy_certs: + external: true \ No newline at end of file diff --git a/docker-image/examples/docker-compose.simple.yml b/docker-image/examples/docker-compose.simple.yml new file mode 100644 index 000000000..183c18734 --- /dev/null +++ b/docker-image/examples/docker-compose.simple.yml @@ -0,0 +1,34 @@ +# This file is an example for running solid server directly on port 443 with +# existing (letsencrypt) certificates and without reverse proxy. + +# To use it adjust any line that is commented with (!): +# 1. Change any occurrence of the domain `solid.example` to your actual domain +# 2. Adjust the `latest` tag to a specific version you want to use. + +version: '3.7' +services: + server: + image: nodesolidserver/node-solid-server:latest # (!) use specific version tag here + + # this ensures automatic container start, when host reboots + restart: always + + ports: + - 443:8443 + + volumes: + # mount local directories to the container + # (!) the host directories have to exist and be owned by UID 1000 + - /opt/solid/data:/opt/solid/data + - /opt/solid/.db:/opt/solid/.db + - /opt/solid/config:/opt/solid/config + + # (!) mount existing TLS certificates, e.g. from letsencrypt + # (!) ensure that the key and fullchain files are readable by UID 1000 + - /etc/letsencrypt/live/solid.example/:/opt/solid/certs + + environment: + # (!) use your actual SOLID_SERVER_URI + - "SOLID_SERVER_URI=https://solid.example" + - "SOLID_SSL_KEY=/opt/solid/certs/key.pem" + - "SOLID_SSL_CERT=/opt/solid/certs/fullchain.pem" \ No newline at end of file diff --git a/docker-image/src/Dockerfile b/docker-image/src/Dockerfile new file mode 100644 index 000000000..2ccccbcc0 --- /dev/null +++ b/docker-image/src/Dockerfile @@ -0,0 +1,33 @@ +FROM node:10-alpine + +RUN apk add --no-cache openssl + +ARG SOLID_SERVER_VERSION=latest +RUN npm install -g solid-server@${SOLID_SERVER_VERSION} + +# image configuration +ENV SOLID_HOME=/opt/solid +ENV PROCESS_USER=node +ENV TEMPORARY_CERT_NAME=solid-temporary + +WORKDIR ${SOLID_HOME} +COPY ./entrypoint.sh ./entrypoint.sh +COPY ./checks.sh ./checks.sh +COPY ./create-temporary-cert.sh ./create-temporary-cert.sh +RUN chown --recursive ${PROCESS_USER}:${PROCESS_USER} ${SOLID_HOME} + +USER ${PROCESS_USER} + +# solid configuration +ENV SOLID_ROOT=${SOLID_HOME}/data +ENV SOLID_SSL_KEY=${SOLID_HOME}/${TEMPORARY_CERT_NAME}.key +ENV SOLID_SSL_CERT=${SOLID_HOME}/${TEMPORARY_CERT_NAME}.crt +ENV SOLID_PORT=8443 +ENV SOLID_CORS_PROXY=/xss +ENV DEBUG=solid:* + +VOLUME $SOLID_HOME + +ENTRYPOINT ["./entrypoint.sh"] + +CMD ["start"] diff --git a/docker-image/src/checks.sh b/docker-image/src/checks.sh new file mode 100755 index 000000000..ed6461233 --- /dev/null +++ b/docker-image/src/checks.sh @@ -0,0 +1,56 @@ +#!/bin/sh + +echo "checking preconditions..." + +checks_failed=0 + +check_failed() +{ + checks_failed=$((checks_failed + 1)) +} +check_if_writable() +{ + # checks if the given dir is writable, if it exists + # it's ok if the dir does not exist at all, because it will be created + # during solid server startup then and have the correct permissions + dir=$1 + if [ -d "${dir}" ]; then + if [ -w "${dir}" ]; then + echo "✓ ${dir} is accessible by $(whoami)" + else + echo "✗ ${dir} not writable by $(whoami)" + check_failed + fi + fi +} + +check_if_file_readable() +{ + # checks if the given file exists and is readable + file=$1 + if [ -e "${file}" ]; then + if [ -r "${file}" ]; then + echo "✓ ${file} is accessible by $(whoami)" + else + echo "✗ ${file} not readable by $(whoami)" + check_failed + fi + else + echo "✗ ${file} does not exist" + check_failed + fi +} + +check_if_writable "${SOLID_HOME}/config" +check_if_writable "${SOLID_HOME}/data" +check_if_writable "${SOLID_HOME}/.db" +check_if_file_readable "${SOLID_SSL_KEY}" +check_if_file_readable "${SOLID_SSL_CERT}" + +if [ "$checks_failed" -gt 0 ]; then + echo "Finished: ERROR" + exit 1 +else + echo "Finished: SUCCESS" + exit 0; +fi diff --git a/docker-image/src/create-temporary-cert.sh b/docker-image/src/create-temporary-cert.sh new file mode 100755 index 000000000..830c1bd07 --- /dev/null +++ b/docker-image/src/create-temporary-cert.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -e + +NAME=$1 + +if [ -z $NAME ]; then + echo "Usage: ./create-temporary-cert.sh some-name" + exit 1 +fi + +openssl req -nodes -x509 -days 3 -newkey rsa:2048 \ + -keyout ./$NAME.key \ + -out ./$NAME.crt \ + -subj "/O=$NAME/OU=$NAME/CN=$NAME" diff --git a/docker-image/src/entrypoint.sh b/docker-image/src/entrypoint.sh new file mode 100755 index 000000000..1b86a8a36 --- /dev/null +++ b/docker-image/src/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +set -e + +./create-temporary-cert.sh ${TEMPORARY_CERT_NAME} +./checks.sh + +solid "$@" diff --git a/docker-image/src/hooks/build b/docker-image/src/hooks/build new file mode 100644 index 000000000..d27dce64e --- /dev/null +++ b/docker-image/src/hooks/build @@ -0,0 +1,9 @@ +#!/bin/bash + +# conditional assignment of SOLID_SERVER_VERSION, like +# SOLID_SERVER_VERSION = $SOURCE_BRANCH == master ? 'latest' : $SOURCE_BRANCH +SOLID_SERVER_VERSION=latest && [[ "$SOURCE_BRANCH" != "master" ]] && SOLID_SERVER_VERSION=${SOURCE_BRANCH} + +echo building on branch/tag ${SOURCE_BRANCH}, server version ${SOLID_SERVER_VERSION} with image name ${IMAGE_NAME} . + +docker build --build-arg SOLID_SERVER_VERSION=${SOLID_SERVER_VERSION} --file Dockerfile -t ${IMAGE_NAME} . diff --git a/docker-image/test/conftest.py b/docker-image/test/conftest.py new file mode 100644 index 000000000..74024c406 --- /dev/null +++ b/docker-image/test/conftest.py @@ -0,0 +1,11 @@ +import docker +import pytest + +@pytest.fixture(scope="session") +def client(): + return docker.from_env() + +@pytest.fixture(scope="session") +def image(client): + img, _ = client.images.build(path='./src', dockerfile='Dockerfile') + return img \ No newline at end of file diff --git a/docker-image/test/test_image_foundations.py b/docker-image/test/test_image_foundations.py new file mode 100644 index 000000000..71d04b25a --- /dev/null +++ b/docker-image/test/test_image_foundations.py @@ -0,0 +1,51 @@ +import docker +import pytest + +testinfra_hosts = ['docker://test_container'] + +@pytest.fixture(scope="module", autouse=True) +def container(client, image): + container = client.containers.run( + image.id, + name="test_container", + detach=True, + tty=True, + entrypoint="sh", + command="-" + ) + yield container + container.remove(force=True) + +def test_current_user_is_node(host): + assert host.user().name == "node" + assert host.user().group == "node" + +def test_solid_home_dir_exists_and_owned_by_node(host): + solid_root = host.file("/opt/solid") + assert solid_root.is_directory + assert solid_root.user == "node" + assert solid_root.group == "node" + +def test_node_command_is_available(host): + assert host.exists("node") + +def test_node_version_is_10(host): + assert host.check_output("node --version").startswith('v10') + +def test_openssl_command_is_available(host): + assert host.exists("openssl") + +def test_entrypoint_exist(host): + entrypoint = host.file("/opt/solid/entrypoint.sh") + assert entrypoint.is_file + assert entrypoint.user == "node" + assert entrypoint.group == "node" + +def test_create_temporary_cert_exist(host): + create_temporary_cert = host.file("/opt/solid/create-temporary-cert.sh") + assert create_temporary_cert.is_file + assert create_temporary_cert.user == "node" + assert create_temporary_cert.group == "node" + +def test_solid_command_is_available(host): + assert host.exists("solid") diff --git a/docker-image/test/test_non_accessible_key_cert.py b/docker-image/test/test_non_accessible_key_cert.py new file mode 100644 index 000000000..36ee0649d --- /dev/null +++ b/docker-image/test/test_non_accessible_key_cert.py @@ -0,0 +1,36 @@ +# coding=utf-8 +import docker +import pytest +import time + +import os + +testinfra_hosts = ['docker://test_container'] + + +@pytest.fixture(scope="module", autouse=True) +def container(client, image): + container = client.containers.run( + image.id, + name="test_container", + environment=[ + # just using to files that exist but are not readable by node + "SOLID_SSL_KEY=/root", + "SOLID_SSL_CERT=/etc/shadow" + ], + detach=True, + tty=True + ) + # give the solid process some seconds to create the directory structure before making assertions + time.sleep(2) + yield container + container.remove(force=True) + + +def test_container_fails_with_errors(container): + assert container.status == "created" + logs = container.logs() + assert "✗ /root not readable by node" in logs + assert "✗ /etc/shadow not readable by node" in logs + assert "Finished: ERROR" in logs + assert not "Finished: SUCCESS" in logs diff --git a/docker-image/test/test_precondition_checks.py b/docker-image/test/test_precondition_checks.py new file mode 100644 index 000000000..3e5aba108 --- /dev/null +++ b/docker-image/test/test_precondition_checks.py @@ -0,0 +1,41 @@ +# coding=utf-8 +import docker +import pytest +import time + +testinfra_hosts = ['docker://test_container'] + + +@pytest.fixture(scope="module", autouse=True) +def container(client, image): + container = client.containers.run( + image.id, + name="test_container", + volumes={ + 'missing_data': {'bind': '/opt/solid/data'}, + 'missing_db': {'bind': '/opt/solid/.db'}, + 'missing_config': {'bind': '/opt/solid/config'} + }, + environment=[ + "SOLID_SSL_KEY=/missing/key", + "SOLID_SSL_CERT=/missing/cert" + ], + detach=True, + tty=True + ) + # give the solid process some seconds to create the directory structure before making assertions + time.sleep(2) + yield container + container.remove(force=True) + + +def test_container_fails_with_errors(container): + assert container.status == "created" + logs = container.logs() + assert "✗ /opt/solid/config not writable by node" in logs + assert "✗ /opt/solid/data not writable by node" in logs + assert "✗ /opt/solid/.db not writable by node" in logs + assert "✗ /missing/key does not exist" in logs + assert "✗ /missing/cert does not exist" in logs + assert "Finished: ERROR" in logs + assert not "Finished: SUCCESS" in logs diff --git a/docker-image/test/test_solid_default_config.py b/docker-image/test/test_solid_default_config.py new file mode 100644 index 000000000..753a56c3c --- /dev/null +++ b/docker-image/test/test_solid_default_config.py @@ -0,0 +1,67 @@ +import docker +import pytest +import time + +testinfra_hosts = ['docker://test_container'] + +@pytest.fixture(scope="module", autouse=True) +def container(client, image): + container = client.containers.run( + image.id, + name="test_container", + detach=True, + tty=True + ) + # give the solid process some seconds to create the directory structure before making assertions + time.sleep(2) + yield container + container.remove(force=True) + +def test_solid_data_dir_exists_and_owned_by_node(host): + solid_data = host.file("/opt/solid/data/") + assert solid_data.exists + assert solid_data.is_directory + assert solid_data.user == "node" + assert solid_data.group == "node" + +def test_solid_db_dir_exists_and_owned_by_node(host): + solid_db = host.file("/opt/solid/.db/") + assert solid_db.exists + assert solid_db.is_directory + assert solid_db.user == "node" + assert solid_db.group == "node" + +def test_solid_config_dir_exists_and_owned_by_node(host): + solid_config = host.file("/opt/solid/config/") + assert solid_config.exists + assert solid_config.is_directory + assert solid_config.user == "node" + assert solid_config.group == "node" + +def test_temporary_tls_cert_exists(host): + cert = host.file("/opt/solid/solid-temporary.crt") + assert cert.exists + assert cert.is_file + assert cert.user == "node" + assert cert.group == "node" + +def test_temporary_tls_key_exists(host): + key = host.file("/opt/solid/solid-temporary.key") + assert key.exists + assert key.is_file + assert key.user == "node" + assert key.group == "node" + +def test_certificate_and_key_are_used(host): + env = host.check_output("env") + assert "SOLID_SSL_KEY=/opt/solid/solid-temporary.key" in env + assert "SOLID_SSL_CERT=/opt/solid/solid-temporary.crt" in env + +def test_solid_is_running(host): + solid = host.process.get(comm="node") + assert solid.args == "node /usr/local/bin/solid start" + assert solid.user == "node" + assert solid.group == "node" + +def test_solid_is_listening_on_port_8443(host): + assert host.socket("tcp://0.0.0.0:8443").is_listening diff --git a/docker-image/test/test_volumes.py b/docker-image/test/test_volumes.py new file mode 100644 index 000000000..6c844bab1 --- /dev/null +++ b/docker-image/test/test_volumes.py @@ -0,0 +1,53 @@ +import docker +import pytest +import time + +testinfra_hosts = ['docker://test_container'] + +@pytest.fixture(scope="module", autouse=True) +def solid_server(client, image): + container = client.containers.run( + image.id, + name="solid_server", + detach=True, + tty=True + ) + # give the solid process some seconds to create the directory structure before making assertions + time.sleep(2) + yield container + container.remove(force=True) + +@pytest.fixture(scope="module", autouse=True) +def container(client, solid_server): + container = client.containers.run( + 'alpine', + name="test_container", + detach=True, + tty=True, + volumes_from=solid_server.id + ) + # give the solid process some seconds to create the directory structure before making assertions + time.sleep(2) + yield container + container.remove(force=True) + +def test_solid_data_dir_is_mounted(host): + solid_data = host.file("/opt/solid/data/") + assert solid_data.exists + assert solid_data.is_directory + assert solid_data.uid == 1000 + assert solid_data.gid == 1000 + +def test_solid_db_dir_is_mounted(host): + solid_db = host.file("/opt/solid/.db/") + assert solid_db.exists + assert solid_db.is_directory + assert solid_db.uid == 1000 + assert solid_db.gid == 1000 + +def test_solid_config_dir_is_mounted(host): + solid_config = host.file("/opt/solid/config/") + assert solid_config.exists + assert solid_config.is_directory + assert solid_config.uid == 1000 + assert solid_config.gid == 1000