diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..1fe1162 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,82 @@ +# bats-ai + +## Deployment with Docker (recommended quickstart) + +This was a bit rushed so the deployment utilizes a single +docker file `docker-compose.prod.yml` in the root of the directory + +I wanted some simple instructions below to configure the deployment + +Be sure to use the proper hostname (batdetectai.kitware.com) in +all locations that require it. + +## Docker Compose Differences + +I created a `client` service which has it's own Dockerfile and +builds the vue client app. +The `client` service also uses a reverse proxy to route +`/api`, `/admin` fields to the django server. +The client will need to be built with a different Client ID +for accessing the server. + +### Initial Setup for Deployment + +1. Run `docker compose run --rm django ./manage.py migrate` +2. Run `docker compose run --rm django ./manage.py createsuperuser` + and follow the prompts to create your own user +3. Run `docker compose run --rm django ./manage.py makeclient \ + --username your.super.user@email.address \ + --uri https://batdetectai.kitware.com/` +4. Run `docker compose run --rm django ./manage.py loaddata species` to load species + data into the database +5. Run `docker compose run --rm django ./manage.py collectstatic` + to collect the static files +6. Run `docker compose -f docker-compose.prod.yml up` to start the server + add `-d` for a silent version to run in the background +7. Copy over the ./dev/.env.prod.docker-compose.template + to `./dev/.env.prod.docker-compose.template` and change the default passwords +8. Change the ID in the `./client/env.production` to a custom ID - this will + probably require a `docker compose build` to build the app afterwards +9. After creating the basic application log into the django admin `batdetectai.kitware.com/admin` + and change the ApplicationId to the ID in the `./client.env.production` +10. Test logging in/out and uploading data to the server. + +### system.d service + +Service that will automatically start and launch the server +Create this at `/etc/systemd/system` using sudo + +```systemd +[Unit] +Description=batai-server +Requires=docker.service +After=docker.service + +[Service] +ExecStartPre=/bin/sleep 10 +Environment=PATH=/usr/bin:/sbin:/usr/sbin:/usr/local/sbin:/usr/local/bin +Restart=always +User=bryon +Group=docker +TimeoutStartSec=300 +RestartSec=20 +WorkingDirectory=/home/bryon/batai +# Shutdown container (if running) when unit is started +ExecStartPre=docker compose down +# Start container when unit is started +ExecStart=docker compose -f docker-compose.prod.yml up +# Stop container when unit is stopped +ExecStop=docker compose down + +[Install] +WantedBy=multi-user.target +``` + +After run `sudo systemctl enable batai.service` +Then to start you can use `sudo systemctl start batai.service` +Stopping: `sudo systemctl stop batai.service` + +### User Management + +There is no email server connected up so users need to be +individually approved and their email verified by an admin diff --git a/bats_ai/settings.py b/bats_ai/settings.py index 9764a92..11c29dd 100644 --- a/bats_ai/settings.py +++ b/bats_ai/settings.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from pathlib import Path from composed_configuration import ( @@ -10,12 +11,9 @@ ProductionBaseConfiguration, TestingBaseConfiguration, ) +from composed_configuration._configuration import _BaseConfiguration from configurations import values -CORS_ALLOWED_ORIGINS = [ - 'http://localhost:3000', -] - class BatsAiMixin(ConfigMixin): WSGI_APPLICATION = 'bats_ai.wsgi.application' @@ -80,6 +78,32 @@ class TestingConfiguration(BatsAiMixin, TestingBaseConfiguration): pass +class KitwareConfiguration(BatsAiMixin, _BaseConfiguration): + SECRET_KEY = values.SecretValue() + baseHost = 'batdetectai.kitware.com' + if 'SERVERHOSTNAME' in os.environ: + baseHost = os.environ['SERVERHOSTNAME'] + EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + DEFAULT_FILE_STORAGE = 'minio_storage.storage.MinioMediaStorage' + MINIO_STORAGE_ENDPOINT = values.Value( + 'minio:9000', + ) + MINIO_STORAGE_USE_HTTPS = values.BooleanValue(False) + MINIO_STORAGE_ACCESS_KEY = values.SecretValue() + MINIO_STORAGE_SECRET_KEY = values.SecretValue() + MINIO_STORAGE_MEDIA_BUCKET_NAME = values.Value( + environ_name='STORAGE_BUCKET_NAME', + environ_required=True, + ) + MINIO_STORAGE_AUTO_CREATE_MEDIA_BUCKET = True + MINIO_STORAGE_AUTO_CREATE_MEDIA_POLICY = 'READ_WRITE' + MINIO_STORAGE_MEDIA_USE_PRESIGNED = True + MINIO_STORAGE_MEDIA_URL = 'http://127.0.0.1:9000/django-storage' + ALLOWED_HOSTS = [baseHost] + CSRF_TRUSTED_ORIGINS = [f'https://{baseHost}', f'https://{baseHost}'] + CORS_ORIGIN_WHITELIST = [f'https://{baseHost}', f'https://{baseHost}'] + + class ProductionConfiguration(BatsAiMixin, ProductionBaseConfiguration): pass diff --git a/client/.env.production b/client/.env.production index edf6415..0eb0b2c 100644 --- a/client/.env.production +++ b/client/.env.production @@ -1,4 +1,3 @@ -VUE_APP_API_ROOT=https://CHANGEME/api/v1 -VUE_APP_OAUTH_API_ROOT=https://CHANGEME/oauth/ -VUE_APP_OAUTH_CLIENT_ID=CHANGEME -VUE_APP_SENTRY_DSN=CHANGEME +VUE_APP_API_ROOT=https://batdetectai.kitware.com/api/v1 +VUE_APP_OAUTH_API_ROOT=https://batdetectai.kitware.com/oauth/ +VUE_APP_OAUTH_CLIENT_ID=HSJWFZ2cIpWQOvNyCXyStV9hiOd7DfWeBOCzo4pP diff --git a/client/.env.production.test b/client/.env.production.test new file mode 100644 index 0000000..080cf16 --- /dev/null +++ b/client/.env.production.test @@ -0,0 +1,3 @@ +VUE_APP_API_ROOT=http://localhost/api/v1 +VUE_APP_OAUTH_API_ROOT=http://localhost/oauth/ +VUE_APP_OAUTH_CLIENT_ID=HSJWFZ2cIpWQOvNyCXyStV9hiOd7DfWeBOCzo4pP diff --git a/client/README.md b/client/README.md index 35a6427..4bf6c06 100644 --- a/client/README.md +++ b/client/README.md @@ -30,8 +30,8 @@ git grep CHANGEME ## Recommended IDE Setup -- [VSCode](https://code.visualstudio.com/) -- [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) +* [VSCode](https://code.visualstudio.com/) +* [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) ## Type Support For `.vue` Imports in TS diff --git a/dev/.env.docker-compose b/dev/.env.docker-compose index be89c27..3aadfbe 100644 --- a/dev/.env.docker-compose +++ b/dev/.env.docker-compose @@ -1,4 +1,6 @@ DJANGO_CONFIGURATION=DevelopmentConfiguration +DJANGO_DATABASE_NAME=django +DJANGO_DATABASE_PASSWORD=postgres DJANGO_DATABASE_URL=postgres://postgres:postgres@postgres:5432/django DJANGO_CELERY_BROKER_URL=amqp://rabbitmq:5672/ DJANGO_MINIO_STORAGE_ENDPOINT=minio:9000 diff --git a/dev/.env.prod.docker-compose.template b/dev/.env.prod.docker-compose.template new file mode 100644 index 0000000..f03ed11 --- /dev/null +++ b/dev/.env.prod.docker-compose.template @@ -0,0 +1,13 @@ +DJANGO_CONFIGURATION=KitwareConfiguration +DJANGO_DATABASE_NAME=django +DJANGO_DATABASE_PASSWORD=postgres +DJANGO_DATABASE_URL=postgres://postgres:postgres@postgres:5432/django +DJANGO_CELERY_BROKER_URL=amqp://rabbitmq:5672/ +DJANGO_MINIO_STORAGE_ENDPOINT=minio:9000 +DJANGO_MINIO_STORAGE_ACCESS_KEY=minioAccessKey +DJANGO_MINIO_STORAGE_SECRET_KEY=minioSecretKey +DJANGO_STORAGE_BUCKET_NAME=django-storage +DJANGO_MINIO_STORAGE_ENDPOINT=minio:9000 +SERVERHOSTNAME=batdetectai.kitware.com +DJANGO_SECRET_KEY=changeme +ACME_EMAIL=Bryon.Lewis@kitware.com diff --git a/dev/client.Dockerfile b/dev/client.Dockerfile new file mode 100644 index 0000000..908f5c1 --- /dev/null +++ b/dev/client.Dockerfile @@ -0,0 +1,36 @@ +# Use official Node.js image as the base image for building Vue.js app +FROM node:16 as build-stage + +# Set working directory +WORKDIR /app + +# Copy package.json and package-lock.json +COPY client/package*.json ./ + +# Install dependencies +RUN npm install + +# Copy the rest of the application +COPY client . + +# Build the Vue.js application +RUN npm run build + +# Use NGINX as the final base image +FROM nginx:alpine + +# Remove default NGINX website +RUN rm -rf /usr/share/nginx/html/* + +# Copy built Vue.js app to NGINX HTML directory +COPY --from=build-stage /app/dist /usr/share/nginx/html + +RUN ls +# Copy custom NGINX configuration +COPY nginx/nginx.conf /etc/nginx/nginx.conf + +# Expose port 80 +EXPOSE 80 + +# Start NGINX +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..234e2a1 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,172 @@ +version: '3.8' + +services: + # COMMENTED OUT UNTIL READY TO TEST + traefik: + restart: always + image: traefik:v2.4 + container_name: traefik + env_file: ./dev/.env.prod.docker-compose + networks: + - django-nginx + command: > + --providers.docker=true + --providers.docker.exposedByDefault=false + --log.level=${LOG_LEVEL:-WARN} + --providers.docker.exposedByDefault=false + --providers.file.filename=/var/traefik/dynamic.yml + --entrypoints.web.address=:80 + --entrypoints.websecure.address=:443 + --entrypoints.websecure.http.tls.certresolver=myresolver + --certificatesresolvers.myresolver.acme.email=${ACME_EMAIL:-Bryon.Lewis@kitware.com} + --certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json + --certificatesresolvers.myresolver.acme.httpchallenge=true + --certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web + --certificatesresolvers.myresolver.acme.caserver=${ACME_CA_SERVER:-https://acme-v02.api.letsencrypt.org/directory} + labels: + # Traefik HTTPS Redirect + - "traefik.enable=true" + - "traefik.http.routers.http-catchall.entrypoints=web" + - "traefik.http.routers.http-catchall.rule=Host(`${SERVERHOSTNAME:-batdetectai.kitware.com}`)" + - "traefik.http.routers.http-catchall.middlewares=redirect-to-https-mddl@docker" + - "traefik.http.middlewares.redirect-to-https-mddl.redirectscheme.scheme=https" + volumes: + - "${SOCK_PATH:-/var/run/docker.sock}:/var/run/docker.sock" + - "./traefik/letsencrypt:/letsencrypt" + - "./traefik/dynamic.yml:/var/traefik/dynamic.yml:ro" + ports: + - "80:80" + - "443:443" + + django: + build: + context: . + dockerfile: ./dev/django.Dockerfile + command: 'gunicorn bats_ai.wsgi:application --bind 0.0.0.0:8000' + # ./manage.py runserver 0.0.0.0:8000 --noreload + # entrypoint: ["/bin/bash"] + # command: "" + # Log printing via Rich is enhanced by a TTY + tty: true + env_file: ./dev/.env.prod.docker-compose + networks: + - django-nginx + volumes: + - .:/opt/django-project + environment: + - SERVERHOSTNAME=${SERVERHOSTNAME:-batdetectai.kitware.com} + ports: + - 8000:8000 + depends_on: + - postgres + - rabbitmq + - minio + celery: + build: + context: . + dockerfile: ./dev/django.Dockerfile + command: [ + "celery", + "--app", "bats_ai.celery", + "worker", + "--loglevel", "INFO", + "--without-heartbeat" + ] + # Docker Compose does not set the TTY width, which causes Celery errors + tty: false + env_file: ./dev/.env.prod.docker-compose + networks: + - django-nginx + volumes: + - .:/opt/django-project + depends_on: + - postgres + - rabbitmq + - minio + client: + build: + context: . + dockerfile: ./dev/client.Dockerfile + env_file: ./dev/.env.prod.docker-compose + networks: + - django-nginx + depends_on: + - django + labels: + - "traefik.http.routers.client-rtr.entrypoints=websecure" + - "traefik.http.routers.client-rtr.rule=Host(`${SERVERHOSTNAME:-batdetectai.kitware.com}`)" + - "traefik.enable=true" + - "traefik.http.services.client-svc.loadbalancer.server.port=80" + postgres: + image: postgis/postgis:latest + env_file: ./dev/.env.prod.docker-compose + environment: + - POSTGRES_DB=${DJANGO_DATABASE_NAME:-django} + - POSTGRES_PASSWORD=${DJANGO_MINIO_STORAGE_SECRET_KEY:-postgres} + networks: + - django-nginx + ports: + - ${DOCKER_POSTGRES_PORT-5432}:5432 + volumes: + - postgres:/var/lib/postgresql/data + + rabbitmq: + env_file: ./dev/.env.prod.docker-compose + image: rabbitmq:management + networks: + - django-nginx + ports: + - ${DOCKER_RABBITMQ_PORT-5672}:5672 + - ${DOCKER_RABBITMQ_CONSOLE_PORT-15672}:15672 + volumes: + - rabbitmq:/var/lib/rabbitmq/mnesia + + minio: + image: minio/minio:latest + # When run with a TTY, minio prints credentials on startup + tty: true + command: ["server", "/data", "--console-address", ":${DOCKER_MINIO_CONSOLE_PORT-9001}"] + env_file: ./dev/.env.prod.docker-compose + environment: + - MINIO_ROOT_USER=${DJANGO_MINIO_STORAGE_ACCESS_KEY:-minioAccessKey} + - MINIO_ROOT_PASSWORD=${DJANGO_DATABASE_PASSWORD:-minioSecretKey} + networks: + - django-nginx + ports: + - ${DOCKER_MINIO_PORT-9000}:9000 + - ${DOCKER_MINIO_CONSOLE_PORT-9001}:9001 + volumes: + - minio:/data + + flower: + env_file: ./dev/.env.prod.docker-compose + build: + context: . + dockerfile: ./dev/django.Dockerfile + command: [ + "celery", + "--app", "bats_ai.celery", + "flower" + ] + tty: false + volumes: + - .:/opt/django-project + networks: + - django-nginx + ports: + - ${DOCKER_FLOWER_PORT-5555}:5555 + depends_on: + - postgres + - rabbitmq + - minio + - celery + +volumes: + postgres: + sourcedb: + minio: + rabbitmq: + +networks: + django-nginx: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 52df5c6..bab8bd6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,8 +3,8 @@ services: postgres: image: postgis/postgis:latest environment: - POSTGRES_DB: django - POSTGRES_PASSWORD: postgres + - POSTGRES_DB=${DJANGO_DATABASE_NAME:-django} + - POSTGRES_PASSWORD=${DJANGO_MINIO_STORAGE_SECRET_KEY:-postgres} ports: - ${DOCKER_POSTGRES_PORT-5432}:5432 volumes: @@ -24,8 +24,8 @@ services: tty: true command: ["server", "/data", "--console-address", ":${DOCKER_MINIO_CONSOLE_PORT-9001}"] environment: - MINIO_ROOT_USER: minioAccessKey - MINIO_ROOT_PASSWORD: minioSecretKey + - MINIO_ROOT_USER=${DJANGO_MINIO_STORAGE_ACCESS_KEY:-minioAccessKey} + - MINIO_ROOT_PASSWORD=${DJANGO_DATABASE_PASSWORD:-minioSecretKey} ports: - ${DOCKER_MINIO_PORT-9000}:9000 - ${DOCKER_MINIO_CONSOLE_PORT-9001}:9001 diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..274fa8a --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,75 @@ +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + client_max_body_size 100m; + + server { + listen 80; + + + # Serve Vue.js static files + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } + # Proxy API requests to Django + location /api { + proxy_pass http://django:8000/api; + proxy_set_header Host $host; + 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 $scheme; + proxy_redirect off; + } + location /oauth { + proxy_pass http://django:8000/oauth; + proxy_set_header Host $host; + 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 $scheme; + proxy_redirect off; + } + location /static { + proxy_pass http://django:8000/static; + proxy_set_header Host $host; + 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 $scheme; + proxy_redirect off; + } + + location /admin { + proxy_pass http://django:8000/admin; + proxy_set_header Host $host; + 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 $scheme; + proxy_redirect off; + } + location /accounts { + proxy_pass http://django:8000/accounts; + proxy_set_header Host $host; + 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 $scheme; + proxy_redirect off; + } + + location /django-storage { + proxy_pass http://minio:9000/django-storage; + proxy_set_header Host $host; + 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 $scheme; + proxy_redirect off; + } + + } +} diff --git a/setup.py b/setup.py index a8deb60..6589de7 100644 --- a/setup.py +++ b/setup.py @@ -37,6 +37,7 @@ include_package_data=True, install_requires=[ 'celery', + 'gunicorn', 'django-ninja', 'django>=4.1, <4.2', 'django-allauth', diff --git a/traefik/dynamic.yml b/traefik/dynamic.yml new file mode 100644 index 0000000..ca6764f --- /dev/null +++ b/traefik/dynamic.yml @@ -0,0 +1,5 @@ +tls: + options: + default: + minVersion: VersionTLS12 + sniStrict: true