diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..66a3cdf --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +**/__pycache__/ +build/ +dist/ +**/*.egg-info diff --git a/backend/.gitignore b/backend/.gitignore index 66a3cdf..8d51787 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -2,3 +2,4 @@ build/ dist/ **/*.egg-info +db-data/ diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..35faeca --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,25 @@ +version: "2" + +services: + monsun_postgres: + image: postgres + container_name: monsun_postgres + restart: always + volumes: + - ./db-data:/var/lib/postgresql/data + env_file: dev.env + + monsun_backend: + build: . + container_name: monsun_backd + restart: always + ports: + - 80:80 + volumes: + - ./config:/var/config/ + depends_on: + - monsun_postgres + environment: + POSTGRES_HOST: monsun_postgres + env_file: dev.env + command: ["bash", "./wait-for-it.sh", "monsun_postgres:5432", "-t", "60", "--", "./docker-entrypoint.sh"] diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100755 index 0000000..f5ca333 --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +source /opt/venv/bin/activate +flask db upgrade + +service nginx start +uwsgi --ini uwsgi.ini diff --git a/backend/docker_build_and_upload.sh b/backend/docker_build_and_upload.sh new file mode 100755 index 0000000..e5eed83 --- /dev/null +++ b/backend/docker_build_and_upload.sh @@ -0,0 +1,3 @@ +docker build -t monsun_backend . +docker tag monsun_backend:latest registry.berthoud.dev/monsun_backend +docker push registry.berthoud.dev/monsun_backend:latest diff --git a/backend/dockerfile b/backend/dockerfile new file mode 100644 index 0000000..c8fdb41 --- /dev/null +++ b/backend/dockerfile @@ -0,0 +1,45 @@ +# stage 1 +FROM python:3.8-slim-buster as backend-build + +VOLUME /app +WORKDIR /app + +RUN apt-get update \ + && apt-get -y install python3-dev \ + && apt-get -y install build-essential + +RUN python3 -m venv /opt/venv + +COPY . . + +RUN . /opt/venv/bin/activate \ + && pip install --upgrade setuptools wheel \ + && pip install -r requirements.txt \ + && python setup.py sdist bdist_wheel \ + && pip install monsun_backend --no-index --find-links file:///app/dist + +# stage 2 +FROM python:3.8-slim-buster + +RUN apt-get update \ + && apt-get -y install nginx \ + && apt-get -y install python3-dev \ + && apt-get -y install build-essential \ + && apt-get -qy install netcat + +RUN mkdir /var/config +VOLUME /var/config + +VOLUME /app +WORKDIR /app + +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +COPY wait-for-it.sh /app/wait-for-it.sh +COPY wsgi.py /app/wsgi.py +COPY nginx.conf /etc/nginx +COPY uwsgi.ini /app/uwsgi.ini +COPY migrations /app/migrations + +COPY --from=backend-build /opt/venv /opt/venv + +CMD ["bash", "./docker-entrypoint.sh"] diff --git a/backend/monsun_backend/__init__.py b/backend/monsun_backend/__init__.py index 2bdd2c3..2c0f96b 100644 --- a/backend/monsun_backend/__init__.py +++ b/backend/monsun_backend/__init__.py @@ -1,13 +1,17 @@ import logging -import os from logging.config import dictConfig from flask_api import FlaskAPI from flask_marshmallow import Marshmallow """https://blog.miguelgrinberg.com/post/how-to-add-flask-migrate-to-an-existing-project""" # noqa +import os + from flask_migrate import Migrate from flask_security import Security +from sqlalchemy import create_engine +from sqlalchemy_utils import create_database +from sqlalchemy_utils import database_exists from . import access from . import command_execution @@ -48,6 +52,12 @@ def create_app() -> FlaskAPI: app.register_blueprint(logout.bp) app.register_blueprint(admin.bp) + # somehow the 'flask db upgrade' does not create the DB as promised... + # so let's do it here instead + engine = create_engine(os.getenv("DATABASE_URI")) + if not database_exists(engine.url): + create_database(engine.url) + dictConfig( { "version": 1, diff --git a/backend/monsun_backend/command_execution.py b/backend/monsun_backend/command_execution.py index f1341a1..d301009 100644 --- a/backend/monsun_backend/command_execution.py +++ b/backend/monsun_backend/command_execution.py @@ -17,8 +17,6 @@ from typing import Tuple from serial import Serial -from backend.monsun_backend.util import log_function_call - from . import commands from .commands import Command from .commands import CommandId @@ -27,6 +25,7 @@ from .commands import Request from .commands import Response from .commands import get_response_class from .container import get_initialize_container +from .util import log_function_call _logger = logging.getLogger(__file__) diff --git a/backend/nginx.conf b/backend/nginx.conf new file mode 100644 index 0000000..0c42b0b --- /dev/null +++ b/backend/nginx.conf @@ -0,0 +1,47 @@ +user www-data; +worker_processes auto; +pid /run/nginx.pid; + +events { + worker_connections 1024; + use epoll; + multi_accept on; +} + +http { + access_log /dev/stdout; + error_log /dev/stdout; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + client_max_body_size 20M; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + index index.html index.htm; + + server { + listen 80 default_server; + listen [::]:80 default_server; + server_name localhost; + root /var/www/html; + + location / { + include uwsgi_params; + uwsgi_pass unix:/tmp/uwsgi.socket; + uwsgi_read_timeout 1h; + uwsgi_send_timeout 1h; + proxy_send_timeout 1h; + proxy_read_timeout 1h; + + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + } + } +} diff --git a/backend/pytest.ini b/backend/pytest.ini index de532e4..48b83af 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -3,3 +3,6 @@ addopts = -s markers = client: required client device (dongle) + +env = + D:TEST_BASE_URL=https://monsun.berthoud.dev \ No newline at end of file diff --git a/backend/system_tests/test_login.py b/backend/system_tests/test_login.py new file mode 100644 index 0000000..f7c305a --- /dev/null +++ b/backend/system_tests/test_login.py @@ -0,0 +1,74 @@ +import logging +import os + +import pytest +import requests +from flask_api import status + +BASE_URL = os.environ.get("TEST_BASE_URL", "http://localhost") +LOGIN_URI = BASE_URL + "/login" +LOGOUT_URI = BASE_URL + "/logout" +ADMIN_URI = BASE_URL + "/admin" + +PASSWORD = os.environ.get("MONSUN_PASSWORD", "password") + + +@pytest.fixture(autouse=True) +def log_variables(): + logger = logging.getLogger("system tests") + logger.info(f"BASE_URL: {BASE_URL}") + + +@pytest.fixture() +def session() -> requests.Session: + session = requests.Session() + response_login = session.post( + url=LOGIN_URI, + data={"email": "andreasberthoud@gmail.com", "password": PASSWORD}, + ) + assert response_login.status_code == status.HTTP_200_OK + return session + + +def test_login_as_admin_and_accessing_admin_endpoint(): + session_admin_ = requests.Session() + response_login = session_admin_.post( + url=LOGIN_URI, + data={"email": "andreasberthoud@gmail.com", "password": PASSWORD}, + ) + assert response_login.status_code == status.HTTP_200_OK + + response_admin_logged_in = session_admin_.get( + url=ADMIN_URI, + ) + assert response_admin_logged_in.status_code == status.HTTP_200_OK + + response_logout = session_admin_.delete( + url=LOGOUT_URI, + ) + assert response_logout.status_code == status.HTTP_200_OK + + response_admin_logged_out = session_admin_.get( + url=ADMIN_URI, + ) + assert response_admin_logged_out.status_code == status.HTTP_403_FORBIDDEN + + +def test_toggle_client_led_then_status_is_ok(session): + response = session.post( + url=BASE_URL + "/client/command?cmd=led&id=red&command=toggle", + ) + assert response.status_code == status.HTTP_200_OK + + +def test_pair_with_server_then_status_is_ok(session): + response = session.post(url=BASE_URL + "/client/command?cmd=gp&command_id=1") + assert response.status_code == status.HTTP_200_OK + + +def test_toggle_server_led_then_status_is_ok(session): + """Requires a connected GATT server""" + response = session.post( + url=BASE_URL + "/client/command?cmd=led&id=red&command=toggle&target=server", + ) + assert response.status_code == status.HTTP_200_OK diff --git a/backend/tests/docker-compose.yml b/backend/tests/docker-compose.yml new file mode 100644 index 0000000..71d61fd --- /dev/null +++ b/backend/tests/docker-compose.yml @@ -0,0 +1,12 @@ +version: "2" + +services: + tests_monsun_postgres-test: + image: postgres + environment: + POSTGRES_PASSWORD: pass + POSTGRES_USER: usr + POSTGRES_DB: sqlalchemy + POSTGRES_HOST: postgres + ports: + - 5432:5432 diff --git a/backend/tests/requirements.txt b/backend/tests/requirements.txt index e079f8a..32cb7ff 100644 --- a/backend/tests/requirements.txt +++ b/backend/tests/requirements.txt @@ -1 +1,3 @@ pytest +pytest-env +requests diff --git a/backend/uwsgi.ini b/backend/uwsgi.ini new file mode 100644 index 0000000..47232f1 --- /dev/null +++ b/backend/uwsgi.ini @@ -0,0 +1,12 @@ +[uwsgi] +wsgi-file = wsgi.py +uid = www-data +gid = www-data +master = true +processes = 5 + +socket = /tmp/uwsgi.socket +chmod-sock = 664 +vacuum = true + +die-on-term = true diff --git a/backend/wait-for-it.sh b/backend/wait-for-it.sh new file mode 100644 index 0000000..2b0db3e --- /dev/null +++ b/backend/wait-for-it.sh @@ -0,0 +1,79 @@ +#!/bin/sh +# https://github.com/vishnubob/wait-for-it +TIMEOUT=15 +QUIET=0 + +echoerr() { + if [ "$QUIET" -ne 1 ]; then printf "%s\n" "$*" 1>&2; fi +} + +usage() { + exitcode="$1" + cat << USAGE >&2 +Usage: + $cmdname host:port [-t timeout] [-- command args] + -q | --quiet Do not output any status messages + -t TIMEOUT | --timeout=timeout Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit "$exitcode" +} + +wait_for() { + for i in `seq $TIMEOUT` ; do + nc -z "$HOST" "$PORT" > /dev/null 2>&1 + + result=$? + if [ $result -eq 0 ] ; then + if [ $# -gt 0 ] ; then + exec "$@" + fi + exit 0 + fi + sleep 1 + done + echo "Operation timed out" >&2 + exit 1 +} + +while [ $# -gt 0 ] +do + case "$1" in + *:* ) + HOST=$(printf "%s\n" "$1"| cut -d : -f 1) + PORT=$(printf "%s\n" "$1"| cut -d : -f 2) + shift 1 + ;; + -q | --quiet) + QUIET=1 + shift 1 + ;; + -t) + TIMEOUT="$2" + if [ "$TIMEOUT" = "" ]; then break; fi + shift 2 + ;; + --timeout=*) + TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + break + ;; + --help) + usage 0 + ;; + *) + echoerr "Unknown argument: $1" + usage 1 + ;; + esac +done + +if [ "$HOST" = "" -o "$PORT" = "" ]; then + echoerr "Error: you need to provide a host and port to test." + usage 2 +fi + +wait_for "$@" \ No newline at end of file