feat: harden containers and ci

This commit is contained in:
2025-10-16 22:56:33 -05:00
parent c51604fdb7
commit 8ca2756d7b
14 changed files with 293 additions and 17 deletions

View File

@@ -7,6 +7,12 @@ POSTGRES_DB=merchantsofhope_supplyanddemandportal
POSTGRES_USER=merchantsofhope_user
POSTGRES_PASSWORD=merchantsofhope_password
# Optional database pool tuning
DB_POOL_MAX=10
DB_POOL_IDLE_MS=30000
DB_POOL_CONNECTION_TIMEOUT_MS=5000
DB_WAIT_TIMEOUT_MS=60000
# Backend Application
JWT_SECRET=a_much_stronger_and_longer_secret_key_for_jwt_that_is_not_in_git
RATE_LIMIT_WINDOW_MS=900000

View File

@@ -83,6 +83,16 @@ RATE_LIMIT_MAX=100
# Uploads directory (overridable if you mount elsewhere)
UPLOAD_DIR=uploads/resumes
# Optional entrypoint controls
RUN_MIGRATIONS=true
RUN_SEED=false
# Database pool and wait tuning (milliseconds)
DB_POOL_MAX=10
DB_POOL_IDLE_MS=30000
DB_POOL_CONNECTION_TIMEOUT_MS=5000
DB_WAIT_TIMEOUT_MS=60000
```
Set `POSTGRES_PASSWORD` and `JWT_SECRET` before launching the stack. See `.env.example` for the full list.
@@ -165,6 +175,7 @@ To mirror CI in one shot, run the helper script from the repository root:
```
The script executes linting followed by the backend and frontend test suites (each with coverage reporting). Coverage thresholds are enforced by Jest to guard against regressions.
The helper waits for the ad-hoc PostgreSQL container, honours the backend entrypoint (which now blocks on database readiness, runs migrations, and optionally seeds), and then runs both suites with coverage thresholds enforced.
## Continuous Integration

View File

@@ -1,15 +1,29 @@
FROM node:18-alpine
# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS base
WORKDIR /app
COPY package*.json ./
FROM base AS dev
ENV NODE_ENV=development
RUN npm ci
COPY . .
ENV HOST=0.0.0.0 \
PORT=3001
EXPOSE 3001
RUN chmod +x docker-entrypoint.sh scripts/wait-for-db.js
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["npm", "run", "dev"]
FROM base AS prod-deps
ENV NODE_ENV=production
RUN npm ci --omit=dev
FROM node:18-alpine AS prod
WORKDIR /app
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=3001
COPY --from=prod-deps /app/node_modules ./node_modules
COPY . .
RUN chmod +x docker-entrypoint.sh scripts/wait-for-db.js
EXPOSE 3001
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "src/index.js"]

21
backend/docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env sh
set -euo pipefail
RUN_MIGRATIONS="${RUN_MIGRATIONS:-true}"
RUN_SEED="${RUN_SEED:-false}"
if [ "${SKIP_DB_WAIT:-false}" != "true" ]; then
node ./scripts/wait-for-db.js
fi
if [ "${RUN_MIGRATIONS}" = "true" ]; then
echo ">> Running database migrations"
npm run migrate
fi
if [ "${RUN_SEED}" = "true" ]; then
echo ">> Seeding database"
npm run seed || true
fi
exec "$@"

65
backend/scripts/wait-for-db.js Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env node
const { Client } = require('pg');
const SECOND = 1000;
const DEFAULT_TIMEOUT = Number.parseInt(process.env.DB_WAIT_TIMEOUT_MS || `${60 * SECOND}`, 10);
const RETRY_INTERVAL = Number.parseInt(process.env.DB_WAIT_RETRY_MS || '2000', 10);
function deriveConnectionString() {
if (process.env.DATABASE_URL) {
return process.env.DATABASE_URL;
}
const {
POSTGRES_USER = 'merchantsofhope_user',
POSTGRES_PASSWORD,
POSTGRES_DB = 'merchantsofhope_supplyanddemandportal',
POSTGRES_HOST = 'merchantsofhope-supplyanddemandportal-database',
POSTGRES_PORT = '5432'
} = process.env;
if (!POSTGRES_PASSWORD) {
return null;
}
return `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}`;
}
const connectionString = deriveConnectionString();
if (!connectionString) {
console.error('[wait-for-db] DATABASE_URL or POSTGRES_PASSWORD must be provided.');
process.exit(1);
}
const startTime = Date.now();
async function waitForDatabase() {
const client = new Client({
connectionString,
connectionTimeoutMillis: Math.min(RETRY_INTERVAL, DEFAULT_TIMEOUT)
});
try {
await client.connect();
} catch (error) {
const elapsed = Date.now() - startTime;
if (elapsed >= DEFAULT_TIMEOUT) {
console.error('[wait-for-db] Timed out waiting for database connection.');
console.error(error);
process.exit(1);
}
const remaining = DEFAULT_TIMEOUT - elapsed;
const delay = Math.min(RETRY_INTERVAL, remaining);
console.info(`[wait-for-db] Database not ready yet. Retrying in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
return waitForDatabase();
} finally {
await client.end().catch(() => {});
}
console.info('[wait-for-db] Database connection established.');
}
waitForDatabase().then(() => process.exit(0));

View File

@@ -39,6 +39,11 @@ const config = {
rateLimit: {
windowMs: optionalNumber(process.env.RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000),
max: optionalNumber(process.env.RATE_LIMIT_MAX, 100)
},
db: {
max: optionalNumber(process.env.DB_POOL_MAX, 10),
idleTimeoutMillis: optionalNumber(process.env.DB_POOL_IDLE_MS, 30000),
connectionTimeoutMillis: optionalNumber(process.env.DB_POOL_CONNECTION_TIMEOUT_MS, 5000)
}
};

View File

@@ -3,7 +3,10 @@ const config = require('../config');
const pool = new Pool({
connectionString: config.databaseUrl,
ssl: config.env === 'production' ? { rejectUnauthorized: false } : false
ssl: config.env === 'production' ? { rejectUnauthorized: false } : false,
max: config.db.max,
idleTimeoutMillis: config.db.idleTimeoutMillis,
connectionTimeoutMillis: config.db.connectionTimeoutMillis
});
// Test database connection

View File

@@ -1,6 +1,45 @@
const app = require('./server');
const config = require('./config');
const pool = require('./database/connection');
app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
console.info(`MerchantsOfHope-SupplyANdDemandPortal backend server running on ${config.host}:${config.port}`);
});
let terminating = false;
async function shutdown(reason, exitCode = 0) {
if (terminating) {
return;
}
terminating = true;
console.info(`Shutting down server (${reason})`);
try {
await new Promise((resolve) => {
server.close(resolve);
});
await pool.end();
console.info('Server shutdown complete.');
} catch (error) {
console.error('Error during shutdown:', error);
exitCode = exitCode || 1;
} finally {
process.exit(exitCode);
}
}
['SIGTERM', 'SIGINT'].forEach((signal) => {
process.on(signal, () => shutdown(signal));
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled promise rejection:', reason);
shutdown('unhandledRejection', 1);
});
process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error);
shutdown('uncaughtException', 1);
});

View File

@@ -28,16 +28,25 @@ services:
DATABASE_URL: postgresql://${POSTGRES_USER:-merchantsofhope}:${POSTGRES_PASSWORD}@merchantsofhope-supplyanddemandportal-database:5432/${POSTGRES_DB:-merchantsofhope_supplyanddemandportal}
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET}
UPLOAD_DIR: /app/uploads/resumes
RUN_MIGRATIONS: "true"
RUN_SEED: "false"
DB_WAIT_TIMEOUT_MS: 120000
ports:
- "0.0.0.0:3001:3001"
volumes:
- merchantsofhope-supplyanddemandportal-uploads:/app/uploads/resumes
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/health || exit 1"]
interval: 30s
timeout: 5s
retries: 5
merchantsofhope-supplyanddemandportal-frontend:
image: ${FRONTEND_IMAGE:?set FRONTEND_IMAGE}
container_name: merchantsofhope-supplyanddemandportal-frontend
depends_on:
- merchantsofhope-supplyanddemandportal-backend
merchantsofhope-supplyanddemandportal-backend:
condition: service_healthy
environment:
HOST: 0.0.0.0
PORT: 12000

View File

@@ -22,22 +22,27 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
target: dev
container_name: merchantsofhope-supplyanddemandportal-backend
environment:
NODE_ENV: development
DATABASE_URL: postgresql://${POSTGRES_USER:-merchantsofhope_user}:${POSTGRES_PASSWORD}@merchantsofhope-supplyanddemandportal-database:5432/${POSTGRES_DB:-merchantsofhope_supplyanddemandportal}
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is not set}
HOST: ${BACKEND_HOST:-0.0.0.0}
PORT: ${BACKEND_PORT:-3001}
POSTGRES_HOST: merchantsofhope-supplyanddemandportal-database
UPLOAD_DIR: /app/uploads/resumes
RUN_MIGRATIONS: "true"
RUN_SEED: "false"
ports:
- "0.0.0.0:${BACKEND_PORT:-3001}:3001"
command: >
sh -c "npm run migrate && npm run seed && npm run dev"
depends_on:
merchantsofhope-supplyanddemandportal-database:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/health || exit 1"]
interval: 30s
timeout: 5s
retries: 5
volumes:
- ./backend:/app
- backend-resume-uploads:/app/uploads/resumes

View File

@@ -0,0 +1,88 @@
# Operations Runbook
This document captures the operational playbooks for the MerchantsOfHope Supply & Demand Portal. It is intended for on-call engineers and SREs maintaining the platform across Coolify environments.
## 1. Service Topology
- **Backend API (`merchantsofhope-supplyanddemandportal-backend`)**
- Node.js 18, Express server on port 3001.
- Entry point waits for PostgreSQL, runs migrations, optional seeding (`RUN_SEED`).
- Health probe: `GET /api/health`.
- Persistent uploads stored at `/app/uploads/resumes` (mounted volume required in production).
- **Frontend (`merchantsofhope-supplyanddemandportal-frontend`)**
- React 18 application served by the CRA dev server (in dev) or static bundle (in production image).
- Communicates with the backend via `REACT_APP_API_URL` (set to the internal service URL).
- **PostgreSQL (`merchantsofhope-supplyanddemandportal-database`)**
- PostgreSQL 15, health-checked via `pg_isready`.
- Volume-backed data directory `merchantsofhope-supplyanddemandportal-postgres-data`.
## 2. Environment Variables
| Variable | Purpose | Default |
| --- | --- | --- |
| `POSTGRES_*` | Database credentials used by backend and DB container | See `.env.example` |
| `DATABASE_URL` | Overrides assembled connection string | Derived automatically |
| `JWT_SECRET` | Required for signing auth tokens | none (must be supplied) |
| `RATE_LIMIT_MAX`, `RATE_LIMIT_WINDOW_MS` | Express rate limiter configuration | 100 req / 15 min |
| `DB_POOL_MAX`, `DB_POOL_IDLE_MS`, `DB_POOL_CONNECTION_TIMEOUT_MS` | pg connection pool tuning | 10 / 30000 / 5000 |
| `DB_WAIT_TIMEOUT_MS` | Maximum wait for database readiness in entrypoint | 60000 |
| `RUN_MIGRATIONS` | Run schema migrations on container boot | `true` |
| `RUN_SEED` | Run seed data on container boot | `false` |
| `UPLOAD_DIR` | Resume storage path | `uploads/resumes` |
## 3. Deployments (Coolify)
1. Ensure the Gitea pipeline has published new backend/frontend images (see workflow summary for SHA tags).
2. In Coolify, update `BACKEND_IMAGE` / `FRONTEND_IMAGE` environment variables to the new tags.
3. Trigger a deployment; Coolify will:
- Bring up PostgreSQL (if not already running).
- Start backend, wait for DB, run migrations, and expose `/api/health`.
- Start frontend once backend healthcheck passes.
4. Post-deploy checks:
- `curl https://<domain>/api/health` returns `200` with JSON payload.
- Frontend login screen reachable.
- Review container logs for migration output (`docker compose logs backend` in Coolify shell).
## 4. Rollback Procedure
1. Identify the previous known-good image tags (from Gitea workflow history or Coolify activity log).
2. Update `BACKEND_IMAGE` / `FRONTEND_IMAGE` to the old tags.
3. Redeploy in Coolify. Migrations are idempotent; no additional action needed.
4. Validate health endpoints and smoke-test the UI.
## 5. Local Development
- Run `docker compose up --build` to start the stack. The backend container waits for PostgreSQL, runs migrations automatically, and skips seeding by default. To seed once, run `RUN_SEED=true docker compose up backend` or execute `docker compose exec ... npm run seed` manually.
- `./scripts/run-ci-tests.sh` runs lint + unit tests with the same coverage thresholds as CI.
- Backend tests rely on Docker; ensure Docker Desktop/Engine is running.
## 6. Backup & Restore
### Database
- Use `docker compose exec merchantsofhope-supplyanddemandportal-database pg_dump -U <user> <db>` to generate a dump file.
- Restore via `psql` piping the dump into the running container.
### Uploads
- Archive the `merchantsofhope-supplyanddemandportal-uploads` volume (Coolify: Settings → Backups → Volume Snapshot).
## 7. Monitoring & Alerting
- Healthcheck endpoints should be wired into external monitoring (e.g., Uptime Kuma, Grafana Cloud).
- Rate limiter defaults protect against bursts; adjust `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MS` if legitimate traffic patterns trigger 429s.
## 8. Incident Response Checklist
1. **Validate Health** `curl` backend health endpoint, inspect Coolify container logs.
2. **Check Database** `docker compose exec ... pg_isready` and `
docker compose exec ... psql -c 'SELECT NOW();'`.
3. **Restart Services** In Coolify or locally, redeploy backend/front containers (entrypoint will re-run migrations safely).
4. **Rollback if Needed** Follow rollback steps above.
5. **Postmortem** Capture root cause, update this runbook with remediation notes.
## 9. Security Posture
- JWT secrets must be at least 32 bytes and rotated regularly.
- Uploaded files are sanitized and stored on disk; configure antivirus scanning if compliance requires it.
- Rate limiting is enabled globally; consider pairing with IP allowlists at the reverse proxy if stricter controls are needed.
Keep this runbook updated as infrastructure evolves.

View File

@@ -28,6 +28,16 @@
"eject": "react-scripts eject",
"lint": "eslint --ext js,jsx src"
},
"jest": {
"coverageThreshold": {
"global": {
"statements": 18,
"branches": 7,
"functions": 12,
"lines": 18
}
}
},
"eslintConfig": {
"extends": [
"react-app",

View File

@@ -30,7 +30,7 @@ jest.mock('react-router-dom', () => ({
describe('Layout', () => {
const renderLayout = () => {
return render(
<MemoryRouter>
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<Layout />
</MemoryRouter>
);

View File

@@ -23,7 +23,7 @@ jest.mock('react-query', () => ({
const renderJobs = () => {
render(
<MemoryRouter>
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<Jobs />
</MemoryRouter>
);