diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..e78b09c --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,102 @@ +name: Build and Deploy + +on: + workflow_dispatch: + push: + branches: + - 'main' + - 'staging' + tags: + - 'v*' + pull_request: + branches: + - 'main' + - 'staging' + +jobs: + build: + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $GITHUB_ACTOR --password-stdin + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/Dockerfile + target: 'prod' + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + deploy: + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + needs: build + strategy: + matrix: + include: + - environment: staging + branch: staging + target_path: "~/staging.mapdb.cncnet.org" + compose_file: "docker-compose.prod.yml" + nginx_conf: "docker/nginx.prod.conf" +# disabled for now +# - environment: production +# branch: main +# target_path: "~/prod2.mapdb.cncnet.org" +# compose_file: "docker-compose.prod.yml" +# nginx_conf: "docker/nginx.prod.conf" + + steps: + - name: "Exit if not matching branch" + if: github.ref != format('refs/heads/{0}', matrix.branch) + run: echo "Not target branch for this deployment. Skipping..." && exit 0 + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Copy docker-compose and nginx config over ssh + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + source: "${{ matrix.compose_file }},${{ matrix.nginx_conf }}" + target: "${{ matrix.target_path }}" + + - name: SSH into server and deploy + uses: appleboy/ssh-action@v1.2.1 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + cd ${{ matrix.target_path }} + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + docker compose -f ${{ matrix.compose_file }} pull + docker compose -f ${{ matrix.compose_file }} down + docker compose -f ${{ matrix.compose_file }} up -d \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 68222e3..c8d5a12 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -10,35 +10,38 @@ services: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} env_file: - .env - ports: - - "127.0.0.1:${POSTGRES_PORT}:${POSTGRES_PORT}" command: -p ${POSTGRES_PORT} + networks: + - mapdb-network django: container_name: mapdb-django - build: - context: . - dockerfile: docker/Dockerfile - target: prod + image: ghcr.io/cncnet/cncnet-map-api:${APP_TAG} volumes: - ${HOST_MEDIA_ROOT}:/data/cncnet_silo # django will save user-uploaded files here. MEDIA_ROOT - ${HOST_STATIC_ROOT}:/data/cncnet_static # django will gather static files here. STATIC_ROOT - ports: - - "8000:8000" env_file: - .env depends_on: - db + networks: + - mapdb-network nginx-server: # nginx proxies requests to django via gunicorn. container_name: mapdb-nginx image: nginx:latest volumes: - - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./docker/nginx.prod.conf:/etc/nginx/nginx.conf:ro - ${HOST_STATIC_ROOT}:/usr/share/nginx/html/static # website static assets. - ${HOST_MEDIA_ROOT}:/usr/share/nginx/html/silo # website user-uploaded files. ports: - - "80:80" + - "${EXPOSED_PORT}:80" depends_on: - django + networks: + - mapdb-network + +networks: + mapdb-network: + driver: bridge \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index 015bb6f..143f845 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,35 +1,42 @@ FROM python:3.12-bookworm AS base +# Base environment configuration ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 + +# Configurable user setup +ENV USER=cncnet +ENV UID=1000 + WORKDIR /cncnet-map-api -# This is just here to crash the build if you don't make a .env file. -COPY .env /cncnet-map-api/ +# Install system dependencies +RUN apt-get update && apt-get install -y liblzo2-dev libmagic1 -# Copy files needed for build +# Create non-root user with configurable name and UID +RUN useradd -m -u ${UID} ${USER} + +# Copy necessary files for the build COPY requirements.txt /cncnet-map-api COPY requirements-dev.txt /cncnet-map-api COPY web_entry_point.sh /cncnet-map-api -# liblzo2 is a compression library used by westwood. -# libmagic1 is used for detecting file mime types by analyzing the file contents. -RUN apt-get update && apt-get install -y liblzo2-dev libmagic1 +# Set permissions and make script executable +RUN chmod +x /cncnet-map-api/web_entry_point.sh && \ + chown -R ${USER}:${USER} /cncnet-map-api + RUN pip install --upgrade pip -RUN chmod +x /cncnet-map-api/web_entry_point.sh FROM base AS dev -# The cflags are needed to build the lzo library on Apple silicon. -RUN CFLAGS=-I$(brew --prefix)/include LDFLAGS=-L$(brew --prefix)/lib pip install -r ./requirements-dev.txt -ENTRYPOINT "/cncnet-map-api/web_entry_point.sh" +RUN pip install -r ./requirements-dev.txt +USER ${USER} +ENTRYPOINT ["/cncnet-map-api/web_entry_point.sh"] FROM base AS prod -# The cflags are needed to build the lzo library on Apple silicon. -RUN CFLAGS=-I$(brew --prefix)/include LDFLAGS=-L$(brew --prefix)/lib pip install -r ./requirements.txt - COPY . /cncnet-map-api - -ENTRYPOINT "/cncnet-map-api/web_entry_point.sh" +RUN pip install -r ./requirements.txt +USER ${USER} +ENTRYPOINT ["/cncnet-map-api/web_entry_point.sh"] FROM base AS debugger # Just build, but don't run anything. Your debugger will run pytest, manage.py, etc for you. diff --git a/docker/nginx.prod.conf b/docker/nginx.prod.conf new file mode 100644 index 0000000..40340e0 --- /dev/null +++ b/docker/nginx.prod.conf @@ -0,0 +1,34 @@ +user nginx; +worker_processes 4; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + server { + listen 80; + + # Serve static files: js, static images, etc. + location /static/ { + alias /usr/share/nginx/html/static/; # The nginx container's mounted volume. + expires 30d; + add_header Cache-Control public; + } + + # Serve user uploaded files + location /silo/ { + alias /usr/share/nginx/html/silo/; # The container's mounted volume. + } + + # Proxy requests to the Django app running in gunicorn + location / { + proxy_pass http://django:8000; # The Django app is exposed on the `django` container on port 8000 + 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; + } + } +} diff --git a/kirovy/settings/_base.py b/kirovy/settings/_base.py index 4c6ad7e..23b7895 100644 --- a/kirovy/settings/_base.py +++ b/kirovy/settings/_base.py @@ -33,7 +33,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = get_env_var("DEBUG", False, validation_callback=not_allowed_on_prod) -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = get_env_var("ALLOWED_HOSTS", "localhost,mapdb-nginx").split(",") MAX_UPLOADED_FILE_SIZE_MAP = file_utils.ByteSized(mega=25) diff --git a/requirements.txt b/requirements.txt index 4937bb4..a58e77e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,4 @@ gunicorn>=22.0.0,<23.0.0 django-filter==25.1 orjson>=3.10.15,<4.0 structlog>=25.1.0,<26.0.0 +drf-spectacular[sidecar] diff --git a/web_entry_point.sh b/web_entry_point.sh index 3019f9d..a363cc3 100755 --- a/web_entry_point.sh +++ b/web_entry_point.sh @@ -1,3 +1,4 @@ +#!/bin/sh # Don't run this file manually. It's the entry point for the dockerfile. python manage.py collectstatic --noinput python manage.py migrate