diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..5ace4600 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/clean-up.yml b/.github/workflows/clean-up.yml new file mode 100644 index 00000000..d449c6b5 --- /dev/null +++ b/.github/workflows/clean-up.yml @@ -0,0 +1,42 @@ +name: Weekly Image Cleanup + +on: + schedule: + - cron: "0 0 * * 0" # run every sunday at midnight + workflow_dispatch: + +# don't cancel in-progress runs, since this is a weekly cleanup job +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + clean-registry: + runs-on: ubuntu-latest + permissions: + packages: write + + steps: + - name: Clean up Plancake Frontend images + uses: snok/container-retention-policy@d3bdcf5ce9b05f685154e4a16c39233b245e3d53 # v3.1.0 + with: + account: plan-cake + token: ${{ secrets.GITHUB_TOKEN }} + image-names: plancake-frontend + cut-off: 1w + timestamp-to-use: updated_at + keep-n-most-recent: 3 + skip-tags: v*.*.* + dry-run: false + + - name: Clean up Plancake Backend images + uses: snok/container-retention-policy@d3bdcf5ce9b05f685154e4a16c39233b245e3d53 # v3.1.0 + with: + account: plan-cake + token: ${{ secrets.GITHUB_TOKEN }} + image-names: plancake-backend + cut-off: 1w + timestamp-to-use: updated_at + keep-n-most-recent: 3 + skip-tags: v*.*.* + dry-run: false diff --git a/.github/workflows/merge-protect.yml b/.github/workflows/merge-protect.yml index 0e26f640..7b7119c4 100644 --- a/.github/workflows/merge-protect.yml +++ b/.github/workflows/merge-protect.yml @@ -8,14 +8,34 @@ on: # Having edited here updates the check when changing the base branch types: [synchronize, opened, reopened, edited] +env: + HEAD_REF: ${{ github.head_ref }} + jobs: check_branch: runs-on: ubuntu-latest steps: + - name: Check out the repo + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Check branch if: github.base_ref == 'main' run: | - if [[ ! "${{ github.head_ref }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if [[ ! "$HEAD_REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Error: You can only merge to main from version branches (e.g. v1.2.3)." exit 1 fi + + - name: Check package version + if: github.base_ref == 'main' + run: | + # Extract the version from package.json + PACKAGE_VERSION=$(jq -r '.version' frontend/package.json) + + # Extract the version from the branch name + BRANCH_VERSION="${HEAD_REF#v}" + + if [[ "$PACKAGE_VERSION" != "$BRANCH_VERSION" ]]; then + echo "Error: The version in package.json ($PACKAGE_VERSION) does not match the version in the branch name ($BRANCH_VERSION)." + exit 1 + fi diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml new file mode 100644 index 00000000..83a42179 --- /dev/null +++ b/.github/workflows/publish-images.yml @@ -0,0 +1,84 @@ +name: Build and Publish Docker Images + +on: + push: + branches: ["main", "v*.*.*"] + +# Cancel any in-progress builds if a new commit is pushed to the same branch +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Check out the repo + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # --- BACKEND --- + - name: Extract metadata for Backend + id: meta-backend + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + with: + images: ghcr.io/plan-cake/plancake-backend + tags: | + type=ref,event=branch + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Backend + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5.4.0 + with: + context: ./backend + file: ./backend/Dockerfile.dev + push: true + tags: ${{ steps.meta-backend.outputs.tags }} + labels: ${{ steps.meta-backend.outputs.labels }} + platforms: linux/amd64,linux/arm64 + cache-from: | + type=gha,scope=backend-${{ github.ref_name }} + type=gha,scope=backend-main + cache-to: type=gha,mode=max,scope=backend-${{ github.ref_name }} + + # --- FRONTEND --- + - name: Extract metadata for Frontend + id: meta-frontend + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + with: + images: ghcr.io/plan-cake/plancake-frontend + tags: | + type=ref,event=branch + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Frontend + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5.4.0 + with: + context: ./frontend + file: ./frontend/Dockerfile.dev + push: true + tags: ${{ steps.meta-frontend.outputs.tags }} + labels: ${{ steps.meta-frontend.outputs.labels }} + platforms: linux/amd64,linux/arm64 + cache-from: | + type=gha,scope=frontend-${{ github.ref_name }} + type=gha,scope=frontend-main + cache-to: type=gha,mode=max,scope=frontend-${{ github.ref_name }} diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..e09e049e --- /dev/null +++ b/Makefile @@ -0,0 +1,95 @@ + +.PHONY: help up build down restart logs-api logs-web shell-api shell-web migrate makemigrations url + +# Determine the Docker image tag based on the current Git branch. If the branch is not +# "main", use the version from the frontend package.json to identify the Docker image. +# Otherwise, default to latest. +CURRENT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD) +IMAGE_TAG := latest +ifneq ($(CURRENT_BRANCH),main) + # Get the version from the frontend package.json for identifying the Docker image. + PKG_VERSION := $(shell node -p "require('./frontend/package.json').version") + IMAGE_TAG := v$(PKG_VERSION) +endif + +# --- COMMANDS --- + +help: + @echo "Usage: make " + @echo "" + @echo "Targets:" + @echo " up Starts the environment in the background and prints out the URLs for local and network access." + @echo " build Creates a full local rebuild from scratch (bypasses the registry) and prints out the URLs for local and network access." + @echo " down Stops the environment completely and removes the connected containers and networks." + @echo " restart Restarts the containers without rebuilding them. Use this for things like env changes." + @echo " logs-api Stream the Django backend logs." + @echo " logs-web Stream the Next.js frontend logs." + @echo " shell-api Open a terminal inside the backend container." + @echo " shell-web Open a terminal inside the frontend container." + @echo " migrate Run Django migrations inside the running container." + @echo " makemigrations Generate new Django migrations inside the running container." + +# Pulls the image from the registry and starts the containers in the background. +up: + @IMAGE_TAG=$(IMAGE_TAG) docker compose pull + @IMAGE_TAG=$(IMAGE_TAG) docker compose up -d && \ + echo "" && \ + echo "Using image tag $(IMAGE_TAG)" && \ + echo "" && \ + echo "Plancake Industries" && \ + echo " - Local: http://localhost:3000" && \ + echo " - Network: $$(node scripts/print-ip.js)" && \ + echo "" + +# Creates a full local rebuild from scratch (bypasses the registry) +build: + @IMAGE_TAG=$(IMAGE_TAG) docker compose up -d --build && \ + echo "" && \ + echo "Plancake Industries" && \ + echo " - Local: http://localhost:3000" && \ + echo " - Network: $$(node scripts/print-ip.js)" && \ + echo "" + +# Stops the environment completely and removes the connected containers and networks. +down: + docker compose down + +# Restarts the containers without rebuilding them. Use this for things like env changes. +restart: + docker compose restart + +# --- LOGS --- + +# Stream the Django backend logs +logs-api: + docker compose logs -f backend + +# Stream the Next.js frontend logs +logs-web: + docker compose logs -f frontend + +# --- SHELLS & COMMANDS --- + +# Print out the URLs for the local and network access. +url: + @echo "" && \ + echo "Plancake Industries" && \ + echo " - Local: http://localhost:3000" && \ + echo " - Network: $$(node scripts/print-ip.js)" && \ + echo "" + +# Open a terminal inside the frontend container +shell-web: + docker compose exec -it frontend /bin/sh + +# Open a terminal inside the backend container +shell-api: + docker compose exec -it backend /bin/bash + +# Run Django migrations inside the running container +migrate: + docker compose exec backend python manage.py migrate + +# Generate new Django migrations inside the running container +makemigrations: + docker compose exec backend python manage.py makemigrations api diff --git a/README.md b/README.md index 8d454565..2e8bd403 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,90 @@ A scheduling website that solves the logistics problem of figuring out when everyone is available to meet. -This is a monorepo holding both the frontend and backend code for the website. Each folder has its own README for setup. +## Tech Stack + +| Layer | Technology | +| :--------------- | :----------------------------------- | +| Frontend | Next.js (React, TypeScript) | +| Backend | Django (ASGI, served via Uvicorn) | +| Database | PostgreSQL | +| Cache / Pub-Sub | Redis (live updates + Celery broker) | +| Background Tasks | Celery (worker + beat) | + +## Project Structure + +This is a monorepo containing both the frontend client and the backend API service. + +```bash +plancake/ +├── backend/ # Django API server +└── frontend/ # Next.js application +``` + +## Getting Started + +This project can be run either natively or via Docker. Docker is the fastest way to get a consistent environment running locally; native setup is useful if you need finer-grained control over each service (e.g. running Celery, debugging with your IDE's native tooling). + +### Prerequisites + +- [Docker](https://www.docker.com/) and Docker Compose (for the Docker setup) +- Node.js and Python (for native setup — see the linked READMEs below for versions) +- A PostgreSQL database (local, or a hosted option like [Supabase](https://supabase.com)) + +### `.env` Files + +Copy the contents of the respective `.env.example` files in each folder into its own `.env`: + +- [`backend/.env.example`](backend/.env.example) → `backend/.env` +- [`frontend/.env.example`](frontend/.env.example) → `frontend/.env` + +The same `.env` setup is used both natively and in Docker. For more details about the database connection, see the [Backend Setup](backend/README.md) guide. + +### Native Setup + +For native setup, follow the setup instructions in these READMEs: + +- [Backend Setup](backend/README.md) +- [Frontend Setup](frontend/README.md) + +### Docker Setup + +The project uses Docker Compose alongside a set of `make` commands that wrap common workflows. The Compose stack includes: + +| Service | Description | +| :--------- | :--------------------------------------------------------- | +| `frontend` | Next.js dev server, hot-reloaded via a bind mount | +| `backend` | Django API server (Uvicorn), hot-reloaded via a bind mount | +| `redis` | Backs live updates (pub/sub) and the Celery broker | + +> **Note:** PostgreSQL and Celery (worker/beat) are not included in the Compose stack. The backend connects to whatever database you configure in `backend/.env` (local or hosted), and scheduled background tasks (e.g. expired session cleanup) won't run unless you start Celery separately — see the [Backend Setup](backend/README.md) guide. + +For a deeper look at how the Docker setup works — architecture, images, CI/CD, and known gaps — see [`docs/docker.md`](docs/docker.md). + +#### Quick Start + +```bash +make up +``` + +This pulls the latest published images and starts the stack in the background. Once it's running: + +- Local: http://localhost:3000 +- Network: printed to the terminal, for testing on other devices on your network + +#### Make Commands + +The most commonly used commands: + +| Task | Command | Description | +| :----------- | :------------- | :---------------------------------------------------------------------------- | +| Start/Resume | `make up` | Pulls the latest images and starts the containers in the background | +| Full Rebuild | `make build` | Rebuilds images from source (bypasses the registry) and starts the containers | +| Stop | `make down` | Stops and removes containers and networks | +| Restart | `make restart` | Restarts containers without rebuilding (useful for `.env` changes) | + +See [`docs/docker.md`](docs/docker.md#makefile) for the full command list (logs, shells, migrations, and more) and run `make help` at any time to see it from the terminal. + +_(To exit log streams, press `Ctrl+C`. To exit shell sessions, type `exit` or press `Ctrl+D`.)_ + +Named volumes are used to persist data like `node_modules` and the Redis cache across restarts — see [`docs/docker.md`](docs/docker.md#named-volumes) for details on wiping them. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 00000000..e04d6f52 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,56 @@ +# Python Bytecode +__pycache__/ +*.py[cod] +*.pyc +*.pyo +*$py.class +*.so + +# Documentation +*.md +docs/ + +# Virtual Environments +venv/ +.venv/ +env/ +.env/ + +# Git and OS files +.git/ +.DS_Store +Thumbs.db + +# Environment variables (only commit template files) +.env +.env*.local +.env.development +.env.test +.env.production +.env.production.local + +# Docker configuration files (not needed inside build context) +Dockerfile* +.dockerignore +compose.yaml +compose.yml +docker-compose*.yaml +docker-compose*.yml + +# Logs and Databases +*.db +*.log +db.sqlite3 +db.sqlite3-journal + +# Testing and Coverage +.pytest_cache/ +.tox/ +coverage/ +htmlcov/ +.coverage +.coverage.* + +# Developer Tools +.vscode/ +.idea/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..74298237 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,24 @@ +# This Dockerfile is used to build the production and development image for the backend. + +FROM python:3.13-slim + +# Prevent Python from writing pyc files and buffering stdout +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Create a non-root user and switch to it +RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser +USER appuser + +EXPOSE 8000 + +CMD ["uvicorn", "api.asgi:application", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 00000000..c040a8d7 --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,20 @@ +# This Dockerfile is used to build the production and development image for the backend. + +FROM python:3.13-slim + +# Prevent Python from writing pyc files and buffering stdout +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "api.asgi:application", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/api/settings.py b/backend/api/settings.py index c15ebe4c..16ca1b3a 100644 --- a/backend/api/settings.py +++ b/backend/api/settings.py @@ -166,10 +166,10 @@ class ThrottleScopes: "schedule": crontab(hour=0, minute=0), # Every day at midnight }, } -CELERY_BROKER_URL = "redis://localhost:6379/0" +CELERY_BROKER_URL = env("CELERY_BROKER_URL", default="redis://localhost:6379/0") # Live updates -LIVE_UPDATES_URL = "redis://localhost:6379/1" +LIVE_UPDATES_URL = env("LIVE_UPDATES_URL", default="redis://localhost:6379/1") LIVE_UPDATES_HEARTBEAT_SECONDS = 1 MAX_LIVE_CONNECTIONS_EVENT = 25 MAX_LIVE_CONNECTIONS_GLOBAL = 500 diff --git a/backend/start.sh b/backend/start.sh new file mode 100644 index 00000000..f7db9cf8 --- /dev/null +++ b/backend/start.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -e + +echo "Checking for Python dependency updates..." +pip install -r requirements.txt + +echo "Applying database migrations..." +python manage.py migrate + +echo "Starting Uvicorn server..." +exec uvicorn api.asgi:application --host 0.0.0.0 --port 8000 --reload diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..704c6669 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +services: + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + ports: + - "127.0.0.1:6379:6379" + + backend: + image: ghcr.io/plan-cake/plancake-backend:${IMAGE_TAG:-latest} + build: + context: ./backend + dockerfile: Dockerfile.dev + command: ["sh", "./start.sh"] + volumes: + - ./backend:/app + ports: + - "8000:8000" + env_file: + - ./backend/.env + environment: + - CELERY_BROKER_URL=redis://redis:6379/0 + - LIVE_UPDATES_URL=redis://redis:6379/1 + depends_on: + - redis + + frontend: + image: ghcr.io/plan-cake/plancake-frontend:${IMAGE_TAG:-latest} + build: + context: ./frontend + dockerfile: Dockerfile.dev + ports: + - "3000:3000" + command: ["sh", "./start.sh"] + volumes: + - ./frontend:/app + - node_modules:/app/node_modules + - next_cache:/app/.next + env_file: + - ./frontend/.env + environment: + # The API URL for both the client and the Next.js server to communicate with the backend + - INTERNAL_API_URL=http://backend:8000 + # For Next.js to detect file changes in Docker + - WATCHPACK_POLLING=true + depends_on: + - backend + +volumes: + redis_data: + node_modules: + next_cache: diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 00000000..4472b564 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,115 @@ +# Docker System Overview + +This document explains how Plancake's Docker setup is put together: the services, the images, the `make` workflow, and the CI pipelines that build and publish them. + +For quick-start commands, read alongside the [root README](../README.md). + +## Architecture + +``` + ┌────────────┐ ┌────────────┐ + :3000 ───▶ │ frontend │──────▶ │ backend │ ───▶ :8000 + │ (Next.js) │ │ (Django, │ + └────────────┘ │ Uvicorn) │ + └─────┬──────┘ + │ + ┌─────▼──────┐ + │ redis │ + └────────────┘ +``` + +- **frontend** — Next.js dev server. Talks to the backend over the internal Docker network (`http://backend:8000`). +- **backend** — Django served via Uvicorn. Talks to Redis for live updates and the Celery broker, and to whatever PostgreSQL database is configured in `backend/.env`. +- **redis** — backs two things: the pub/sub channel used for live updates on the results page, and the Celery broker (`CELERY_BROKER_URL`). + +**Not included in the Compose stack:** PostgreSQL and Celery (worker/beat). + +- The database is expected to already exist — locally installed, or hosted (e.g. Supabase) — and is configured entirely through `backend/.env`. Containerizing it wasn't necessary since most contributors are already pointed at a hosted dev database. +- Celery only drives the `daily_duties` scheduled task (session/token/reset-code cleanup at midnight). It's not required for day-to-day feature work, so it's left out to keep the stack lighter. If you need it, run it natively alongside the containers (`backend/README.md` has the commands) — its broker URL already matches redis's Docker port (`redis://localhost:6379/0`), so it'll happily connect to the containerized Redis. + +## Images + +### Backend (`backend/Dockerfile` and `backend/Dockerfile.dev`) + +Single-stage image based on `python:3.13-slim`. Installs `requirements.txt`, copies the app, and drops to a non-root user before running Uvicorn. The production and dev images are very similar, with the only difference being the use of the non-root user. + +In Compose, the `Dockerfile.dev` is used for local dev and `docker-compose.yml` overrides its `command` to run `backend/start.sh` instead (which re-installs dependencies and runs with `--reload`), and bind-mounts the local `./backend` directory over `/app` so code changes are picked up live. + +### Frontend (`frontend/Dockerfile` and `frontend/Dockerfile.dev`) + +Two separate images, matching the prod/dev split: + +- **`Dockerfile`** — multi-stage production build (`dependencies` → `builder` → `runner`). Uses Next.js's standalone output to keep the final image small, and runs as the built-in non-root `node` user. +- **`Dockerfile.dev`** — single-stage, installs deps and runs `npm run dev` directly. This is what Compose builds locally, and also what's built and published by CI (see below). + +### `.dockerignore` + +Both `backend/` and `frontend/` have a `.dockerignore` to keep build contexts small — excluding things like `node_modules`, `.next`, virtualenvs, `__pycache__`, logs, and env files. Worth checking when adding new local-only directories so they don't bloat build context/image size. + +## `docker-compose.yml` + +- Both `backend` and `frontend` declare an `image:` (pointed at the registry) **and** a `build:` context. This means: + - `docker compose pull` / `make up` pulls the pre-built image for the given `IMAGE_TAG`. + - `docker compose up --build` / `make build` builds locally from source instead, ignoring the registry. +- `env_file` pulls in `backend/.env` and `frontend/.env` — the same files used for native setup. +- A few environment variables are set directly in the compose file rather than `.env`, because they depend on the container network rather than the developer's machine (e.g. `INTERNAL_API_URL=http://backend:8000`, `CELERY_BROKER_URL`, `LIVE_UPDATES_URL`). + +### Named Volumes + +Three named volumes are declared: `redis_data`, `node_modules`, and `next_cache`. They persist state across `make down`/`make up` cycles so you're not reinstalling `node_modules` or losing Redis data every restart, and so dangling anonymous volumes don't build up on your machine every time `make down` runs. + +If you ever need to wipe all data and start fresh, remove the volumes with: + +```bash +docker compose down -v +``` + +## `Makefile` + +The `Makefile` wraps the common Compose commands so contributors don't need to remember Compose flags or env vars. Run `make help` to see the full list. A few worth calling out: + +- `make up` vs `make build` — `up` pulls published images (fast, no local build); `build` builds from your working tree (needed if you've changed a Dockerfile or want to test uncommitted backend/frontend changes without publishing). +- `IMAGE_TAG` — determines which image tag is pulled/built. On `main`, it's `latest`. On any other branch, it's derived from `frontend/package.json`'s version as `v`. **This means `package.json`'s version must be kept up to date with whichever release image you want to pull** — bump it when starting work toward a new version, not just when publishing. +- `make shell-api` / `make shell-web` — open a shell in the running container for one-off debugging (`python manage.py shell`, inspecting `node_modules`, etc.) without needing to rebuild. + +This is the full list current supported commands: + +| Task | Command | Description | +| :---------------- | :-------------------- | :---------------------------------------------------------------------------- | +| Start/Resume | `make up` | Pulls the latest images and starts the containers in the background | +| Full Rebuild | `make build` | Rebuilds images from source (bypasses the registry) and starts the containers | +| Stop | `make down` | Stops and removes containers and networks | +| Restart | `make restart` | Restarts containers without rebuilding (useful for `.env` changes) | +| Backend Logs | `make logs-api` | Streams the backend Django logs | +| Frontend Logs | `make logs-web` | Streams the frontend Next.js logs | +| URLs | `make url` | Displays local and network URLs | +| API Shell | `make shell-api` | Opens a shell in the backend container | +| Frontend Shell | `make shell-web` | Opens a shell in the frontend container | +| Run Migrations | `make migrate` | Runs Django migrations inside the running container | +| Create Migrations | `make makemigrations` | Generates new Django migrations inside the running container | + +## CI/CD Workflows + +### `publish-images.yml` + +Runs on every push to `main` or a version branch (`v*.*.*`). Builds and pushes both images to GHCR using Buildx, multi-platform (`linux/amd64,linux/arm64`) via QEMU, with GitHub Actions cache (`cache-from`/`cache-to`) to speed up repeat builds. + +- Backend is built from `backend/Dockerfile` (the production image). +- Frontend is built from `frontend/Dockerfile.dev` — **not** the production `Dockerfile`. This was to get a working image published quickly during development; before this goes out as the "real" published image, it should probably be switched to build from the production Dockerfile instead. +- Tags: `type=ref,event=branch` (branch name) plus `latest` on the default branch, via `docker/metadata-action`. + +### `clean-up.yml` + +This cleaning job runs weekly every Sunday night and also can be manually enabled. It uses `snok/container-retention-policy` to delete old images from GHCR, keeping the 3 most recent per image and anything newer than a week. + +### `merge-protect.yml` + +Not Docker-specific, but lives with these workflows: gates PRs into `main` so they can only come from a version branch (`vX.Y.Z`) whose name matches `frontend/package.json`'s version. Keeps `main` (and therefore the `latest` tag published by `publish-images.yml`) in sync with an actual release version. + +## Known Gaps / Follow-ups + +- No Celery worker/beat service in Compose — daily cleanup task won't run unless started natively. +- No PostgreSQL service in Compose — a database must already exist and be reachable from `backend/.env`. +- Frontend CI publishes the dev image (`Dockerfile.dev`), not the production build. + +These aren't blockers for local development, but should be resolved before treating the published images as production-ready. diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..d8166316 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,130 @@ +# Dependencies (installed inside Docker, never copied) +node_modules/ +.pnpm-store/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Next.js build outputs (always generated during `next build`) +.next/ +out/ +dist/ +build/ +.vercel/ + +# Tests and testing output (not needed in production images) +coverage/ +.nyc_output/ +__tests__/ +__mocks__/ +jest/ +cypress/ +cypress/screenshots/ +cypress/videos/ +playwright-report/ +test-results/ +.vitest/ +vitest.config.* +jest.config.* +cypress.config.* +playwright.config.* +*.test.* +*.spec.* + +# Local development and editor files +.git/ +.gitignore +.gitattributes +.vscode/ +.idea/ +*.swp +*.swo +*~ +*.log + +# Environment variables (only commit template files) +.env +.env*.local +.env.development +.env.test +.env.production +.env.production.local + +# Docker configuration files (not needed inside build context) +Dockerfile* +.dockerignore +compose.yaml +compose.yml +docker-compose*.yaml +docker-compose*.yml + +# Documentation +*.md +docs/ + +# CI/CD configuration files +.github/ +.gitlab-ci.yml +.travis.yml +.circleci/ +Jenkinsfile + +# Cache directories and temporary data +.cache/ +.parcel-cache/ +.eslintcache +.stylelintcache +.swc/ +.turbo/ +.tmp/ +.temp/ + +# TypeScript build metadata +*.tsbuildinfo + +# Sensitive or unnecessary configuration files +*.pem +.editorconfig +.prettierrc* +prettier.config.* +.eslintrc* +eslint.config.* +.stylelintrc* +stylelint.config.* +.babelrc* +*.iml +*.ipr +*.iws + +# OS-specific junk +.DS_Store +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +Desktop.ini + +# AI/ML tool metadata and configs +.cursor/ +.cursorrules +.copilot/ +.copilotignore +.github/copilot/ +.gemini/ +.anthropic/ +.kiro +.claude +AGENTS.md +.agents/ + +# AI-generated temp files +*.aider* +*.copilot* +*.chatgpt* +*.claude* +*.gemini* +*.openai* +*.anthropic* \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..5d109cda --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,50 @@ +# This Dockerfile is used to build the production image for the frontend. + +# The node version is specified as a build argument for ease of updating to the most +# stable version of Node.js in the future. +ARG NODE_VERSION=22-alpine + +## INSTALL DEPENDENCIES +FROM node:${NODE_VERSION} AS dependencies + +WORKDIR /app + +# Install dependencies +COPY package.json package-lock.json* ./ +RUN npm ci + +## BUILD IMAGE +FROM node:${NODE_VERSION} AS builder + +WORKDIR /app + +COPY --from=dependencies /app/node_modules ./node_modules + +COPY . . + +RUN npm run build + +## RUN APPLICATION +FROM node:${NODE_VERSION} AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +# Copy production assets +COPY --from=builder --chown=node:node /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown node:node .next + +# Automatically leverage output traces to reduce image size +COPY --from=builder --chown=node:node /app/.next/standalone ./ +COPY --from=builder --chown=node:node /app/.next/static ./.next/static + +# Use the non-root "node" user +USER node + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev new file mode 100644 index 00000000..fea9d4be --- /dev/null +++ b/frontend/Dockerfile.dev @@ -0,0 +1,14 @@ +# This Dockerfile is used to build the development image for the frontend. + +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "run", "dev"] diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 6d6d98fa..e8dccbde 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -7,7 +7,7 @@ const nextConfig: NextConfig = { return [ { source: "/api/:path*", - destination: "http://127.0.0.1:8000/:path*/", + destination: `${process.env.INTERNAL_API_URL || "http://localhost:8000"}/:path*/`, }, ]; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c4f8d777..8f971af2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.2.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.2.0", + "version": "0.5.0", "hasInstallScript": true, "dependencies": { "@microsoft/fetch-event-source": "^2.0.1", diff --git a/frontend/package.json b/frontend/package.json index 14a5a9d5..7a368244 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.2.0", + "version": "0.5.0", "private": true, "scripts": { "dev": "next dev --turbopack", diff --git a/frontend/src/lib/utils/api/server-fetch.ts b/frontend/src/lib/utils/api/server-fetch.ts index 6eb777a7..b947f0ed 100644 --- a/frontend/src/lib/utils/api/server-fetch.ts +++ b/frontend/src/lib/utils/api/server-fetch.ts @@ -4,6 +4,21 @@ import { getAuthCookieString } from "@/lib/utils/api/cookie-utils"; import { InferReq, InferRes } from "@/lib/utils/api/endpoints"; import { fetchJson } from "@/lib/utils/api/fetch-wrapper"; +/** + * Resolves the backend API base URL, throwing a configuration error if + * neither environment variable is set. + */ +function getApiBaseUrl(): string { + const baseUrl = + process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_URL; + if (!baseUrl) { + throw new Error( + "API base URL is not configured. Set INTERNAL_API_URL or NEXT_PUBLIC_API_URL.", + ); + } + return baseUrl; +} + /** * Extracts headers for User-Agent and X-Forwarded-For from the incoming request */ @@ -32,7 +47,7 @@ export async function serverGet( params?: InferReq, options?: RequestInit, ): Promise> { - const baseUrl = process.env.NEXT_PUBLIC_API_URL; + const baseUrl = getApiBaseUrl(); let queryString = ""; if (params && Object.keys(params).length > 0) { @@ -69,7 +84,7 @@ export async function serverPost( body?: InferReq, options?: RequestInit, ): Promise> { - const baseUrl = process.env.NEXT_PUBLIC_API_URL; + const baseUrl = getApiBaseUrl(); const url = `${baseUrl}${endpoint.url}`; const cookieString = await getAuthCookieString(); const forwardedHeaders = await getForwardedHeaders(); diff --git a/frontend/start.sh b/frontend/start.sh new file mode 100644 index 00000000..d11fefdd --- /dev/null +++ b/frontend/start.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +echo "Checking and syncing node_modules..." +npm install + +echo "Starting Next.js..." +exec npm run dev diff --git a/scripts/print-ip.js b/scripts/print-ip.js new file mode 100644 index 00000000..ff03f94b --- /dev/null +++ b/scripts/print-ip.js @@ -0,0 +1,9 @@ +const os = require("os"); + +for (const interfaces of Object.values(os.networkInterfaces())) { + for (const iface of interfaces ?? []) { + if (iface.family === "IPv4" && !iface.internal) { + console.log(`http://${iface.address}:3000`); + } + } +}