diff --git a/.env.sample b/.env.sample index d56f890f..8f8c8039 100644 --- a/.env.sample +++ b/.env.sample @@ -1,3 +1,5 @@ GOOGLE_CALENDAR_API_KEY= SENTRY_DSN= NODE_ENV= +RESTART_TOKEN=your-secret-token-here +RESTART_SCRIPT=/path/to/poke-docker.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..365f932b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,107 @@ +name: Release Deployment + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + environment: release + steps: + - name: Checkout code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Extract and verify version + id: version + run: | + TAG="${{ github.event.release.tag_name }}" + VERSION="${TAG#v}" + echo "Release tag version: $VERSION" + + # Extract version from package.json + PACKAGE_VERSION=$(node -p "require('./package.json').version") + echo "package.json version: $PACKAGE_VERSION" + + # Verify they match + if [ "$VERSION" != "$PACKAGE_VERSION" ]; then + echo "❌ Error: Release tag version ($VERSION) does not match package.json version ($PACKAGE_VERSION)" + echo "Please ensure package.json version is updated before creating a release" + exit 1 + fi + + echo "✅ Version verified: $VERSION" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Trigger server restart + run: | + echo "Triggering server restart..." + curl -X POST \ + -H "Authorization: Bearer ${{ secrets.RESTART_TOKEN }}" \ + -f \ + ${{ secrets.SERVER_URL }}/restart + echo "Restart command sent successfully" + + - name: Wait for server to restart + run: | + echo "Waiting 10 seconds for server to restart..." + sleep 10 + + - name: Verify deployment + run: | + EXPECTED_VERSION="${{ steps.version.outputs.version }}" + echo "Expected version: $EXPECTED_VERSION" + + MAX_ATTEMPTS=24 + SLEEP_INTERVAL=5 + + for i in $(seq 1 $MAX_ATTEMPTS); do + echo "Attempt $i/$MAX_ATTEMPTS: Checking health endpoint..." + + # Fetch health endpoint + RESPONSE=$(curl -s ${{ secrets.SERVER_URL }}/health || echo "") + + if [ -z "$RESPONSE" ]; then + echo "⚠️ No response from server" + else + # Extract version from response + ACTUAL_VERSION=$(echo "$RESPONSE" | jq -r '.version') + echo "Server reported version: $ACTUAL_VERSION" + + if [ "$ACTUAL_VERSION" = "$EXPECTED_VERSION" ]; then + echo "✅ Deployment verified: version $ACTUAL_VERSION matches expected $EXPECTED_VERSION" + exit 0 + else + echo "⚠️ Version mismatch: expected $EXPECTED_VERSION, got $ACTUAL_VERSION" + fi + fi + + if [ $i -lt $MAX_ATTEMPTS ]; then + echo "Waiting ${SLEEP_INTERVAL}s before next attempt..." + sleep $SLEEP_INTERVAL + fi + done + + echo "❌ Deployment verification failed after $((MAX_ATTEMPTS * SLEEP_INTERVAL))s" + exit 1 + + - name: Notify Telegram - Success + if: success() + run: | + curl -X POST \ + -H "Content-Type: multipart/form-data" \ + -F chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \ + -F text="✅ ccc-server ${{ github.event.release.tag_name }} deployed successfully" \ + https://api.telegram.org/bot${{ secrets.TELEGRAM_TOKEN }}/sendMessage + + - name: Notify Telegram - Failure + if: failure() + run: | + curl -X POST \ + -H "Content-Type: multipart/form-data" \ + -F chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \ + -F text="❌ ccc-server ${{ github.event.release.tag_name }} deployment FAILED. Check GitHub Actions logs: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + https://api.telegram.org/bot${{ secrets.TELEGRAM_TOKEN }}/sendMessage diff --git a/README.md b/README.md index 35b07f88..2466d25e 100644 --- a/README.md +++ b/README.md @@ -58,3 +58,106 @@ mise run test:carleton-college TDD workflow This repository practices TDD for agentic development: write a failing AVA test next to the implementation (`*.test.ts`), run `mise run test`, implement until green, then run smoke tests for integration checks. + +## Deployment + +### Automated Release Workflow + +The repository uses GitHub Actions to automate server restarts after new releases are published. + +#### Prerequisites + +The following secrets must be configured in GitHub: + +- **`RESTART_TOKEN`** - Secret token for authenticating restart requests (must match the server's `RESTART_TOKEN` environment variable) +- **`SERVER_URL`** - Base URL of the production server (e.g., `https://api.example.com`) +- **`TELEGRAM_TOKEN`** - Bot token for Telegram notifications +- **`TELEGRAM_CHAT_ID`** - Chat ID for Telegram notifications + +#### Environment Protection + +The workflow uses the `release` environment with manual approval requirements: + +1. Go to **Settings > Environments > release** +2. Enable **Required reviewers** +3. Add authorized users who can approve deployments + +#### How it Works + +1. When a new release is published on GitHub, the workflow: + - Verifies the release tag matches the version in `package.json` + - Waits for manual approval (if configured) + - Triggers a server restart via `POST /restart` + - Polls the `/health` endpoint to verify the new version is running + - Sends a Telegram notification on success or failure + +2. The server must have: + - `RESTART_TOKEN` environment variable set (matching GitHub secret) + - A process manager (systemd, PM2, Docker) that automatically restarts the process on exit + +#### Releasing a New Version + +Before creating a release: + +1. Update the version in `package.json`: + ```bash + npm version patch # or minor, or major + ``` + +2. Commit the version change: + ```bash + git add package.json package-lock.json + git commit -m "Bump version to X.Y.Z" + git push + ``` + +3. Create a GitHub release with a tag matching the package.json version (e.g., `v1.2.3`) + +The workflow will verify that the release tag matches the package.json version before proceeding with the deployment. + +#### Health Check Endpoint + +`GET /health` + +Returns the current version from `package.json` and status. + +Returns: +```json +{ + "version": "1.2.3", + "status": "ok" +} +``` + +#### Restart Endpoint + +`POST /restart` + +Headers: +``` +Authorization: Bearer +``` + +Returns: +```json +{ + "message": "Server restart initiated" +} +``` + +**Restart Behavior:** + +The restart endpoint supports two modes: + +1. **Custom Script (Docker/docker-compose deployments)**: Set `RESTART_SCRIPT` environment variable to point to a script that handles the restart. For docker-compose deployments, this script should pull new images and restart containers: + + ```bash + # Example: /home/user/poke-docker.sh + #!/bin/sh + cd /home/user/ccc-server + docker-compose pull && docker-compose down && docker-compose up -d + ``` + + Set `RESTART_SCRIPT=/home/user/poke-docker.sh` in your environment. + +2. **Process Exit (Default)**: If no `RESTART_SCRIPT` is configured, the server will exit gracefully. The process manager (systemd, PM2, or Docker's restart policy) should restart it automatically. **Note**: This won't pull new Docker images, so it's only suitable for non-containerized deployments. diff --git a/package-lock.json b/package-lock.json index 0f9dc5b4..8853766e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,10 +1,12 @@ { "name": "@frogpond/ccc-server", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@frogpond/ccc-server", + "version": "0.1.0", "license": "AGPL-3.0-only", "dependencies": { "@koa/body-parsers": "^6.0.1", diff --git a/package.json b/package.json index d4d66ebc..8c62dd95 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "@frogpond/ccc-server", + "version": "0.1.0", "description": "", "keywords": [], "author": "Frog Pond Labs, LLC", diff --git a/scripts/poke-docker.sh.example b/scripts/poke-docker.sh.example new file mode 100644 index 00000000..9a69b47c --- /dev/null +++ b/scripts/poke-docker.sh.example @@ -0,0 +1,31 @@ +#!/bin/sh +# Example restart script for docker-compose deployments +# This script should be placed on the server (outside the container) +# and referenced via RESTART_SCRIPT environment variable + +# Ensure script is run by the correct user +if [ "$(whoami)" != "your-username" ]; then + >&2 echo "Error: This script must be run by your-username" + exit 1 +fi + +# Enable debugging +set -x + +# Change to the directory containing docker-compose.yml +dir=/home/your-username/ccc-server + +cd "$dir" || exit 1 + +# Pull latest images, stop containers, and restart them +docker-compose pull && docker-compose down && docker-compose up -d + +exit_code=$? + +if [ $exit_code -eq 0 ]; then + echo "Successfully restarted containers with latest images" +else + echo "Failed to restart containers (exit code: $exit_code)" +fi + +exit $exit_code diff --git a/source/ccc-server/health.test.ts b/source/ccc-server/health.test.ts new file mode 100644 index 00000000..b7d14016 --- /dev/null +++ b/source/ccc-server/health.test.ts @@ -0,0 +1,18 @@ +import {test} from 'node:test' +import type {TestContext} from 'node:test' +import {health} from './health.ts' +import type {Context} from './context.ts' + +void test('health endpoint should return version and status', async (t: TestContext) => { + const ctx = {body: null} as Context + await health(ctx) + + t.assert.ok(ctx.body !== null, 'Response body should not be null') + t.assert.equal(typeof ctx.body, 'object', 'Response should be an object') + + const body = ctx.body as {version: string; status: string} + t.assert.ok(body.version, 'Response should include version') + t.assert.equal(body.status, 'ok', 'Status should be "ok"') + t.assert.equal(typeof body.version, 'string', 'Version should be a string') + t.assert.match(body.version, /^\d+\.\d+\.\d+/, 'Version should match semver pattern') +}) diff --git a/source/ccc-server/health.ts b/source/ccc-server/health.ts new file mode 100644 index 00000000..7e8c2b04 --- /dev/null +++ b/source/ccc-server/health.ts @@ -0,0 +1,29 @@ +import {readFile} from 'node:fs/promises' +import {fileURLToPath} from 'node:url' +import {dirname, join} from 'node:path' +import type {Context} from './context.ts' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +let cachedVersion: string | null = null + +async function getVersion(): Promise { + if (cachedVersion) { + return cachedVersion + } + + const packageJsonPath = join(__dirname, '..', '..', 'package.json') + const packageJson = await readFile(packageJsonPath, 'utf-8') + const parsed = JSON.parse(packageJson) as {version: string} + cachedVersion = parsed.version + return cachedVersion +} + +export async function health(ctx: Context): Promise { + const version = await getVersion() + ctx.body = { + version, + status: 'ok', + } +} diff --git a/source/ccc-server/middleware/auth.test.ts b/source/ccc-server/middleware/auth.test.ts new file mode 100644 index 00000000..65bf6ef0 --- /dev/null +++ b/source/ccc-server/middleware/auth.test.ts @@ -0,0 +1,99 @@ +import {test} from 'node:test' +import type {TestContext} from 'node:test' +import {verifyRestartToken} from './auth.ts' +import type {Context} from '../context.ts' + +void test('auth middleware should accept valid token', async (t: TestContext) => { + const validToken = 'test-secret-token' + process.env['RESTART_TOKEN'] = validToken + + const ctx = { + request: { + headers: { + authorization: `Bearer ${validToken}`, + }, + }, + status: 200, + body: null, + } as unknown as Context + + let nextCalled = false + const next = () => { + nextCalled = true + return Promise.resolve() + } + + await verifyRestartToken(ctx, next) + t.assert.ok(nextCalled, 'next() should be called for valid token') + t.assert.equal(ctx.status, 200, 'Status should remain 200 for valid token') +}) + +void test('auth middleware should reject missing token', async (t: TestContext) => { + process.env['RESTART_TOKEN'] = 'test-secret-token' + + const ctx = { + request: { + headers: {}, + }, + status: 200, + body: null, + } as unknown as Context + + let nextCalled = false + const next = () => { + nextCalled = true + return Promise.resolve() + } + + await verifyRestartToken(ctx, next) + t.assert.equal(ctx.status, 401, 'Status should be 401 for missing token') + t.assert.ok(!nextCalled, 'next() should not be called for missing token') +}) + +void test('auth middleware should reject invalid token', async (t: TestContext) => { + process.env['RESTART_TOKEN'] = 'correct-token' + + const ctx = { + request: { + headers: { + authorization: 'Bearer wrong-token', + }, + }, + status: 200, + body: null, + } as unknown as Context + + let nextCalled = false + const next = () => { + nextCalled = true + return Promise.resolve() + } + + await verifyRestartToken(ctx, next) + t.assert.equal(ctx.status, 401, 'Status should be 401 for invalid token') + t.assert.ok(!nextCalled, 'next() should not be called for invalid token') +}) + +void test('auth middleware should reject malformed authorization header', async (t: TestContext) => { + process.env['RESTART_TOKEN'] = 'test-token' + + const ctx = { + request: { + headers: { + authorization: 'NotBearer test-token', + }, + }, + status: 200, + body: null, + } as unknown as Context + + let nextCalled = false + const next = () => { + nextCalled = true + return Promise.resolve() + } + + await verifyRestartToken(ctx, next) + t.assert.equal(ctx.status, 401, 'Status should be 401 for malformed header') + t.assert.ok(!nextCalled, 'next() should not be called for malformed header') +}) diff --git a/source/ccc-server/middleware/auth.ts b/source/ccc-server/middleware/auth.ts new file mode 100644 index 00000000..347f8de0 --- /dev/null +++ b/source/ccc-server/middleware/auth.ts @@ -0,0 +1,36 @@ +import type {Context} from '../context.ts' +import type {Next} from 'koa' + +export async function verifyRestartToken(ctx: Context, next: Next): Promise { + const restartToken = process.env['RESTART_TOKEN'] + + if (!restartToken) { + ctx.status = 500 + ctx.body = {error: 'Server configuration error: RESTART_TOKEN not set'} + return + } + + const authHeader = ctx.request.headers.authorization + if (!authHeader) { + ctx.status = 401 + ctx.body = {error: 'Unauthorized: Missing authorization header'} + return + } + + const parts = authHeader.split(' ') + if (parts.length !== 2 || parts[0] !== 'Bearer') { + ctx.status = 401 + ctx.body = {error: 'Unauthorized: Invalid authorization header format'} + return + } + + const token = parts[1] + if (token !== restartToken) { + ctx.status = 401 + ctx.body = {error: 'Unauthorized: Invalid token'} + return + } + + // Token is valid, continue to next middleware + await next() +} diff --git a/source/ccc-server/restart.test.ts b/source/ccc-server/restart.test.ts new file mode 100644 index 00000000..e5f306e1 --- /dev/null +++ b/source/ccc-server/restart.test.ts @@ -0,0 +1,47 @@ +import {test} from 'node:test' +import type {TestContext} from 'node:test' +import {restart} from './restart.ts' +import type {Context} from './context.ts' + +void test('restart endpoint should return success response', async (t: TestContext) => { + // Mock process.exit to prevent actual exit during test + const originalExit = process.exit.bind(process) + let exitCalled = false + let exitCode: number | undefined + + // @ts-expect-error - Mocking process.exit for testing + process.exit = (code?: number) => { + exitCalled = true + exitCode = code + } + + // Clear RESTART_SCRIPT env var for default behavior test + const originalScript = process.env['RESTART_SCRIPT'] + delete process.env['RESTART_SCRIPT'] + + const ctx = { + status: 200, + body: null, + } as Context + + restart(ctx) + + t.assert.equal(ctx.status, 200, 'Status should be 200') + t.assert.ok(ctx.body !== null, 'Response body should not be null') + const body = ctx.body as {message: string} + t.assert.equal(body.message, 'Server restart initiated', 'Should return restart message') + + // Wait for setImmediate to execute + await new Promise((resolve) => setImmediate(resolve)) + + // Restore process.exit and env var + process.exit = originalExit + if (originalScript !== undefined) { + process.env['RESTART_SCRIPT'] = originalScript + } + + // In production, process.exit would be called with code 0 + // We verify it was called correctly + t.assert.ok(exitCalled, 'process.exit should be called when no RESTART_SCRIPT is set') + t.assert.equal(exitCode, 0, 'Exit code should be 0') +}) diff --git a/source/ccc-server/restart.ts b/source/ccc-server/restart.ts new file mode 100644 index 00000000..658ca818 --- /dev/null +++ b/source/ccc-server/restart.ts @@ -0,0 +1,41 @@ +import {execFile} from 'node:child_process' +import {promisify} from 'node:util' +import type {Context} from './context.ts' + +const execFileAsync = promisify(execFile) + +async function executeRestart(): Promise { + console.log('Server restart requested') + + // Check if a custom restart script is configured + const restartScript = process.env['RESTART_SCRIPT'] + + if (restartScript) { + // Execute custom restart script (e.g., poke-docker.sh for docker-compose pull & restart) + console.log(`Executing restart script: ${restartScript}`) + try { + const {stdout, stderr} = await execFileAsync(restartScript) + if (stdout) console.log('Restart script output:', stdout) + if (stderr) console.error('Restart script errors:', stderr) + } catch (error) { + console.error('Failed to execute restart script:', error) + // Fall back to process exit if script fails + console.log('Falling back to process exit') + process.exit(0) + } + } else { + // Default behavior: exit process (works with systemd, PM2, Docker restart policies) + console.log('No RESTART_SCRIPT configured, exiting process') + process.exit(0) + } +} + +export function restart(ctx: Context): void { + ctx.status = 200 + ctx.body = {message: 'Server restart initiated'} + + // Schedule restart after response is sent + setImmediate(() => { + void executeRestart() + }) +} diff --git a/source/ccc-server/server.ts b/source/ccc-server/server.ts index 5c5331d3..bda71467 100644 --- a/source/ccc-server/server.ts +++ b/source/ccc-server/server.ts @@ -12,6 +12,9 @@ import {ctxCacheControl} from '../ccc-koa/ctx-cache-control.ts' import {cachable, type CacheObject} from '../ccc-koa/cache.ts' import QuickLRU from 'quick-lru' import {ONE_DAY} from '../ccc-lib/constants.ts' +import {health} from './health.ts' +import {restart} from './restart.ts' +import {verifyRestartToken} from './middleware/auth.ts' const InstitutionSchema = z.enum(['stolaf-college', 'carleton-college']) @@ -59,6 +62,10 @@ async function main() { ctx.body = 'pong' }) + router.get('/health', health) + + router.post('/restart', verifyRestartToken, restart) + // // attach middleware //