diff --git a/backend/deploy.dockerfile b/backend/deploy.dockerfile index e7dceae13..a9eeb5cbc 100644 --- a/backend/deploy.dockerfile +++ b/backend/deploy.dockerfile @@ -12,6 +12,11 @@ COPY requirements.txt . COPY pyproject.toml . COPY alembic.ini.example alembic.ini COPY scripts/entry.sh scripts/entry.sh +COPY scripts/cron /etc/cron.d/appointment-cron + +# Setup cron permissions +RUN chmod 0644 /etc/cron.d/appointment-cron +RUN crontab /etc/cron.d/appointment-cron # Needed for deploy, we don't have a volume attached COPY src . @@ -25,5 +30,6 @@ RUN pip install .'[deploy]' RUN mkdir src RUN ln -s /app/appointment src/appointment + EXPOSE 5000 CMD ["/bin/sh", "./scripts/entry.sh"] diff --git a/backend/requirements.txt b/backend/requirements.txt index e4e109408..4845f23ca 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -22,6 +22,7 @@ sentry-sdk==1.26.0 starlette-context==0.3.6 sqlalchemy-utils==0.39.0 sqlalchemy==1.4.40 +typer[all]==0.9.0 tzdata==2022.7 uvicorn==0.20.0 validators==0.20.0 diff --git a/backend/scripts/dev-entry.sh b/backend/scripts/dev-entry.sh index 1425bbd1b..dfbb91f40 100644 --- a/backend/scripts/dev-entry.sh +++ b/backend/scripts/dev-entry.sh @@ -1,10 +1,13 @@ #!/bin/sh -run-command update-db +run-command main update-db # Start up fake mail server python -u -m smtpd -n -c DebuggingServer localhost:8050 & +# Start cron +service cron start + # Start up real webserver uvicorn --factory appointment.main:server --reload --host 0.0.0.0 --port 5173 diff --git a/backend/scripts/entry.sh b/backend/scripts/entry.sh index 721b462dd..883c736eb 100644 --- a/backend/scripts/entry.sh +++ b/backend/scripts/entry.sh @@ -1,5 +1,5 @@ #!/bin/sh -run-command update-db +run-command main update-db uvicorn --factory appointment.main:server --host 0.0.0.0 --port 5000 diff --git a/backend/src/appointment/main.py b/backend/src/appointment/main.py index 8be487a8e..abb78bde7 100644 --- a/backend/src/appointment/main.py +++ b/backend/src/appointment/main.py @@ -20,12 +20,12 @@ import logging import sys +import typer from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.exception_handlers import ( http_exception_handler, ) -from starlette_context import context import sentry_sdk @@ -136,20 +136,15 @@ async def catch_google_refresh_errors(request, exc): def cli(): """ - A very simple cli handler + Entrypoint for our typer cli """ - if len(sys.argv) < 2: - print("No command specified") - return - # Run common setup first _common_setup() - command = sys.argv[1:] - - if command[0] == 'update-db': - from .commands import update_db - update_db.run() - + from .routes import commands + app = typer.Typer() + # We don't have too many commands, so just dump them under main for now. + app.add_typer(commands.router, name="main") + app() diff --git a/backend/src/appointment/routes/commands.py b/backend/src/appointment/routes/commands.py new file mode 100644 index 000000000..61b549ccb --- /dev/null +++ b/backend/src/appointment/routes/commands.py @@ -0,0 +1,31 @@ +"""This file handles routing for console commands""" +from contextlib import contextmanager +import os + +import typer +from ..commands import update_db + +router = typer.Typer() + + +@contextmanager +def cron_lock(lock_name): + """Context manager helper to create a cron lockfile or error out with FileExistsError.""" + lock_file_name = f'/tmp/{lock_name}.lock' + + # Lock file exists? Don't run + if os.path.isfile(lock_file_name): + raise FileExistsError + + fh = open(lock_file_name, 'w+') + try: + yield + finally: + fh.close() + os.remove(lock_file_name) + + +@router.command('update-db') +def update_database(): + update_db.run() + diff --git a/backend/test/unit/test_commands.py b/backend/test/unit/test_commands.py new file mode 100644 index 000000000..713c891c6 --- /dev/null +++ b/backend/test/unit/test_commands.py @@ -0,0 +1,31 @@ +import os + +import pytest + +from appointment.routes.commands import cron_lock + + +def test_cron_lock(): + """Test our cron lock function, this does use disk io but should clean itself up after.""" + test_lock_name = 'test_cron_lock_run' + test_lock_file_name = f'/tmp/{test_lock_name}.lock' + + # Clean up in case the lock file previously exists + if os.path.isfile(test_lock_file_name): + os.remove(test_lock_file_name) + + # Test that the lock works + with cron_lock(test_lock_name): + assert os.path.isfile(test_lock_file_name) + + # And cleans itself up + assert not os.path.isfile(test_lock_file_name) + + # Test a lock already exists case with way too many withs. + with open(test_lock_file_name, 'w'): + with pytest.raises(FileExistsError): + with cron_lock(test_lock_name): + pass + + # Remove the lock file we manually created + os.remove(test_lock_file_name)