Browse Source

Add initial Docker support for backend

ble
Andreas Berthoud 4 years ago
parent
commit
da2008b750
  1. 3
      .gitignore
  2. 4
      backend/.dockerignore
  3. 1
      backend/.gitignore
  4. 7
      backend/dev.env
  5. 38
      backend/docker-compose-prod.yml
  6. 25
      backend/docker-compose.yml
  7. 6
      backend/docker-entrypoint.sh
  8. 3
      backend/docker_build_and_upload.sh
  9. 45
      backend/dockerfile
  10. 1
      backend/migrations/README
  11. 50
      backend/migrations/alembic.ini
  12. 88
      backend/migrations/env.py
  13. 24
      backend/migrations/script.py.mako
  14. 58
      backend/migrations/versions/312674fa9656_.py
  15. 73
      backend/monsun_backend/__init__.py
  16. 3
      backend/monsun_backend/access/__init__.py
  17. 25
      backend/monsun_backend/access/admin.py
  18. 10
      backend/monsun_backend/access/init.py
  19. 24
      backend/monsun_backend/access/permissions.py
  20. 3
      backend/monsun_backend/command_execution.py
  21. 37
      backend/monsun_backend/container.py
  22. 40
      backend/monsun_backend/database.py
  23. 3
      backend/monsun_backend/defaults/config.yml
  24. 0
      backend/monsun_backend/endpoints/__init__.py
  25. 15
      backend/monsun_backend/endpoints/admin.py
  26. 14
      backend/monsun_backend/endpoints/command.py
  27. 38
      backend/monsun_backend/endpoints/login.py
  28. 23
      backend/monsun_backend/endpoints/logout.py
  29. 52
      backend/monsun_backend/error.py
  30. 19
      backend/monsun_backend/marshmallow_schemas.py
  31. 3
      backend/monsun_backend/models/__init__.py
  32. 152
      backend/monsun_backend/models/user_role.py
  33. 47
      backend/nginx.conf
  34. 8
      backend/pytest.ini
  35. 9
      backend/requirements.txt
  36. 5
      backend/setup-nas-env.sh
  37. 74
      backend/system_tests/test_login.py
  38. 60
      backend/tests/conftest.py
  39. 12
      backend/tests/docker-compose.yml
  40. 50
      backend/tests/endpoints/test_login.py
  41. 3
      backend/tests/requirements.txt
  42. 40
      backend/tests/utilities.py
  43. 12
      backend/uwsgi.ini
  44. 79
      backend/wait-for-it.sh

3
.gitignore

@ -65,4 +65,5 @@ Release/
.vscode/
venv*/
*.pyc
config.yml
monsun_config.yml
.idea/

4
backend/.dockerignore

@ -0,0 +1,4 @@
**/__pycache__/
build/
dist/
**/*.egg-info

1
backend/.gitignore

@ -2,3 +2,4 @@
build/
dist/
**/*.egg-info
db-data/

7
backend/dev.env

@ -0,0 +1,7 @@
FLASK_APP=monsun_backend
FLASK_ENV=development
SECRET=\\\x10#\xd9\x89,2|hBN\xe4\xdf\xe0\xf7W
POSTGRES_PASSWORD=pass
POSTGRES_USER=usr
POSTGRES_HOST=localhost
POSTGRES_DB_NAME=monsun_backend

38
backend/docker-compose-prod.yml

@ -0,0 +1,38 @@
version: "3"
services:
monsun_postgres:
image: postgres
container_name: monsun_postgres
restart: always
logging:
driver: json-file
options:
max-size: "200k"
max-file: "10"
volumes:
- ./db-data:/var/lib/postgresql/data
env_file: prod.env
monsun_backend:
image: registry.berthoud.dev/monsun_backend
container_name: monsun_backend
restart: always
logging:
driver: json-file
options:
max-size: "200k"
max-file: "10"
ports:
- 30010:80
volumes:
- ./config:/var/config/
depends_on:
- monsun_postgres
environment:
POSTGRES_HOST: monsun_postgres
env_file: prod.env
devices:
- "/dev/ttyACM0:/dev/tty.client"
command: ["bash", "./wait-for-it.sh", "monsun_postgres:5432", "-t", "60", "--", "./docker-entrypoint.sh"]

25
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"]

6
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

3
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

45
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"]

1
backend/migrations/README

@ -0,0 +1 @@
Single-database configuration for Flask.

50
backend/migrations/alembic.ini

@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

88
backend/migrations/env.py

@ -0,0 +1,88 @@
from __future__ import with_statement
import logging
from logging.config import fileConfig
from alembic import context
from flask import current_app
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger("alembic.env")
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option(
"sqlalchemy.url",
str(current_app.extensions["migrate"].db.get_engine().url).replace("%", "%%"),
)
target_metadata = current_app.extensions["migrate"].db.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, "autogenerate", False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info("No changes in schema detected.")
connectable = current_app.extensions["migrate"].db.get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions["migrate"].configure_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
backend/migrations/script.py.mako

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

58
backend/migrations/versions/312674fa9656_.py

@ -0,0 +1,58 @@
"""empty message
Revision ID: 312674fa965
Revises:
Create Date: 2021-08-03 18:17:22.918120
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "312674fa9656"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"role",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=80), nullable=True),
sa.Column("description", sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_table(
"user",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("password", sa.String(length=512), nullable=True),
sa.Column("active", sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
op.create_table(
"roles_users",
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("role_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["role_id"],
["role.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["user.id"],
),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("roles_users")
op.drop_table("user")
op.drop_table("role")
# ### end Alembic commands ###

73
backend/monsun_backend/__init__.py

@ -1,21 +1,62 @@
import os
import logging
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 . import command_endpoint
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
from . import database
from . import error
from . import models
from .endpoints import admin
from .endpoints import command
from .endpoints import login
from .endpoints import logout
__title__ = "monsun-backend"
__title__ = "monsun_backend"
__author__ = "Andreas Berthoud, Fabian Klein"
__version__ = "0.0.0"
__email__ = "andreasberthoud@gmail.com"
__copyright__ = f"2021 {__author__}"
os.environ.update(
{
"DATABASE_URI": "postgresql+psycopg2://"
"{dbuser}:{dbpass}@{dbhost}/{dbname}{sslmode}".format(
dbuser=os.getenv("POSTGRES_USER", "usr"),
dbpass=os.getenv("POSTGRES_PASSWORD", "pass"),
dbhost=os.getenv("POSTGRES_HOST", "localhost"),
dbname=os.getenv("POSTGRES_DB_NAME", "monsun_backend"),
sslmode="?sslmode=require" if os.getenv("ENABLE_SSL") is not None else "",
),
},
)
migrate = Migrate()
def create_app() -> FlaskAPI:
app = FlaskAPI(__name__)
app.register_blueprint(command_endpoint.bp)
app.register_blueprint(command.bp)
app.register_blueprint(login.bp)
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(
{
@ -42,8 +83,30 @@ def create_app() -> FlaskAPI:
},
},
)
logger = logging.getLogger("monsun_backend.init")
_exception_logger = logging.getLogger("monsun_backend.exception")
app.secret_key = os.getenv("SECRET")
app.config.update(
SQLALCHEMY_TRACK_MODIFICATIONS=False,
SQLALCHEMY_DATABASE_URI=os.getenv("DATABASE_URI"),
)
logger.info(f"DATABASE_URI: {os.getenv('DATABASE_URI')}")
database.db.init_app(app)
migrate.init_app(app, database.db)
Marshmallow(app)
Security(app=app, datastore=models.user_datastore)
access.init_app(app)
# container.init_app(app)
# register error handler
@app.errorhandler(error.MonsunError)
def monsun_error_handler(e: error.MonsunError):
if 500 > e.status_code >= 400:
_exception_logger.info(f"Exception raised ({e.status_code}): {e.data}")
if e.status_code >= 500:
_exception_logger.error(f"Exception raised ({e.status_code}): {e.data}")
return e.response
if os.environ.get("WERKZEUG_RUN_MAIN") != "true":
# prevent from be called twice in debug mode

3
backend/monsun_backend/access/__init__.py

@ -0,0 +1,3 @@
from .admin import make_admin_user # noqa
from .init import init_app # noqa
from .permissions import admin_permission # noqa

25
backend/monsun_backend/access/admin.py

@ -0,0 +1,25 @@
from flask import Flask
from monsun_backend import models
from ..container import get_initialize_container
def init_app(app: Flask):
app.before_first_request(make_admin_user)
def make_admin_user() -> models.User:
"""Makes the admin user if he does not exist
The admin is always the first user (id==1)
"""
container = get_initialize_container()
admin_user: models.User = models.user_datastore.get_user(identifier=1)
if admin_user is None:
admin_user = models.user_datastore.create_user(
email=container.config.admin_user_email(),
password=container.config.admin_user_password(),
)
admin_user.save()
return admin_user

10
backend/monsun_backend/access/init.py

@ -0,0 +1,10 @@
"""Cannot be part ot __init__.py because it must be executed AFTER imports"""
from flask import Flask
from . import admin
from . import permissions
def init_app(app: Flask):
admin.init_app(app=app)
permissions.init_app(app=app)

24
backend/monsun_backend/access/permissions.py

@ -0,0 +1,24 @@
from flask import Flask
from flask_login import current_user
from flask_principal import AnonymousIdentity
from flask_principal import Identity
from flask_principal import Permission
from flask_principal import Principal
from flask_principal import RoleNeed
from monsun_backend import models
principals = Principal()
@principals.identity_loader
def read_identity_from_flask_login():
if current_user.is_authenticated:
return Identity(current_user.id)
return AnonymousIdentity()
def init_app(app: Flask):
principals.init_app(app=app)
admin_permission = Permission(RoleNeed(models.RoleType.admin.name))

3
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__)

37
backend/monsun_backend/container.py

@ -1,8 +1,6 @@
import logging
from pathlib import Path
from pprint import pformat
from typing import Dict
from typing import Optional
from dependency_injector import containers
from dependency_injector import providers
@ -27,30 +25,33 @@ class Container(containers.DeclarativeContainer):
@log_function_call
def get_initialize_container(
config: Optional[Dict] = None,
config_file: Optional[Path] = None,
) -> Container:
def get_initialize_container() -> Container:
logger = _logger.getChild("initialize_container")
logger.debug("initialize container...")
container = Container()
logger.debug(f"initialize container from config file: {CONFIG_FILE}")
logger.debug(f"initialize container with defaults from config file: {CONFIG_FILE}")
container.config.from_yaml(CONFIG_FILE, required=True)
user_config = Path.cwd() / "config.yml"
if user_config.is_file():
logger.debug(f"initialize container from user config file: {user_config}")
container.config.from_yaml(user_config, required=True)
if config is not None:
logger.debug(f"initialize container with config: {config}")
container.config.from_dict(config)
if config_file is not None:
current_dir = Path.cwd()
is_searching = True
while is_searching:
config_file = current_dir / "monsun_config.yml"
if config_file.is_file():
logger.debug(f"initialize container from user config file: {config_file}")
container.config.from_yaml(config_file, required=True)
break
else:
current_dir = current_dir.parent
if str(current_dir) == current_dir.anchor:
is_searching = False
config_file = Path("/var/config/monsun_config.yml")
if config_file.is_file():
logger.debug(f"initialize container from config file: {config_file}")
container.config.from_yaml(config_file)
container.config.from_yaml(config_file, required=True)
logger.debug(f"container config:\n{pformat(container.config())}")
return container

40
backend/monsun_backend/database.py

@ -0,0 +1,40 @@
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import Column
from sqlalchemy import Integer
from sqlalchemy.ext.declarative import as_declarative
from sqlalchemy.ext.declarative import declared_attr
@as_declarative()
class BaseModel:
"""This class provides helper methods that can be used by its subclasses"""
id = Column(Integer, primary_key=True)
@declared_attr
def __tablename__(cls):
return cls.__name__.lower()
def save(self):
db.session.add(self)
db.session.commit()
return self
def delete(self):
db.session.delete(self)
db.session.commit()
@classmethod
def get_all(cls):
return cls.query.all()
def __repr__(self):
return f"<{self.__class__.__name__} {self.name!r}>"
def session_commit():
"""Shortcut for ``db.session.commit()``"""
db.session.commit()
db = SQLAlchemy(model_class=BaseModel)

3
backend/monsun_backend/defaults/config.yml

@ -2,3 +2,6 @@ baudrate: 115200
header_size: 4
heartbeat_interval: 1
serial_reconnection_wait_timeout: 1
admin_user_email: andreasberthoud@gmail.com
admin_user_password: password
roles: []

0
backend/monsun_backend/endpoints/__init__.py

15
backend/monsun_backend/endpoints/admin.py

@ -0,0 +1,15 @@
from flask import blueprints
from flask_api import status
from .. import access
bp = blueprints.Blueprint("admin", __name__)
@bp.route(
rule="/admin",
methods=["GET"],
)
@access.admin_permission.require(http_exception=status.HTTP_403_FORBIDDEN)
def admin():
return "success", 200

14
backend/monsun_backend/command_endpoint.py → backend/monsun_backend/endpoints/command.py

@ -7,12 +7,13 @@ from flask import make_response
from flask import request
from flask_api import status
from . import commands
from .command_execution import execute_command
from .commands import CommandId
from .commands import CommandTarget
from .commands import get_command_id_from_name
from .commands import get_request_class
from .. import access
from .. import commands
from ..command_execution import execute_command
from ..commands import CommandId
from ..commands import CommandTarget
from ..commands import get_command_id_from_name
from ..commands import get_request_class
_logger = logging.getLogger(__name__)
@ -20,6 +21,7 @@ bp = blueprints.Blueprint("command", __name__)
@bp.route("/<role>/command", methods=["POST", "GET"])
@access.admin_permission.require(http_exception=status.HTTP_403_FORBIDDEN)
def command(role: str):
logger = _logger.getChild(f"{role}/command")

38
backend/monsun_backend/endpoints/login.py

@ -0,0 +1,38 @@
from typing import Optional
from flask import blueprints
from flask import make_response
from flask import request
from flask_api import status
from flask_login import login_user
from monsun_backend import error
from monsun_backend import marshmallow_schemas
from monsun_backend import models
bp = blueprints.Blueprint("login", __name__)
@bp.route(
rule="/login",
methods=["POST"],
)
def login():
email_: Optional[str] = request.form.get("email")
if email_ is None:
raise error.BadRequest("email is missing")
user_: models.User = models.User.query.filter_by(email=email_).first()
if not user_:
raise error.BadRequest("User does not exist")
if user_.check_password(password=request.form.get("password")):
if login_user(user_):
return make_response(
marshmallow_schemas.user_schema.jsonify(user_),
status.HTTP_200_OK,
)
else:
# this shouldn't happen, but one never knows...
raise error.InternalServerError("Could not login user")
else:
raise error.Unauthorized("Password is incorrect")

23
backend/monsun_backend/endpoints/logout.py

@ -0,0 +1,23 @@
from flask import blueprints
from flask import jsonify
from flask import make_response
from flask_api import status
from flask_login import login_required
from flask_login import logout_user
bp = blueprints.Blueprint("logout", __name__)
@bp.route(
rule="/logout",
methods=["DELETE"],
)
@login_required
def logout():
# Remove the user information from the session
logout_user()
return make_response(
jsonify(message="logout successful"),
status.HTTP_200_OK,
)

52
backend/monsun_backend/error.py

@ -0,0 +1,52 @@
from typing import Dict
from typing import Optional
from typing import Union
from flask import Response
from flask import jsonify
from flask_api import status
class MonsunError(Exception):
status_code = status.HTTP_501_NOT_IMPLEMENTED
data: Dict
message: Optional[str]
def __init__(
self,
content: Union[str, Dict],
):
if isinstance(content, str):
self.message = content
self.data = {"message": self.message}
elif isinstance(content, Dict):
self.data = content
else:
raise ValueError("'content' must be either a string or an dict")
@property
def response(self) -> Response:
response: Response = jsonify(self.data)
response.status_code = self.status_code
return response
class BadRequest(MonsunError):
status_code = status.HTTP_400_BAD_REQUEST
class Unauthorized(MonsunError):
status_code = status.HTTP_401_UNAUTHORIZED
class Forbidden(MonsunError):
status_code = status.HTTP_403_FORBIDDEN
class InternalServerError(MonsunError):
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
class ConfigurationError(InternalServerError):
"""raised in case of invalid configuration"""

19
backend/monsun_backend/marshmallow_schemas.py

@ -0,0 +1,19 @@
from flask_marshmallow import Marshmallow
from marshmallow import fields
from .models import User
ma = Marshmallow()
class UserSchema(ma.SQLAlchemySchema): # type: ignore
class Meta:
model = User
id = fields.Int()
email = fields.Str()
active = fields.Bool()
user_schema = UserSchema()
user_schema_public = UserSchema(only=("id", "email"))

3
backend/monsun_backend/models/__init__.py

@ -0,0 +1,3 @@
from .user_role import RoleType # noqa
from .user_role import User # noqa
from .user_role import user_datastore # noqa

152
backend/monsun_backend/models/user_role.py

@ -0,0 +1,152 @@
from __future__ import annotations
import enum
from typing import Dict
from typing import Optional
from typing import Sequence
from flask_security import RoleMixin
from flask_security import SQLAlchemyUserDatastore
from flask_security import UserMixin
from werkzeug.security import check_password_hash
from werkzeug.security import generate_password_hash
from ..database import BaseModel
from ..database import db
roles_users = db.Table(
"roles_users",
db.Column("user_id", db.Integer(), db.ForeignKey("user.id")),
db.Column("role_id", db.Integer(), db.ForeignKey("role.id")),
)
class RoleType(enum.Enum):
"""The type of a role"""
admin: int = 1
ROLE_DESCRIPTIONS: Dict[RoleType, str] = {
RoleType.admin: "Has admin rights",
}
class Role(BaseModel, RoleMixin):
id: int # type: ignore
"""The DB id"""
name: str = db.Column(db.String(80), unique=True)
"""The name of the role, see :class:`RoleType`"""
description: str = db.Column(db.String(255))
"""A short description of the role"""
def __init__(
self,
name: str,
description: str,
):
"""
:param name: The name of the role, see :class:`RoleType`
:param description: A short description of the role
"""
self.name = name
self.description = description
@classmethod
def get_role(
cls,
role_type: RoleType,
) -> Role:
"""Fetches the role from the DB or creates it if necessary
:param role_type: The type of the role
:return: The role instance
"""
role_: Optional[Role] = cls.query.filter_by(name=role_type.name).first()
if role_ is None:
role_ = cls(
name=role_type.name,
description=ROLE_DESCRIPTIONS[role_type],
)
role_.save()
return role_
class User(BaseModel, UserMixin):
id: int # type: ignore
"""The DB id"""
email: str = db.Column(db.String(255), unique=True)
"""The email address"""
password: str = db.Column(db.String(512))
"""The password as hash"""
active: bool = db.Column(db.Boolean())
"""Is the user activated?"""
roles: Sequence[Role] = db.relationship(
"Role",
secondary=roles_users,
backref=db.backref("users", lazy="dynamic"),
)
"""The roles which the user has"""
def __init__(
self,
email: str,
password: str,
active: bool = True,
roles: Sequence[Role] = None,
):
"""
:param email: The email address
:param password: The password as plain text
:param active: *True* if the user is active
:param roles: The Roles of the user.
"""
self.email = email
self.set_password(password=password)
self.active = active
self.roles = roles or [Role.get_role(RoleType.admin)]
def set_password(
self,
password: str,
):
"""Create hashed password."""
self.password = generate_password_hash(
password=password,
)
def check_password(
self,
password: str,
) -> bool:
"""Check hashed password
:param password: The password in plain text
:return: True if equal
"""
return bool(
check_password_hash(
pwhash=self.password,
password=password,
),
)
def __repr__(self):
return (
f"<{self.__class__.__name__} "
f"email={self.email!r}, "
f"active={self.active!r}, "
f"roles={self.roles!r}>"
)
user_datastore = SQLAlchemyUserDatastore(db, User, Role)

47
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;
}
}
}

8
backend/pytest.ini

@ -0,0 +1,8 @@
[pytest]
addopts = -s
markers =
client: required client device (dongle)
env =
D:TEST_BASE_URL=https://monsun.berthoud.dev

9
backend/requirements.txt

@ -1,4 +1,13 @@
dependency-injector[yaml]>=4.34.0,<5
email_validator
flask-api>=3.0.post1,<4
flask-migrate
flask-script
flask-sqlalchemy
flask_marshmallow
flask_security
marshmallow-sqlalchemy
psycopg2-binary
pyserial>=3.5,<4
sqlalchemy-utils
uwsgi

5
backend/setup-nas-env.sh

@ -0,0 +1,5 @@
# Remove DB data and create clean folder
rm -rfd db-data/ && mkdir db-data
# Enable RW for everyone
chmod 666 /dev/ttyACM0

74
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

60
backend/tests/conftest.py

@ -0,0 +1,60 @@
import os
from typing import Iterator
from typing import Type
import pytest
import utilities
from flask.testing import FlaskClient
from monsun_backend.container import get_initialize_container
from sqlalchemy import create_engine
from sqlalchemy_utils import create_database
from sqlalchemy_utils import database_exists
from utilities import setup_app
@pytest.fixture(scope="session", autouse=True)
def db_test():
"""makes sure that the DB exists"""
engine = create_engine(os.getenv("DATABASE_URI"))
if not database_exists(engine.url):
create_database(engine.url)
@pytest.fixture(scope="class")
def app_class_scope(db_test):
with setup_app() as app:
yield app
@pytest.fixture(scope="class")
def client_class_scope(app_class_scope):
return app_class_scope.test_client
@pytest.fixture()
def app(db_test):
with setup_app() as app:
yield app
@pytest.fixture()
def client(app):
return app.test_client
@pytest.fixture(scope="class")
def as_admin(
client_class_scope: Type[FlaskClient],
) -> Iterator[utilities.TestUser]:
container = get_initialize_container()
with client_class_scope() as client_:
yield utilities.TestUser(
client_.post(
"/login",
data={
"email": container.config.admin_user_email(),
"password": container.config.admin_user_password(),
},
),
client_,
)

12
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

50
backend/tests/endpoints/test_login.py

@ -0,0 +1,50 @@
from flask import Response
from flask_api import status
from monsun_backend.container import get_initialize_container
from utilities import TestUser
def test_login_as_admin_then_get_status_ok(client):
container = get_initialize_container()
response: Response = client().post(
"/login",
data={
"email": container.config.admin_user_email(),
"password": container.config.admin_user_password(),
},
)
assert response.status_code == status.HTTP_200_OK
def test_logout_as_not_logged_in_user_then_get_status_found(client):
response: Response = client().get(
"/logout",
)
assert response.status_code == status.HTTP_302_FOUND
def test_logout_as_admin_then_get_status_found(as_admin: TestUser):
response: Response = as_admin.client.get(
"/logout",
)
assert response.status_code == status.HTTP_302_FOUND
def test_as_admin_access_admin_endpoint_then_get_status_ok(as_admin: TestUser):
response: Response = as_admin.client.get(
"/admin",
)
assert response.status_code == status.HTTP_200_OK
def test_as_not_logged_in_user_access_admin_endpoint_then_get_status_forbidden(
client,
): # noqa
response: Response = client().get(
"/admin",
)
assert response.status_code == status.HTTP_403_FORBIDDEN

3
backend/tests/requirements.txt

@ -0,0 +1,3 @@
pytest
pytest-env
requests

40
backend/tests/utilities.py

@ -0,0 +1,40 @@
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator
from flask import Flask
from flask import Response
from flask.testing import FlaskClient
from monsun_backend import access
from monsun_backend import create_app
from monsun_backend import database
TESTS_DIR = Path(__file__).parent
@contextmanager
def setup_app() -> Iterator[Flask]:
app: Flask = create_app()
app.config["SERVER_NAME"] = "monsun_backend.localdomain"
app.config["TESTING"] = True
# binds the app to the current context
with app.app_context():
# create all tables
database.db.create_all()
access.make_admin_user()
yield app
with app.app_context():
# drop all tables
database.db.session.remove()
database.db.drop_all()
@dataclass
class TestUser:
__test__ = False
response: Response
client: FlaskClient

12
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

79
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 "$@"
Loading…
Cancel
Save