feat: harden containers and ci
This commit is contained in:
@@ -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
|
||||
|
||||
11
README.md
11
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
21
backend/docker-entrypoint.sh
Executable 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
65
backend/scripts/wait-for-db.js
Executable 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));
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
88
docs/OPERATIONS_RUNBOOK.md
Normal file
88
docs/OPERATIONS_RUNBOOK.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -23,7 +23,7 @@ jest.mock('react-query', () => ({
|
||||
|
||||
const renderJobs = () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<Jobs />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user