Skip to content
Merged

Re 1 #85

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 36 additions & 12 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 0 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 0 additions & 8 deletions admin_whitelist.txt

This file was deleted.

46 changes: 31 additions & 15 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
@@ -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:
8 changes: 5 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/auth/session/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
// ------------------------------------------------------------------
Expand Down
31 changes: 10 additions & 21 deletions src/lib/adminWhitelist.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,35 @@
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<string> {
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<string>();

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);
}

return 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<string> = loadAdminWhitelist();

/**
Expand Down
41 changes: 12 additions & 29 deletions src/lib/whitelist.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,42 @@
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<string> {
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<string>();

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);
}

return 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<string> = loadWhitelist();

/**
Expand Down
9 changes: 0 additions & 9 deletions whitelist.txt

This file was deleted.

Loading