diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 57b779a4..d05e93a1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,13 +15,11 @@ on: required: true env: - IMAGE_REF: us-west1-docker.pkg.dev/${{ secrets.GCP_PROJECT_SLUG }}/firetower-docker/firetower:${{ github.sha }} + BACKEND_IMAGE_REF: us-west1-docker.pkg.dev/${{ secrets.GCP_PROJECT_SLUG }}/firetower-docker/firetower:${{ github.sha }} + STATIC_IMAGE_REF: us-west1-docker.pkg.dev/${{ secrets.GCP_PROJECT_SLUG }}/firetower-docker/nginx:${{ github.sha }} jobs: - ci: - uses: ./.github/workflows/ci.yml - deploy: - needs: [ci] + build: runs-on: ubuntu-latest steps: - name: Checkout code @@ -35,13 +33,34 @@ jobs: run: gcloud auth configure-docker us-west1-docker.pkg.dev - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Build and push + - name: Build and push (backend) + uses: docker/build-push-action@v6 + with: + push: true + tags: ${{ env.BACKEND_IMAGE_REF }} + file: docker/backend.Dockerfile + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Build and push (static assets) uses: docker/build-push-action@v6 with: push: true - tags: ${{ env.IMAGE_REF }} + tags: ${{ env.STATIC_IMAGE_REF }} + file: docker/static.Dockerfile cache-from: type=gha cache-to: type=gha,mode=max + + deploy: + needs: [build] + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + - name: Authenticate with GCP + uses: 'google-github-actions/auth@v3' + with: + project_id: ${{ secrets.GCP_PROJECT_SLUG }} + workload_identity_provider: projects/${{ secrets.GCP_PROJECT_NUM }}/locations/global/workloadIdentityPools/github/providers/github-prvdr - name: deploy-test-db-migration if: ${{ inputs.environment == 'test' }} uses: "google-github-actions/deploy-cloudrun@v3" @@ -49,25 +68,29 @@ jobs: job: firetower-test-db-migration project_id: ${{ secrets.GCP_PROJECT_SLUG }} region: us-west1 - image: ${{ env.IMAGE_REF }} + image: ${{ env.BACKEND_IMAGE_REF }} wait: true - - name: deploy-prod-db-migration - if: ${{ (github.ref == 'refs/heads/main' && !inputs.environment ) || inputs.environment == 'prod' }} + - name: deploy-test + if: ${{ inputs.environment == 'test' }} uses: "google-github-actions/deploy-cloudrun@v3" with: - job: firetower-prod-db-migration + service: firetower-test project_id: ${{ secrets.GCP_PROJECT_SLUG }} region: us-west1 - image: ${{ env.IMAGE_REF }} - wait: true - - name: deploy-test - if: ${{ inputs.environment == 'test' }} + # NOTE: we use flags here because the action does not natively support updating multiple containers. + flags: > + --container nginx --image "${{ env.STATIC_IMAGE_REF }}" --port 80 + --container firetower-backend --image "${{ env.BACKEND_IMAGE_REF }}" + + - name: deploy-prod-db-migration + if: ${{ (github.ref == 'refs/heads/main' && !inputs.environment ) || inputs.environment == 'prod' }} uses: "google-github-actions/deploy-cloudrun@v3" with: - service: firetower-test + job: firetower-prod-db-migration project_id: ${{ secrets.GCP_PROJECT_SLUG }} region: us-west1 - image: ${{ env.IMAGE_REF }} + image: ${{ env.BACKEND_IMAGE_REF }} + wait: true - name: deploy-prod if: ${{ (github.ref == 'refs/heads/main' && !inputs.environment ) || inputs.environment == 'prod' }} uses: "google-github-actions/deploy-cloudrun@v3" @@ -75,4 +98,7 @@ jobs: service: firetower project_id: ${{ secrets.GCP_PROJECT_SLUG }} region: us-west1 - image: ${{ env.IMAGE_REF }} + # NOTE: we use flags here because the action does not natively support updating multiple containers. + flags: > + --container nginx --image "${{ env.STATIC_IMAGE_REF }}" --port 80 + --container firetower-backend --image "${{ env.BACKEND_IMAGE_REF }}" diff --git a/docker/backend.Dockerfile b/docker/backend.Dockerfile new file mode 100644 index 00000000..61ea8415 --- /dev/null +++ b/docker/backend.Dockerfile @@ -0,0 +1,34 @@ +FROM python:3.12.11-alpine3.22 AS build_backend +COPY --from=ghcr.io/astral-sh/uv:0.8.18 /uv /uvx /bin/ + +WORKDIR /app + +# Install dependencies +COPY uv.lock . +COPY pyproject.toml . +RUN uv sync --no-dev --frozen --compile-bytecode --no-install-project + +COPY src/firetower . + +# Install the backend itself +COPY pyproject.toml . +COPY manage.py . +COPY README.md . +COPY src/ src/ +RUN uv sync --group prod --no-dev --frozen --compile-bytecode --no-editable + +FROM python:3.12.11-alpine3.22 + +RUN adduser app -h /app -u 1100 -D && chown -R 1100 /app + +# Copy the environment, but not the source code +COPY --from=build_backend --chown=1100 /app/.venv /app/.venv + +WORKDIR /app +USER 1100 + +ENV PORT=8080 +EXPOSE $PORT + +ENV DJANGO_ENV="prod" +ENTRYPOINT [ "sh", "-c", "/app/.venv/bin/granian --interface wsgi --host 0.0.0.0 --port $PORT firetower.wsgi:application"] diff --git a/Dockerfile b/docker/static.Dockerfile similarity index 62% rename from Dockerfile rename to docker/static.Dockerfile index a4a65194..76f0c725 100644 --- a/Dockerfile +++ b/docker/static.Dockerfile @@ -39,21 +39,13 @@ ENV VITE_API_URL="/api" RUN bun run build -FROM python:3.12.11-alpine3.22 +FROM nginx:1.29.3-alpine3.22 -RUN adduser app -h /app -u 1100 -D && chown -R 1100 /app +RUN chown 101:101 -R /var/cache/nginx/ /var/log/nginx/ +RUN touch /run/nginx.pid && chown 101:101 /run/nginx.pid +USER 101 -# Copy the environment, but not the source code -COPY --from=build_backend --chown=1100 /app/.venv /app/.venv -COPY --from=build_frontend --chown=1100 /app/dist /app/static -COPY --from=build_backend --chown=1100 /app/static/backend /app/static/backend +COPY --from=build_frontend --chown=101 /app/dist /app/static +COPY --from=build_backend --chown=101 /app/static/backend /app/static/backend -WORKDIR /app -USER 1100 - -ENV PORT=8080 -EXPOSE $PORT - -ENV GRANIAN_STATIC_PATH_MOUNT="static/" -ENV DJANGO_ENV="prod" -ENTRYPOINT [ "sh", "-c", "/app/.venv/bin/granian --interface wsgi --host 0.0.0.0 --port $PORT firetower.wsgi:application"] +COPY ./nginx.conf /etc/nginx/nginx.conf diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 00000000..75fcd060 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,60 @@ +# user nginx; +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +pid /run/nginx.pid; + + +events { + worker_connections 1024; +} + + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + + keepalive_timeout 65; + + #gzip on; + + #include /etc/nginx/conf.d/*.conf; + + server { + listen 80; + server_name localhost .firetower.getsentry.net; + + access_log /var/log/nginx/host.access.log main; + + location / { + root /app/static/; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://127.0.0.1:8080; + } + location /admin { + proxy_pass http://127.0.0.1:8080; + } + + #error_page 404 /404.html; + + # redirect server error pages to the static page /50x.html + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + } +} diff --git a/pyproject.toml b/pyproject.toml index ba5eda8d..6d87a23e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "cryptography>=46.0.3", "django>=5.2.5", "django-cors-headers>=4.9.0", + "django-kubernetes>=1.1.0", "djangorestframework>=3.15.2", "google-auth>=2.37.0", "google-cloud-logging>=3.11.4", diff --git a/src/firetower/settings.py b/src/firetower/settings.py index 8539e946..94dfa003 100644 --- a/src/firetower/settings.py +++ b/src/firetower/settings.py @@ -87,12 +87,14 @@ def dev_default(key: str, default: str = "") -> str: "django.contrib.staticfiles", "rest_framework", "corsheaders", + "djk8s", "firetower.auth", "firetower.incidents", "firetower.integrations", ] MIDDLEWARE = [ + "djk8s.middleware.ProbeMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "corsheaders.middleware.CorsMiddleware", @@ -172,7 +174,7 @@ def dev_default(key: str, default: str = "") -> str: # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.2/howto/static-files/ -STATIC_URL = "static/backend/" +STATIC_URL = "backend/" STATIC_ROOT = "static/backend/" # Default primary key field type @@ -223,3 +225,8 @@ def dev_default(key: str, default: str = "") -> str: client = google.cloud.logging.Client() client.setup_logging() + +# django-k8s proreadiness probes +DJK8S_READINESS_PROBES = [ + "djk8s.probes.DatabaseProbe", +] diff --git a/uv.lock b/uv.lock index c5e0cc8d..999292d4 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14'", @@ -279,6 +279,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" }, ] +[[package]] +name = "django-kubernetes" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/6c/3a5bf9a360e14612dfa581d055cc27e78e35954b0edf17cb02c22f3ca154/django_kubernetes-1.1.0.tar.gz", hash = "sha256:6ca3e3b85c8cd7c11057156398d74398c585a7d782ad585a65463e749d2d86ed", size = 24804, upload-time = "2025-06-18T16:47:01.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d0/14e763857e44fc8d846f786527c777d6b85d649e5d990e29cf2b468a91d9/django_kubernetes-1.1.0-py3-none-any.whl", hash = "sha256:068cca992a6b3f8030774618ce23ee22cf68b565a35faa46bb6ccd97f034c029", size = 13731, upload-time = "2025-06-18T16:46:59.95Z" }, +] + [[package]] name = "django-stubs" version = "5.2.7" @@ -336,6 +348,7 @@ dependencies = [ { name = "cryptography" }, { name = "django" }, { name = "django-cors-headers" }, + { name = "django-kubernetes" }, { name = "djangorestframework" }, { name = "google-auth" }, { name = "google-cloud-logging" }, @@ -366,6 +379,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=46.0.3" }, { name = "django", specifier = ">=5.2.5" }, { name = "django-cors-headers", specifier = ">=4.9.0" }, + { name = "django-kubernetes", specifier = ">=1.1.0" }, { name = "djangorestframework", specifier = ">=3.15.2" }, { name = "google-auth", specifier = ">=2.37.0" }, { name = "google-cloud-logging", specifier = ">=3.11.4" },