diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index fbfa433..499d06d 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -40,24 +40,48 @@ jobs: run: pnpm test # CD - - name: Create .env file - run: echo "SITE_URL=${{ env.SITE_URL }}" >> .env + # The revision label records which commit this image was built from - name: Build Docker Image - run: docker build -t jadendocks/ask_easy . + run: | + docker build \ + --label org.opencontainers.image.revision=${{ github.sha }} \ + -t jadendocks/ask_easy . + # Only main publishes :latest - name: Login to Docker Hub - run: docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} - name: Publish Docker Image to Docker Hub + if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: docker push jadendocks/ask_easy:latest deploy: needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: self-hosted + timeout-minutes: 20 + env: + # -p pins the project name so the existing named volumes are reused + COMPOSE: >- + -p ask_easy + --env-file /home/easy/secrets/prod.env + -f docker-compose.yml -f docker-compose.prod.yml steps: - - name: Clean up Docker to free disk space - run: docker system prune -af --volumes - - name: Pull Image from Docker Hub - run: docker pull jadendocks/ask_easy:latest - - name: Stop and Remove Existing Container - run: docker rm -f ask_easy - - name: Run Docker Container - run: docker run -d -p 3000:3000 --name ask_easy jadendocks/ask_easy:latest + # Only the compose files are used here, the app itself comes from the image + - uses: actions/checkout@v4 + + - name: Reclaim disk (images only) + run: docker image prune -af + - name: Pull new app image + run: docker compose $COMPOSE pull app + - name: Start data services + run: docker compose $COMPOSE up -d --no-build postgres redis + + # Runs before the app is swapped, so a failed migration leaves the + # current version serving + - name: Apply database migrations + run: docker compose $COMPOSE run --rm --no-deps app npx prisma migrate deploy + - name: Recreate app container + run: docker compose $COMPOSE up -d --no-build app diff --git a/Dockerfile b/Dockerfile index b54ace9..1a68561 100644 --- a/Dockerfile +++ b/Dockerfile @@ -64,10 +64,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/src ./src COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts ./ -# Whitelist files (needed by auth logic) -COPY --from=builder --chown=nextjs:nodejs /app/admin_whitelist.txt ./ -COPY --from=builder --chown=nextjs:nodejs /app/whitelist.txt ./ - USER nextjs EXPOSE 3000 diff --git a/admin_whitelist.txt b/admin_whitelist.txt deleted file mode 100644 index 66614ee..0000000 --- a/admin_whitelist.txt +++ /dev/null @@ -1,8 +0,0 @@ -# Admin whitelist — god-mode dashboard access -# Format: one UTORid per line -# UTORids listed here can access /dashboard with full read/delete powers. -# Lines starting with # are comments and are ignored. -# Restart the server to pick up changes. -# -# ---- ADMINS ---- -testprof \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d7b8ad1..20524d9 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,26 +1,42 @@ -# Production override - uses pre-built image from Docker Hub -# Usage: docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d +# Production override - runs the pre-built image from Docker Hub. +# The VM never builds; the deploy job only pulls and restarts. +# +# Usage: +# docker compose -p ask_easy --env-file /home/easy/secrets/prod.env \ +# -f docker-compose.yml -f docker-compose.prod.yml up -d + +# Docker's json-file driver is unbounded by default, and postgres/redis are +# never recreated by a deploy, so their logs would grow until the disk filled. +# 25m x 4 caps each service at 100MB while keeping recent history in chunks. +x-logging: &logging + driver: json-file + options: + max-size: "25m" + max-file: "4" + services: app: image: jadendocks/ask_easy:latest - build: - context: . + logging: *logging volumes: - - ./src/server.ts:/app/src/server.ts:ro - - ./src/app/api/auth/session/route.ts:/app/src/app/api/auth/session/route.ts:ro - - ./whitelist.txt:/app/whitelist.txt:ro - - ./admin_whitelist.txt:/app/admin_whitelist.txt:ro - - ./uploads:/app/uploads - env_file: .env + # Named volume, not a bind mount: the deploy checkout is wiped on every + # run, so anything stored relative to it would be destroyed. + - uploads_data:/app/uploads + env_file: /home/easy/secrets/prod.env environment: - - DATABASE_URL=${DATABASE_URL} - - REDIS_URL=${REDIS_URL} + - DATABASE_URL=postgresql://${POSTGRES_USER:?required in prod.env}:${POSTGRES_PASSWORD:?required in prod.env}@postgres:5432/${POSTGRES_DB:?required in prod.env} + - REDIS_URL=redis://:${REDIS_PASSWORD:?required in prod.env}@redis:6379 postgres: + logging: *logging environment: - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required in prod.env} redis: - command: redis-server --requirepass ${REDIS_PASSWORD} + logging: *logging + command: redis-server --requirepass ${REDIS_PASSWORD:?required in prod.env} healthcheck: - test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:?required in prod.env}", "ping"] + +volumes: + uploads_data: diff --git a/docker-compose.yml b/docker-compose.yml index 5598165..3cdfb3e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,9 +6,11 @@ services: ports: - "127.0.0.1:3000:3000" environment: - - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/ask_easy - - REDIS_URL=redis://:changeme@redis:6379 + - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-ask_easy} + - REDIS_URL=redis://:${REDIS_PASSWORD:-changeme}@redis:6379 - SESSION_SECRET=${SESSION_SECRET} + - PROFESSOR_WHITELIST=${PROFESSOR_WHITELIST} + - ADMIN_WHITELIST=${ADMIN_WHITELIST} - DEV_UTORID=${DEV_UTORID} - DEV_NAME=${DEV_NAME} - DEV_ROLE=${DEV_ROLE} @@ -30,7 +32,7 @@ services: volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] interval: 5s timeout: 5s retries: 5 diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts index 1756412..9f4d6b1 100644 --- a/src/app/api/auth/session/route.ts +++ b/src/app/api/auth/session/route.ts @@ -66,7 +66,7 @@ export async function GET(request: NextRequest) { // ------------------------------------------------------------------ // 2. Resolve role from the instructor whitelist. - // PROFESSOR / TA → listed in whitelist.txt + // PROFESSOR / TA → listed in PROFESSOR_WHITELIST // STUDENT → everyone else (default) // In dev mode DEV_ROLE overrides the whitelist (for testing instructor UI). // ------------------------------------------------------------------ diff --git a/src/lib/adminWhitelist.ts b/src/lib/adminWhitelist.ts index 5b324f9..663607b 100644 --- a/src/lib/adminWhitelist.ts +++ b/src/lib/adminWhitelist.ts @@ -1,38 +1,27 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; - // --------------------------------------------------------------------------- // Admin whitelist // -// Reads `admin_whitelist.txt` (or the path in ADMIN_WHITELIST_PATH env var) -// to determine which UTORids have god-mode dashboard access. -// One UTORid per line; comment lines start with #. +// Reads the ADMIN_WHITELIST env var to determine which UTORids have god-mode +// dashboard access. Comma-separated; surrounding whitespace is ignored. // -// File format (one entry per line): -// utorid -// # comment lines are ignored +// Example: +// ADMIN_WHITELIST=smithj,doejohn // --------------------------------------------------------------------------- function loadAdminWhitelist(): Set { - const whitelistPath = resolve(process.env.ADMIN_WHITELIST_PATH ?? "./admin_whitelist.txt"); + const raw = process.env.ADMIN_WHITELIST; - let contents: string; - try { - contents = readFileSync(whitelistPath, "utf-8"); - } catch { + if (!raw) { console.warn( - `[admin-whitelist] Could not read admin whitelist at ${whitelistPath}. No users will have dashboard access.` + "[admin-whitelist] ADMIN_WHITELIST is not set. No users will have dashboard access." ); return new Set(); } const set = new Set(); - for (const rawLine of contents.split("\n")) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) continue; - - const utorid = line.split(",")[0].trim().toLowerCase(); + for (const rawEntry of raw.split(",")) { + const utorid = rawEntry.trim().toLowerCase(); if (utorid) set.add(utorid); } @@ -40,7 +29,7 @@ function loadAdminWhitelist(): Set { } // Loaded once at startup (module-level cache). -// Restart the server to pick up changes to admin_whitelist.txt. +// Restart the server to pick up changes to ADMIN_WHITELIST. const ADMIN_WHITELIST: Set = loadAdminWhitelist(); /** diff --git a/src/lib/whitelist.ts b/src/lib/whitelist.ts index 41894d7..3eba110 100644 --- a/src/lib/whitelist.ts +++ b/src/lib/whitelist.ts @@ -1,51 +1,34 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; - import type { Role } from "@/utils/types"; // --------------------------------------------------------------------------- // Instructor whitelist // -// Reads `whitelist.txt` (or the path in WHITELIST_PATH env var) to determine -// which UTORids are professors. One UTORid per line; comment lines start with #. +// Reads the PROFESSOR_WHITELIST env var to determine which UTORids are +// professors. Comma-separated; surrounding whitespace is ignored. // -// Any UTORid in the file → PROFESSOR -// Any UTORid NOT in the file → STUDENT +// Any UTORid in the list → PROFESSOR +// Any UTORid NOT in the list → STUDENT // // TAs are assigned per-course by professors via the UI and stored in // CourseEnrollment.role. They are never listed here. // -// File format (one entry per line): -// utorid -// # comment lines are ignored -// // Example: -// smithj -// doejohn +// PROFESSOR_WHITELIST=smithj,doejohn // --------------------------------------------------------------------------- function loadWhitelist(): Set { - const whitelistPath = resolve(process.env.WHITELIST_PATH ?? "./whitelist.txt"); + const raw = process.env.PROFESSOR_WHITELIST; - let contents: string; - try { - contents = readFileSync(whitelistPath, "utf-8"); - } catch { - // If the file doesn't exist, everyone is a student — not a fatal error. - console.warn( - `[whitelist] Could not read whitelist at ${whitelistPath}. All users will be STUDENT.` - ); + if (!raw) { + // If the var is unset, everyone is a student — not a fatal error. + console.warn("[whitelist] PROFESSOR_WHITELIST is not set. All users will be STUDENT."); return new Set(); } const set = new Set(); - for (const rawLine of contents.split("\n")) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) continue; - - // Tolerate legacy "utorid,PROFESSOR" format — strip anything after a comma - const utorid = line.split(",")[0].trim().toLowerCase(); + for (const rawEntry of raw.split(",")) { + const utorid = rawEntry.trim().toLowerCase(); if (utorid) set.add(utorid); } @@ -53,7 +36,7 @@ function loadWhitelist(): Set { } // Loaded once at startup (module-level cache). -// Restart the server to pick up changes to whitelist.txt. +// Restart the server to pick up changes to PROFESSOR_WHITELIST. const WHITELIST: Set = loadWhitelist(); /** diff --git a/whitelist.txt b/whitelist.txt deleted file mode 100644 index 59a11f3..0000000 --- a/whitelist.txt +++ /dev/null @@ -1,9 +0,0 @@ -# Instructor whitelist -# Format: one UTORid per line -# UTORids listed here are assigned the PROFESSOR role on login. -# Everyone else defaults to STUDENT. -# TAs are assigned per-course by professors via the UI. -# Lines starting with # are comments and are ignored. -# -# ---- TEST ACCOUNTS ---- -testprof