commited
parent
c536def585
commit
a8b213be39
|
|
@ -31,6 +31,11 @@ frontend/
|
||||||
# Candidate CVs live on the bind mount, not inside an image.
|
# Candidate CVs live on the bind mount, not inside an image.
|
||||||
backend/inbox/decoded_attachments/
|
backend/inbox/decoded_attachments/
|
||||||
|
|
||||||
|
# Alembic revision scripts stay out of images (gitignored; never ship to prod).
|
||||||
|
# Schema drift is applied filelessly at API boot when DB_AUTOGENERATE=true.
|
||||||
|
backend/migrations/versions/*.py
|
||||||
|
!backend/migrations/versions/.gitkeep
|
||||||
|
|
||||||
docs/
|
docs/
|
||||||
tests/
|
tests/
|
||||||
scripts/
|
scripts/
|
||||||
|
|
|
||||||
37
.env.example
37
.env.example
|
|
@ -1,4 +1,38 @@
|
||||||
# Copy to .env and fill in. Never commit .env.
|
# Copy to `.env` at the repo root for Compose variable substitution.
|
||||||
|
# Secrets for the app itself still live in `backend/.env` (see backend/.env.example).
|
||||||
|
# Never commit a filled `.env`.
|
||||||
|
|
||||||
|
# --- Compose / production stack ----------------------------------------------------
|
||||||
|
# Used by docker-compose.yml for the container Postgres and frontend publish port.
|
||||||
|
|
||||||
|
DB_USERNAME=postgres
|
||||||
|
DB_PASSWORD=change-me-strong-password
|
||||||
|
DB_NAME=hrms
|
||||||
|
|
||||||
|
# Public SPA port only (production). Dev overlay defaults FRONTEND_PORT to 5173.
|
||||||
|
FRONTEND_PORT=80
|
||||||
|
|
||||||
|
# API workers behind nginx (production backend-api command).
|
||||||
|
UVICORN_WORKERS=2
|
||||||
|
|
||||||
|
# Schema sync on API startup (docker compose up -d --build).
|
||||||
|
# true = upgrade (if any on-disk revisions) + apply model drift in-memory.
|
||||||
|
# Revision .py files stay gitignored and are not written on the server.
|
||||||
|
DB_AUTO_MIGRATE=true
|
||||||
|
DB_AUTOGENERATE=true
|
||||||
|
|
||||||
|
# Empty = same-origin fetches; frontend nginx proxies to backend-api.
|
||||||
|
# Set only when the API is intentionally on another origin.
|
||||||
|
VITE_API_BASE=
|
||||||
|
|
||||||
|
# Optional: override mail service URL for containers (else host.docker.internal:5000).
|
||||||
|
# EMAIL_URL=http://host.docker.internal:5000
|
||||||
|
|
||||||
|
# Optional: point containers at host Postgres instead of Compose postgres
|
||||||
|
# (also set by docker-compose.dev.yml).
|
||||||
|
# DB_HOST=host.docker.internal
|
||||||
|
|
||||||
|
# --- Bulk ATS scoring engine (also read by ats-engine / backend) --------------------
|
||||||
|
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
|
|
@ -15,7 +49,6 @@ OPENAI_MODEL=gpt-5.4-mini
|
||||||
OPENAI_MAX_OUTPUT_TOKENS=4000
|
OPENAI_MAX_OUTPUT_TOKENS=4000
|
||||||
|
|
||||||
# none | minimal | low | medium | high | xhigh
|
# none | minimal | low | medium | high | xhigh
|
||||||
# Per-model support varies; the API rejects a level the model does not implement.
|
|
||||||
OPENAI_EFFORT=low
|
OPENAI_EFFORT=low
|
||||||
|
|
||||||
OPENAI_MAX_RETRIES=3
|
OPENAI_MAX_RETRIES=3
|
||||||
|
|
|
||||||
|
|
@ -57,4 +57,3 @@ frontend/dist/
|
||||||
**.pdf
|
**.pdf
|
||||||
**_**_**.py
|
**_**_**.py
|
||||||
Utopia-ai-hr-ats-portal 1.pem
|
Utopia-ai-hr-ats-portal 1.pem
|
||||||
db_setup.py
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
# Docker
|
||||||
|
|
||||||
|
Production stack is self-contained: Postgres, Redis, API, ATS engine, Taskiq
|
||||||
|
workers, and the React SPA. Only the frontend is published on the host.
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Root compose env (DB password, port, workers) — see .env.example
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit DB_PASSWORD (and JWT_SECRET_KEY inside backend/.env)
|
||||||
|
|
||||||
|
# 2. App secrets (JWT, OpenAI, email, Buffer, …)
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
# Fill JWT_SECRET_KEY, OPENAI_API_KEY, DB_* matching the Compose postgres, …
|
||||||
|
|
||||||
|
# 3. Build and start
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
| Service | Image | Host port |
|
||||||
|
|---|---|---|
|
||||||
|
| `frontend` | `hrms-frontend:local` | `${FRONTEND_PORT:-80}` |
|
||||||
|
| `backend-api` | `hrms-backend:local` | (internal) |
|
||||||
|
| `ats-engine` | `hrms-ats-engine:local` | (internal) |
|
||||||
|
| `redis` | `redis:7-alpine` | (internal) |
|
||||||
|
| `postgres` | `hrms-postgres:local` | (internal) |
|
||||||
|
| Taskiq workers / schedulers | `hrms-backend:local` | (internal) |
|
||||||
|
|
||||||
|
Browser → `http://<host>/` → nginx (same-origin) → `backend-api:8000`.
|
||||||
|
CV files live in the `attachments-data` named volume (shared by API + workers).
|
||||||
|
|
||||||
|
### Schema / migrations (automatic)
|
||||||
|
|
||||||
|
On every `backend-api` start (`docker compose up -d --build`):
|
||||||
|
|
||||||
|
1. Fresh empty Postgres → create all tables from models and stamp a marker.
|
||||||
|
2. Otherwise → `alembic upgrade head` if any revision files exist in the image
|
||||||
|
(they normally do not — versions stay gitignored and are excluded from builds).
|
||||||
|
3. If `DB_AUTOGENERATE=true` (Compose default) → detect ORM drift (new/changed/removed
|
||||||
|
columns or tables) and apply DDL **in-memory** — no `versions/*.py` is written
|
||||||
|
on the server.
|
||||||
|
4. Apply any pending `backend/migrations/manual/*.sql` (seed/RBAC batches only).
|
||||||
|
|
||||||
|
Revision scripts under `backend/migrations/versions/` remain gitignored and are
|
||||||
|
listed in `.dockerignore` so they never ship in the production image.
|
||||||
|
|
||||||
|
Toggle with root `.env`: `DB_AUTO_MIGRATE` / `DB_AUTOGENERATE` (default `true`).
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose config
|
||||||
|
curl -sf http://127.0.0.1/health # nginx → backend /health
|
||||||
|
curl -sf -o /dev/null -w "%{http_code}\n" http://127.0.0.1/
|
||||||
|
docker compose logs -f backend-api
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the SPA, sign in, confirm `/jobs` lists requisitions (Network: `/jobs/fetch`
|
||||||
|
same-origin JSON, not HTML).
|
||||||
|
|
||||||
|
### Secrets
|
||||||
|
|
||||||
|
- Never bake `.env` into images (`.dockerignore` already excludes them).
|
||||||
|
- Require a strong `DB_PASSWORD` and `JWT_SECRET_KEY` before any real deploy.
|
||||||
|
- Root `.env` is for Compose substitution; `backend/.env` is for the app process.
|
||||||
|
|
||||||
|
### TLS
|
||||||
|
|
||||||
|
This stack serves HTTP on the frontend port. Terminate TLS at a reverse proxy or
|
||||||
|
cloud load balancer in front of port 80.
|
||||||
|
|
||||||
|
## Local development (host Postgres)
|
||||||
|
|
||||||
|
Restores published ports, bind-mounted attachments, and `--reload`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
| Override | Value |
|
||||||
|
|---|---|
|
||||||
|
| `DB_HOST` | `host.docker.internal` |
|
||||||
|
| Frontend | `${FRONTEND_PORT:-5173}` |
|
||||||
|
| API | `${BACKEND_PORT:-8000}` |
|
||||||
|
| ATS | `${ATS_PORT:-8100}` |
|
||||||
|
| Redis | `${REDIS_PORT:-6379}` |
|
||||||
|
| Attachments | `./backend/inbox/decoded_attachments` |
|
||||||
|
|
||||||
|
Container Postgres still starts (production default) but is unused while
|
||||||
|
`DB_HOST=host.docker.internal`. Host Postgres must accept Docker-bridge clients
|
||||||
|
(`listen_addresses = '*'`, `pg_hba` for the bridge subnet).
|
||||||
|
|
||||||
|
For the Vite HMR loop, run `npm run dev` on the host against
|
||||||
|
`frontend/.env.development` (`VITE_API_BASE=http://127.0.0.1:8000`).
|
||||||
|
|
||||||
|
## Data migration
|
||||||
|
|
||||||
|
The Compose Postgres volume starts empty. To move an existing host database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_dump -Fc hrms > hrms.dump
|
||||||
|
# with postgres published via the dev overlay, or `docker compose exec -T postgres …`
|
||||||
|
pg_restore -h 127.0.0.1 -p 5433 -U postgres -d hrms --clean --if-exists hrms.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
## Useful commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f backend-api
|
||||||
|
docker compose logs -f taskiq-worker
|
||||||
|
docker compose restart backend-api
|
||||||
|
docker compose down # keep volumes
|
||||||
|
docker compose down -v # wipe postgres + attachments + redis data
|
||||||
|
```
|
||||||
78
README.md
78
README.md
|
|
@ -115,82 +115,24 @@ docker compose up redis taskiq-worker taskiq-scheduler
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
Every service has its own image and its own container, all in one
|
Self-contained production stack (Postgres in Compose; only the SPA is published).
|
||||||
[docker-compose.yml](docker-compose.yml). **Postgres is in that file but does not run**
|
See **[DOCKER.md](DOCKER.md)** for env checklist, verification, TLS notes, and the
|
||||||
— it sits behind a compose profile, and the stack talks to the PostgreSQL server
|
local host-Postgres overlay.
|
||||||
already running on the host.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose build
|
cp .env.example .env # set DB_PASSWORD
|
||||||
docker compose up -d
|
cp backend/.env.example backend/.env # set JWT_SECRET_KEY, OPENAI_API_KEY, …
|
||||||
docker compose ps
|
docker compose up -d --build
|
||||||
|
# SPA: http://localhost/ health: http://localhost/health
|
||||||
```
|
```
|
||||||
|
|
||||||
| Service | Image | Host port | Built from |
|
Local day-to-day (host Postgres, exposed API ports, `--reload`):
|
||||||
|---|---|---|---|
|
|
||||||
| `backend-api` | `hrms-backend:local` | 8000 | [backend/Dockerfile](backend/Dockerfile) |
|
|
||||||
| `taskiq-worker` · `taskiq-scheduler` · `taskiq-cv-worker` · `taskiq-cv-scheduler` | `hrms-backend:local` (same image, different `command`) | — | same |
|
|
||||||
| `ats-engine` | `hrms-ats-engine:local` | 8100 | [app/Dockerfile](app/Dockerfile) |
|
|
||||||
| `frontend` | `hrms-frontend:local` | 5173 | [frontend/Dockerfile](frontend/Dockerfile) |
|
|
||||||
| `redis` | `redis:7-alpine` | 6379 | — |
|
|
||||||
| `postgres` *(profile `postgres` — never starts by default)* | `hrms-postgres:local` | 5433 | [docker/postgres/Dockerfile](docker/postgres/Dockerfile) |
|
|
||||||
|
|
||||||
The backend image builds from the **repo root**, not `./backend`: `job/candidate`
|
|
||||||
imports the scoring engine from `app/`, and `inbox.plugins` pulls that in transitively,
|
|
||||||
so a `./backend` context produces workers that die on `No module named 'app'`.
|
|
||||||
|
|
||||||
### The shared file mount
|
|
||||||
|
|
||||||
`backend/inbox/decoded_attachments/` on the host is bind-mounted into every container
|
|
||||||
that touches a CV — `backend-api`, `taskiq-worker`, `taskiq-cv-worker` — at the
|
|
||||||
identical path `/app/inbox/decoded_attachments`. A PDF written by the API is the same
|
|
||||||
file the worker opens, and absolute paths stored in the database resolve in either
|
|
||||||
direction (`inbox.plugins.resolve_attachment_path` also falls back to
|
|
||||||
basename-under-that-folder for rows written by a host process). Point it elsewhere with
|
|
||||||
`ATTACHMENTS_DIR=/some/host/path`.
|
|
||||||
|
|
||||||
### Talking to the host
|
|
||||||
|
|
||||||
`backend/.env` is written for host processes, so compose overrides the three values a
|
|
||||||
container needs: `DB_HOST=host.docker.internal` (the local Postgres),
|
|
||||||
`EMAIL_URL=http://host.docker.internal:5000` (the email service on the host), and
|
|
||||||
`REDIS_URL=redis://redis:6379/0`. The host Postgres must accept connections from the
|
|
||||||
Docker bridge — `listen_addresses = '*'` plus a `pg_hba.conf` entry for `172.16.0.0/12`.
|
|
||||||
|
|
||||||
> **Stop the host `uvicorn` and `npm run dev` first.** Windows lets a host process bind
|
|
||||||
> `127.0.0.1:8000` while Docker binds `0.0.0.0:8000`, and `localhost` resolves to `::1`
|
|
||||||
> first — so both listen and requests silently reach whichever won. Same for 5173. Use
|
|
||||||
> `BACKEND_PORT` / `FRONTEND_PORT` / `ATS_PORT` if both must run.
|
|
||||||
|
|
||||||
`VITE_API_BASE` is inlined into the bundle at **build** time (default
|
|
||||||
`http://localhost:8000`), so changing the API origin means rebuilding the frontend
|
|
||||||
image, not restarting the container.
|
|
||||||
|
|
||||||
### The Postgres profile
|
|
||||||
|
|
||||||
The image is defined alongside everything else, but the `postgres` profile keeps it out
|
|
||||||
of `docker compose build` and `docker compose up` — bringing it up is always explicit:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile postgres build postgres
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||||
docker compose --profile postgres up -d postgres # host port 5433; 5432 is the host server's
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Pointing the app at it is a second, deliberate step: set `DB_HOST=postgres` (the only
|
### First run
|
||||||
value that changes — services reach it on 5432 over the compose network) and recreate
|
|
||||||
the services. Its volume starts empty, so Alembic rebuilds the schema on first boot; it
|
|
||||||
does not share the host server's data.
|
|
||||||
|
|
||||||
### Live-code overlay
|
|
||||||
|
|
||||||
[docker-compose.dev.yml](docker-compose.dev.yml) is not a second stack — it defines no
|
|
||||||
services or images, it only adds source bind mounts and `--reload` to the ones above:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. First run
|
|
||||||
|
|
||||||
1. Sign up / log in (`/auth/login`) — the user needs a role carrying
|
1. Sign up / log in (`/auth/login`) — the user needs a role carrying
|
||||||
`candidates.create` + `candidates.view` (RBAC screen or seed a role).
|
`candidates.create` + `candidates.view` (RBAC screen or seed a role).
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,6 @@
|
||||||
# Bulk ATS scoring engine — the standalone FastAPI service (CLAUDE.md is its spec).
|
# Bulk ATS scoring engine — the standalone FastAPI service (CLAUDE.md is its spec).
|
||||||
# Serves POST /api/v1/score, GET /api/v1/health and the card-grid test UI at /.
|
# Serves POST /api/v1/score, GET /api/v1/health and the card-grid test UI at /.
|
||||||
#
|
#
|
||||||
# The backend imports this same package as a library; this image is the separate
|
|
||||||
# service form of it, so it can be scaled, restarted or pointed at a different model
|
|
||||||
# independently of the portal API.
|
|
||||||
#
|
|
||||||
# THE BUILD CONTEXT IS THE REPO ROOT (pyproject.toml lives there):
|
# THE BUILD CONTEXT IS THE REPO ROOT (pyproject.toml lives there):
|
||||||
#
|
#
|
||||||
# docker build -f app/Dockerfile -t hrms-ats-engine:local .
|
# docker build -f app/Dockerfile -t hrms-ats-engine:local .
|
||||||
|
|
@ -20,16 +16,17 @@ ENV PYTHONUNBUFFERED=1 \
|
||||||
|
|
||||||
WORKDIR /srv
|
WORKDIR /srv
|
||||||
|
|
||||||
|
RUN groupadd --system app && useradd --system --gid app --home-dir /srv --shell /usr/sbin/nologin app
|
||||||
|
|
||||||
COPY pyproject.toml ./
|
COPY pyproject.toml ./
|
||||||
COPY app/ ./app/
|
COPY app/ ./app/
|
||||||
|
|
||||||
# Installs the pinned dependencies from pyproject.toml along with the package. The
|
# Installs the pinned dependencies from pyproject.toml along with the package.
|
||||||
# copy at /srv/app stays on sys.path ahead of the installed one, so the dev overlay's
|
RUN pip install --no-cache-dir . \
|
||||||
# source bind mount is what actually executes.
|
&& chown -R app:app /srv
|
||||||
RUN pip install --no-cache-dir .
|
|
||||||
|
USER app
|
||||||
|
|
||||||
EXPOSE 8100
|
EXPOSE 8100
|
||||||
|
|
||||||
# No module-level `app` object exists on purpose (app/main.py), so the factory form
|
|
||||||
# is mandatory here.
|
|
||||||
CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8100"]
|
CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8100"]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
#
|
#
|
||||||
# Backend image. The FastAPI API and all four Taskiq processes (inbox worker and
|
# Backend image. The FastAPI API and all Taskiq processes run from this one image;
|
||||||
# scheduler, CV worker and scheduler) run from this one image; docker-compose picks
|
# docker-compose picks the process with `command:`.
|
||||||
# the process with `command:`.
|
|
||||||
#
|
#
|
||||||
# THE BUILD CONTEXT IS THE REPO ROOT, not ./backend:
|
# THE BUILD CONTEXT IS THE REPO ROOT, not ./backend:
|
||||||
#
|
#
|
||||||
|
|
@ -22,21 +21,23 @@ ENV PYTHONUNBUFFERED=1 \
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN groupadd --system app && useradd --system --gid app --home-dir /app --shell /usr/sbin/nologin app
|
||||||
|
|
||||||
COPY backend/requirements.txt ./requirements.txt
|
COPY backend/requirements.txt ./requirements.txt
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Backend tree at /app; the scoring engine at /app/app so `import app.services.pdf`
|
# Backend tree at /app; the scoring engine at /app/app so `import app.services.pdf`
|
||||||
# resolves under PYTHONPATH=/app. requirements.txt says to `pip install -e ..` for
|
# resolves under PYTHONPATH=/app.
|
||||||
# this in a host environment — copying it in is the container equivalent, and its
|
|
||||||
# dependencies (openai, pypdf, pydantic-settings, python-multipart) are already pinned
|
|
||||||
# above.
|
|
||||||
COPY backend/ /app/
|
COPY backend/ /app/
|
||||||
COPY app/ /app/app/
|
COPY app/ /app/app/
|
||||||
|
|
||||||
# Decoded CV attachments are read and written here. docker-compose bind-mounts the
|
# Decoded CV attachments are read and written here. Compose mounts a named volume
|
||||||
# host folder over this path so every container shares one set of files; creating it
|
# (prod) or a host bind (dev) over this path; creating it in the image keeps an
|
||||||
# in the image keeps an un-mounted container from failing on first write.
|
# un-mounted container from failing on first write.
|
||||||
RUN mkdir -p /app/inbox/decoded_attachments
|
RUN mkdir -p /app/inbox/decoded_attachments \
|
||||||
|
&& chown -R app:app /app
|
||||||
|
|
||||||
|
USER app
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1046,19 +1046,23 @@ and a module called `alembic.py` would shadow the installed package.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
The repo-root `docker-compose.yml` runs Redis plus the four Taskiq processes (inbox
|
Production Compose is self-contained (Postgres in Docker; only the SPA is published).
|
||||||
worker/scheduler and CV-upload worker/scheduler); the API itself is expected to run on the
|
Local host-Postgres + reload uses the dev overlay. See repo-root **[DOCKER.md](../DOCKER.md)**.
|
||||||
host (the compose file points the containers at `host.docker.internal` for the database).
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d # from the repo root
|
# Production
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Local (host DB, published ports, --reload)
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||||
docker compose logs -f taskiq-worker
|
docker compose logs -f taskiq-worker
|
||||||
docker compose logs -f taskiq-cv-worker
|
docker compose logs -f taskiq-cv-worker
|
||||||
```
|
```
|
||||||
|
|
||||||
`backend/Dockerfile` builds a `python:3.12-slim` image whose default command is the Taskiq
|
`backend/Dockerfile` builds a `python:3.12-slim` image (non-root `app` user) used by
|
||||||
worker. `backend/inbox/decoded_attachments` is bind-mounted so the worker can read the
|
the API and every Taskiq process. In production, CV attachments live in the
|
||||||
attachments the API wrote.
|
`attachments-data` named volume; the dev overlay bind-mounts
|
||||||
|
`backend/inbox/decoded_attachments`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,9 @@ from pathlib import Path
|
||||||
from typing import Any, AsyncIterator, Callable, Sequence
|
from typing import Any, AsyncIterator, Callable, Sequence
|
||||||
|
|
||||||
from alembic import command
|
from alembic import command
|
||||||
from alembic.autogenerate import compare_metadata
|
from alembic.autogenerate import compare_metadata, produce_migrations
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
from alembic.operations import Operations
|
||||||
from alembic.runtime.migration import MigrationContext
|
from alembic.runtime.migration import MigrationContext
|
||||||
from alembic.script import ScriptDirectory
|
from alembic.script import ScriptDirectory
|
||||||
from sqlalchemy import MetaData, text
|
from sqlalchemy import MetaData, text
|
||||||
|
|
@ -197,7 +198,10 @@ def context_options() -> dict[str, Any]:
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
return {
|
return {
|
||||||
"compare_type": True,
|
"compare_type": True,
|
||||||
"compare_server_default": True,
|
# Server-default string forms differ between reflection and models
|
||||||
|
# (e.g. now() vs CURRENT_TIMESTAMP); comparing them re-applies the same
|
||||||
|
# ALTER on every boot when we sync filelessly.
|
||||||
|
"compare_server_default": False,
|
||||||
"include_schemas": bool(s.db_schemas),
|
"include_schemas": bool(s.db_schemas),
|
||||||
"version_table_schema": s.db_default_schema or None,
|
"version_table_schema": s.db_default_schema or None,
|
||||||
"include_object": _include_object,
|
"include_object": _include_object,
|
||||||
|
|
@ -233,18 +237,110 @@ async def current() -> str | None:
|
||||||
return await _run(lambda c: MigrationContext.configure(c, opts=opts).get_current_revision())
|
return await _run(lambda c: MigrationContext.configure(c, opts=opts).get_current_revision())
|
||||||
|
|
||||||
|
|
||||||
|
MODELS_STAMP = "models" # alembic_version marker when no revision files ship in the image
|
||||||
|
|
||||||
|
|
||||||
async def upgrade(revision: str = "head") -> None:
|
async def upgrade(revision: str = "head") -> None:
|
||||||
|
if revision == "head" and not head():
|
||||||
|
logger.info("no alembic revisions on disk; skipping upgrade")
|
||||||
|
return
|
||||||
await _run(lambda c: command.upgrade(config(c), revision))
|
await _run(lambda c: command.upgrade(config(c), revision))
|
||||||
logger.info("upgraded to %s", revision)
|
logger.info("upgraded to %s", revision)
|
||||||
|
|
||||||
|
|
||||||
|
async def stamp(revision: str = "head") -> None:
|
||||||
|
target = revision
|
||||||
|
if target == "head" and not head():
|
||||||
|
target = MODELS_STAMP
|
||||||
|
await _run(lambda c: command.stamp(config(c), target))
|
||||||
|
logger.info("stamped database at %s", target)
|
||||||
|
|
||||||
|
|
||||||
|
async def _schema_is_empty() -> bool:
|
||||||
|
"""True when the app schema has never been populated (fresh Compose volume)."""
|
||||||
|
schema = get_settings().db_default_schema or "public"
|
||||||
|
async with get_engine().connect() as conn:
|
||||||
|
row = (
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT 1 FROM information_schema.tables "
|
||||||
|
"WHERE table_schema = :schema AND table_name = 'users' LIMIT 1"
|
||||||
|
),
|
||||||
|
{"schema": schema},
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
return row is None
|
||||||
|
|
||||||
|
|
||||||
|
async def bootstrap_empty() -> None:
|
||||||
|
"""Create every model table and stamp a revision marker.
|
||||||
|
|
||||||
|
Several historical revisions assume tables (e.g. job_posts) that were never
|
||||||
|
given a create_table in the chain — they only exist on DBs that grew via
|
||||||
|
autogenerate. A brand-new Compose Postgres volume therefore cannot
|
||||||
|
`upgrade head`. Creating from metadata then stamping is the production
|
||||||
|
bootstrap for that case; existing databases keep the normal upgrade path.
|
||||||
|
|
||||||
|
Revision `.py` files are gitignored and excluded from images; stamp uses
|
||||||
|
`models` when the versions directory is empty.
|
||||||
|
"""
|
||||||
|
metadata = target_metadata()
|
||||||
|
async with get_engine().begin() as conn:
|
||||||
|
await conn.run_sync(metadata.create_all)
|
||||||
|
await stamp("head")
|
||||||
|
logger.info("bootstrapped empty database from models")
|
||||||
|
|
||||||
|
|
||||||
async def downgrade(revision: str = "-1") -> None:
|
async def downgrade(revision: str = "-1") -> None:
|
||||||
await _run(lambda c: command.downgrade(config(c), revision))
|
await _run(lambda c: command.downgrade(config(c), revision))
|
||||||
logger.info("downgraded to %s", revision)
|
logger.info("downgraded to %s", revision)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_upgrade_ops(connection: Connection) -> int:
|
||||||
|
"""Apply ORM→DB diffs in-process without writing a revision file."""
|
||||||
|
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
|
||||||
|
ctx = MigrationContext.configure(connection, opts=opts)
|
||||||
|
script = produce_migrations(ctx, target_metadata())
|
||||||
|
if script.upgrade_ops.is_empty():
|
||||||
|
return 0
|
||||||
|
operations = Operations(ctx)
|
||||||
|
applied = 0
|
||||||
|
stack = [script.upgrade_ops]
|
||||||
|
while stack:
|
||||||
|
elem = stack.pop(0)
|
||||||
|
if hasattr(elem, "ops"):
|
||||||
|
stack.extend(elem.ops)
|
||||||
|
else:
|
||||||
|
operations.invoke(elem)
|
||||||
|
applied += 1
|
||||||
|
return applied
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_model_drift() -> bool:
|
||||||
|
"""Sync the live schema to the ORM without creating migration files.
|
||||||
|
|
||||||
|
Used by Docker/prod boots so `versions/*.py` can stay gitignored and out of
|
||||||
|
the image. Returns True when at least one DDL op was applied.
|
||||||
|
"""
|
||||||
|
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
|
||||||
|
diffs = await _run(
|
||||||
|
lambda c: compare_metadata(MigrationContext.configure(c, opts=opts), target_metadata())
|
||||||
|
)
|
||||||
|
if not diffs:
|
||||||
|
logger.info("schema matches the models")
|
||||||
|
return False
|
||||||
|
logger.info("%s schema difference(s) detected; applying without revision files", len(diffs))
|
||||||
|
applied = await _run(_apply_upgrade_ops)
|
||||||
|
logger.info("applied %s schema operation(s)", applied)
|
||||||
|
return applied > 0
|
||||||
|
|
||||||
|
|
||||||
async def autogenerate(message: str = "auto") -> str | None:
|
async def autogenerate(message: str = "auto") -> str | None:
|
||||||
"""Write a revision if the models have drifted; return its id, or None."""
|
"""Write a revision if the models have drifted; return its id, or None.
|
||||||
|
|
||||||
|
Local/CLI only. Docker boots use `apply_model_drift` instead so revision
|
||||||
|
files are never written on the server.
|
||||||
|
"""
|
||||||
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
|
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
|
||||||
diffs = await _run(
|
diffs = await _run(
|
||||||
lambda c: compare_metadata(MigrationContext.configure(c, opts=opts), target_metadata())
|
lambda c: compare_metadata(MigrationContext.configure(c, opts=opts), target_metadata())
|
||||||
|
|
@ -308,12 +404,21 @@ async def _lock() -> AsyncIterator[None]:
|
||||||
|
|
||||||
|
|
||||||
async def migrate(*, autogen: bool | None = None, message: str = "auto") -> None:
|
async def migrate(*, autogen: bool | None = None, message: str = "auto") -> None:
|
||||||
"""Apply pending revisions, fresh model drift, then manual SQL, under the lock."""
|
"""Bring the DB in line with models, then run manual SQL, under the lock.
|
||||||
|
|
||||||
|
Empty Compose volumes bootstrap from models (create_all + stamp). Every boot
|
||||||
|
upgrades any on-disk revisions (skipped when versions are absent from the
|
||||||
|
image), then — when DB_AUTOGENERATE is on — applies ORM drift in-memory so
|
||||||
|
no `versions/*.py` files are written on the server.
|
||||||
|
"""
|
||||||
should_autogen = get_settings().db_autogenerate if autogen is None else autogen
|
should_autogen = get_settings().db_autogenerate if autogen is None else autogen
|
||||||
async with _lock():
|
async with _lock():
|
||||||
|
if await _schema_is_empty():
|
||||||
|
await bootstrap_empty()
|
||||||
|
else:
|
||||||
await upgrade()
|
await upgrade()
|
||||||
if should_autogen and await autogenerate(message):
|
if should_autogen:
|
||||||
await upgrade()
|
await apply_model_drift()
|
||||||
await run_manual_sql()
|
await run_manual_sql()
|
||||||
logger.info("database at revision %s", await current())
|
logger.info("database at revision %s", await current())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"account": "",
|
||||||
|
"client_id": "679334897177-ufal3rogbg8cgm3qcren6pv20phqd7nl.apps.googleusercontent.com",
|
||||||
|
"client_secret": "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",
|
||||||
|
"refresh_token": "1//038IfgCu3D42fCgYIARAAGAMSNwF-L9IrYAZ_DJUqwC9ETwLtH23D46j61gWMwFQjRWPklFZIiLmv7Q3-TOgcNxTrjO30jgkeYOo",
|
||||||
|
"type": "authorized_user",
|
||||||
|
"universe_domain": "googleapis.com"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"installed":{"client_id":"679334897177-ufal3rogbg8cgm3qcren6pv20phqd7nl.apps.googleusercontent.com","project_id":"hrms-ats-portal","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["http://localhost"]}}
|
||||||
|
|
@ -3,6 +3,7 @@ from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from db_setup import lifespan as db_lifespan
|
from db_setup import lifespan as db_lifespan
|
||||||
from inbox.app import router as inbox_router
|
from inbox.app import router as inbox_router
|
||||||
from users.app import router as users_router
|
from users.app import router as users_router
|
||||||
|
|
@ -88,6 +89,13 @@ app.add_middleware(
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
# Liveness only — no DB. Compose healthchecks and load balancers hit this.
|
||||||
|
return JSONResponse(content={"status":"ok","status_code":200})
|
||||||
|
|
||||||
|
|
||||||
app.include_router(inbox_router)
|
app.include_router(inbox_router)
|
||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
app.include_router(role_router)
|
app.include_router(role_router)
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,49 @@
|
||||||
# Live-code overlay. Mounts the source folders into the running containers, so what
|
# Local / host-Postgres overlay. Restores the previous day-to-day workflow on top
|
||||||
# executes is what is on disk on the host — edit, save, uvicorn reloads.
|
# of the production docker-compose.yml:
|
||||||
#
|
#
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||||
#
|
#
|
||||||
# The CV attachments mount from the base file still applies: compose merges volumes
|
# - DB_HOST=host.docker.internal (host Postgres; container postgres still starts
|
||||||
# by target path, and /app/inbox/decoded_attachments is nested under the /app mount,
|
# but is unused unless you flip DB_HOST back)
|
||||||
# so the daemon mounts the parent first and the attachments folder on top.
|
# - Publishes API / ATS / Redis / frontend ports for direct host access
|
||||||
|
# - Bind-mounts CV attachments to ./backend/inbox/decoded_attachments
|
||||||
|
# - Live source mounts + --reload on API / ATS / workers
|
||||||
#
|
#
|
||||||
# Two mounts per Python service, not one: /app is the backend tree and /app/app is the
|
# Frontend stays as the built nginx image — run `npm run dev` on the host for the
|
||||||
# bulk-ats engine that backend/job/candidate imports. Mounting only ./backend over
|
# Vite loop if you need HMR.
|
||||||
# /app would hide the engine baked into the image and every worker would fail on
|
|
||||||
# import.
|
|
||||||
#
|
|
||||||
# The frontend stays as the built nginx image — a Vite dev server wants node_modules
|
|
||||||
# on the mount, which is slow and fragile across a Windows bind mount. Run
|
|
||||||
# `npm run dev` on the host for the frontend loop.
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
redis:
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432"
|
||||||
|
|
||||||
backend-api:
|
backend-api:
|
||||||
|
environment:
|
||||||
|
PYTHONPATH: /app
|
||||||
|
DB_HOST: host.docker.internal
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
|
||||||
|
BACKEND_URL: http://backend-api:8000
|
||||||
command:
|
command:
|
||||||
["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
[
|
||||||
|
"uvicorn",
|
||||||
|
"main:app",
|
||||||
|
"--host",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--port",
|
||||||
|
"8000",
|
||||||
|
"--reload",
|
||||||
|
]
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
|
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
|
||||||
|
|
||||||
ats-engine:
|
ats-engine:
|
||||||
command:
|
command:
|
||||||
|
|
@ -36,30 +57,77 @@ services:
|
||||||
"8100",
|
"8100",
|
||||||
"--reload",
|
"--reload",
|
||||||
]
|
]
|
||||||
|
ports:
|
||||||
|
- "${ATS_PORT:-8100}:8100"
|
||||||
volumes:
|
volumes:
|
||||||
- ./app:/srv/app
|
- ./app:/srv/app
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-5173}:80"
|
||||||
|
|
||||||
taskiq-worker:
|
taskiq-worker:
|
||||||
|
environment:
|
||||||
|
PYTHONPATH: /app
|
||||||
|
DB_HOST: host.docker.internal
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
|
||||||
|
BACKEND_URL: http://backend-api:8000
|
||||||
|
TASKIQ_QUEUE_NAME: inbox
|
||||||
|
TASKIQ_WORKER_NAME: worker-01
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
|
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
|
||||||
|
|
||||||
taskiq-scheduler:
|
taskiq-scheduler:
|
||||||
|
environment:
|
||||||
|
PYTHONPATH: /app
|
||||||
|
DB_HOST: host.docker.internal
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
|
||||||
|
BACKEND_URL: http://backend-api:8000
|
||||||
|
TASKIQ_QUEUE_NAME: inbox
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
|
|
||||||
taskiq-cv-worker:
|
taskiq-cv-worker:
|
||||||
|
environment:
|
||||||
|
PYTHONPATH: /app
|
||||||
|
DB_HOST: host.docker.internal
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
|
||||||
|
BACKEND_URL: http://backend-api:8000
|
||||||
|
TASKIQ_CV_QUEUE_NAME: cv_upload
|
||||||
|
TASKIQ_WORKER_NAME: cv-worker-01
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
|
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
|
||||||
|
|
||||||
taskiq-cv-scheduler:
|
taskiq-cv-scheduler:
|
||||||
|
environment:
|
||||||
|
PYTHONPATH: /app
|
||||||
|
DB_HOST: host.docker.internal
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
|
||||||
|
BACKEND_URL: http://backend-api:8000
|
||||||
|
TASKIQ_CV_QUEUE_NAME: cv_upload
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
|
|
||||||
taskiq-mailbox-sync-worker:
|
taskiq-mailbox-sync-worker:
|
||||||
|
environment:
|
||||||
|
PYTHONPATH: /app
|
||||||
|
DB_HOST: host.docker.internal
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
|
||||||
|
BACKEND_URL: http://backend-api:8000
|
||||||
|
TASKIQ_MAILBOX_SYNC_QUEUE_NAME: mailbox_sync
|
||||||
|
TASKIQ_WORKER_NAME: mailbox-sync-worker-01
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
|
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,23 @@
|
||||||
# HR-ATS-Portal — every service and every image, in one file.
|
# HR-ATS-Portal — production Compose stack (self-contained).
|
||||||
#
|
#
|
||||||
# docker compose build # hrms-backend / hrms-ats-engine / hrms-frontend
|
# docker compose up -d --build
|
||||||
# docker compose up -d
|
|
||||||
# docker compose ps
|
# docker compose ps
|
||||||
# docker compose logs -f backend-api
|
# docker compose logs -f backend-api
|
||||||
#
|
#
|
||||||
# ── Postgres ──────────────────────────────────────────────────────────────────────
|
# Public surface: only the frontend (default host port 80). The browser talks
|
||||||
# The `postgres` service at the bottom is behind a compose PROFILE, so it is defined
|
# same-origin to nginx, which proxies API paths to backend-api. Postgres, Redis,
|
||||||
# and buildable here but never starts with a plain `docker compose up`. The stack
|
# the API, ATS engine and Taskiq workers stay on the Compose network.
|
||||||
# talks to the PostgreSQL server already running on the host, via
|
|
||||||
# DB_HOST=host.docker.internal (backend/.env says localhost — correct for a host
|
|
||||||
# process, wrong inside a container).
|
|
||||||
#
|
#
|
||||||
# Host Postgres must accept connections from the Docker bridge: listen_addresses = '*'
|
# Local / current host-Postgres workflow (exposed ports, --reload, bind mounts):
|
||||||
# in postgresql.conf and a pg_hba.conf line for 172.16.0.0/12 (or the specific subnet).
|
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||||
#
|
#
|
||||||
# To build/run the containerised database instead, see the comments on that service.
|
# See DOCKER.md for env checklist and verification.
|
||||||
#
|
|
||||||
# ── Ports ─────────────────────────────────────────────────────────────────────────
|
x-logging: &default-logging
|
||||||
# Stop a host `uvicorn` (8000) and `npm run dev` (5173) before starting these, or
|
driver: json-file
|
||||||
# override with BACKEND_PORT / FRONTEND_PORT / ATS_PORT. Windows lets a host process
|
options:
|
||||||
# bind 127.0.0.1:8000 while Docker binds 0.0.0.0:8000, and `localhost` resolves to ::1
|
max-size: "10m"
|
||||||
# first — both listen, and requests reach whichever won.
|
max-file: "3"
|
||||||
#
|
|
||||||
# ── Overlay ───────────────────────────────────────────────────────────────────────
|
|
||||||
# docker-compose.dev.yml adds live source mounts and --reload on top of this file. It
|
|
||||||
# defines no services or images of its own; it only overrides the ones here:
|
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
|
||||||
|
|
||||||
x-backend-build: &backend-build
|
x-backend-build: &backend-build
|
||||||
# Root context, not ./backend: backend/job/candidate imports the bulk-ats engine
|
# Root context, not ./backend: backend/job/candidate imports the bulk-ats engine
|
||||||
|
|
@ -36,26 +27,28 @@ x-backend-build: &backend-build
|
||||||
|
|
||||||
x-backend-env: &backend-env
|
x-backend-env: &backend-env
|
||||||
PYTHONPATH: /app
|
PYTHONPATH: /app
|
||||||
# backend/.env is written for host processes; these are the values a container needs.
|
# Production default: container Postgres on the Compose network. The dev overlay
|
||||||
#
|
# overrides DB_HOST to host.docker.internal. Credentials come from root .env so
|
||||||
# DB_HOST defaults to the LOCAL Postgres server on the host. Set DB_HOST=postgres in
|
# they stay in lockstep with the postgres service (backend/.env is for host runs).
|
||||||
# the shell (or a root .env) to point the whole stack at the container below instead
|
DB_HOST: ${DB_HOST:-postgres}
|
||||||
# — that is the only value that has to change, since services reach it over the
|
DB_PORT: ${DB_PORT:-5432}
|
||||||
# compose network on 5432, not the published host port.
|
DB_USERNAME: ${DB_USERNAME:-postgres}
|
||||||
DB_HOST: ${DB_HOST:-host.docker.internal}
|
DB_PASSWORD: ${DB_PASSWORD:-postgres}
|
||||||
|
DB_NAME: ${DB_NAME:-hrms}
|
||||||
|
# On every API boot: upgrade any on-disk revisions (usually none in images),
|
||||||
|
# then apply ORM drift in-memory (no revision files written — versions stay
|
||||||
|
# gitignored and out of the image).
|
||||||
|
DB_AUTO_MIGRATE: ${DB_AUTO_MIGRATE:-true}
|
||||||
|
DB_AUTOGENERATE: ${DB_AUTOGENERATE:-true}
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
# Prefer root/.env EMAIL_URL (e.g. http://3.140.173.13:5000). Fall back to the
|
# Prefer root/.env EMAIL_URL. Fall back to host-gateway when mail is on this machine.
|
||||||
# host-gateway alias when the mail service runs on this machine's :5000.
|
|
||||||
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
|
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
|
||||||
BACKEND_URL: http://backend-api:8000
|
BACKEND_URL: http://backend-api:8000
|
||||||
|
|
||||||
# The one shared folder. Every process that decodes, scores or serves a CV reads and
|
# Shared CV storage. Named volume in production so API + workers see the same
|
||||||
# writes the same host directory, so a file written by the API is the same file the
|
# files without a host path. Dev overlay remounts ./backend/inbox/decoded_attachments.
|
||||||
# worker opens. Absolute paths stored in the DB match across services because the
|
|
||||||
# mount target is identical everywhere; inbox.plugins.resolve_attachment_path also
|
|
||||||
# falls back to basename-under-this-directory for rows written by a host process.
|
|
||||||
x-attachments: &attachments
|
x-attachments: &attachments
|
||||||
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
|
- attachments-data:/app/inbox/decoded_attachments
|
||||||
|
|
||||||
x-backend-service: &backend-service
|
x-backend-service: &backend-service
|
||||||
build: *backend-build
|
build: *backend-build
|
||||||
|
|
@ -69,13 +62,10 @@ x-backend-service: &backend-service
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
# required:false — the default stack uses the host's Postgres and never starts
|
|
||||||
# this one, and that must not be an error. When the profile IS active, startup
|
|
||||||
# waits for it to pass pg_isready.
|
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
required: false
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# --- Redis (broker + result backend for taskiq) ----------------------------------
|
# --- Redis (broker + result backend for taskiq) ----------------------------------
|
||||||
|
|
@ -83,8 +73,7 @@ services:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: hrms-redis
|
container_name: hrms-redis
|
||||||
command: ["redis-server", "--appendonly", "yes"]
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
ports:
|
# Not published in production. Dev overlay binds ${REDIS_PORT:-6379}:6379.
|
||||||
- "${REDIS_PORT:-6379}:6379"
|
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
@ -93,24 +82,59 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
|
# --- Postgres (always on in production) ------------------------------------------
|
||||||
|
postgres:
|
||||||
|
build:
|
||||||
|
context: ./docker/postgres
|
||||||
|
image: hrms-postgres:local
|
||||||
|
container_name: hrms-postgres
|
||||||
|
environment:
|
||||||
|
# Interpolated from the shell or root .env, NOT from backend/.env.
|
||||||
|
POSTGRES_USER: ${DB_USERNAME:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
|
||||||
|
POSTGRES_DB: ${DB_NAME:-hrms}
|
||||||
|
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
|
||||||
|
# Not published in production. Dev overlay can bind 127.0.0.1:${POSTGRES_PORT:-5433}:5432.
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_NAME:-hrms}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
# --- portal API ------------------------------------------------------------------
|
# --- portal API ------------------------------------------------------------------
|
||||||
backend-api:
|
backend-api:
|
||||||
<<: *backend-service
|
<<: *backend-service
|
||||||
container_name: hrms-backend-api
|
container_name: hrms-backend-api
|
||||||
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
command:
|
||||||
ports:
|
[
|
||||||
- "${BACKEND_PORT:-8000}:8000"
|
"uvicorn",
|
||||||
|
"main:app",
|
||||||
|
"--host",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--port",
|
||||||
|
"8000",
|
||||||
|
"--workers",
|
||||||
|
"${UVICORN_WORKERS:-2}",
|
||||||
|
"--proxy-headers",
|
||||||
|
"--forwarded-allow-ips=*",
|
||||||
|
]
|
||||||
|
# Not published in production — browsers reach the API via frontend nginx.
|
||||||
volumes: *attachments
|
volumes: *attachments
|
||||||
healthcheck:
|
healthcheck:
|
||||||
# python:slim has no curl and the API exposes no /health route, so this is a
|
|
||||||
# plain TCP check against the uvicorn socket.
|
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
"CMD",
|
"CMD",
|
||||||
"python",
|
"python",
|
||||||
"-c",
|
"-c",
|
||||||
"import socket;socket.create_connection(('127.0.0.1',8000),3).close()",
|
"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health',timeout=3)",
|
||||||
]
|
]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
|
|
@ -125,13 +149,10 @@ services:
|
||||||
image: hrms-ats-engine:local
|
image: hrms-ats-engine:local
|
||||||
container_name: hrms-ats-engine
|
container_name: hrms-ats-engine
|
||||||
env_file:
|
env_file:
|
||||||
# OPENAI_API_KEY currently lives in backend/.env; a root .env (see .env.example)
|
|
||||||
# overrides it when present, and the stack still comes up when it is not.
|
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
- path: ./.env
|
- path: ./.env
|
||||||
required: false
|
required: false
|
||||||
ports:
|
# Not published in production.
|
||||||
- "${ATS_PORT:-8100}:8100"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
|
|
@ -145,15 +166,14 @@ services:
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
# --- React portal -----------------------------------------------------------------
|
# --- React portal (only published host port) -------------------------------------
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
args:
|
args:
|
||||||
# Empty = same-origin; nginx proxies API paths to backend-api (see nginx.conf).
|
# Empty = same-origin; nginx proxies API paths to backend-api (see nginx.conf).
|
||||||
# A baked http://localhost:8000 / LAN IP sends the browser to a *different*
|
|
||||||
# listener than the SPA (Cursor steals 127.0.0.1:8000) and empties /jobs.
|
|
||||||
VITE_API_BASE: ${VITE_API_BASE:-}
|
VITE_API_BASE: ${VITE_API_BASE:-}
|
||||||
image: hrms-frontend:local
|
image: hrms-frontend:local
|
||||||
container_name: hrms-frontend
|
container_name: hrms-frontend
|
||||||
|
|
@ -161,13 +181,14 @@ services:
|
||||||
backend-api:
|
backend-api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-5173}:80"
|
- "${FRONTEND_PORT:-80}:80"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
|
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
# --- background processing (same image as backend-api, different command) ---------
|
# --- background processing (same image as backend-api, different command) ---------
|
||||||
taskiq-worker:
|
taskiq-worker:
|
||||||
|
|
@ -255,45 +276,7 @@ services:
|
||||||
TASKIQ_WORKER_NAME: mailbox-sync-worker-01
|
TASKIQ_WORKER_NAME: mailbox-sync-worker-01
|
||||||
volumes: *attachments
|
volumes: *attachments
|
||||||
|
|
||||||
# --- Postgres: DEFINED HERE, NOT STARTED BY DEFAULT -------------------------------
|
|
||||||
# The profile is what keeps it out of `docker compose build` and `docker compose up`.
|
|
||||||
# Nothing about the default stack changes by its presence in this file.
|
|
||||||
#
|
|
||||||
# docker compose --profile postgres build postgres # build the image
|
|
||||||
# docker compose --profile postgres up -d postgres # run it, host port 5433
|
|
||||||
#
|
|
||||||
# Pointing the app at it is a separate, deliberate step — set DB_HOST=postgres (see
|
|
||||||
# x-backend-env) and recreate the services. The volume starts empty, so Alembic
|
|
||||||
# rebuilds the schema on first boot; it does not share the host server's data.
|
|
||||||
postgres:
|
|
||||||
profiles: ["postgres"]
|
|
||||||
build:
|
|
||||||
context: ./docker/postgres
|
|
||||||
image: hrms-postgres:local
|
|
||||||
container_name: hrms-postgres
|
|
||||||
environment:
|
|
||||||
# Interpolated from the shell or a root .env, NOT from backend/.env — compose
|
|
||||||
# variable substitution and container environment are different things.
|
|
||||||
# Defaults match backend/.env.example.
|
|
||||||
POSTGRES_USER: ${DB_USERNAME:-postgres}
|
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
|
|
||||||
POSTGRES_DB: ${DB_NAME:-hrms}
|
|
||||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
|
|
||||||
ports:
|
|
||||||
# 5433: the host's own Postgres server owns 5432. Only for host-side tools —
|
|
||||||
# containers reach this one on 5432 over the compose network.
|
|
||||||
- "${POSTGRES_PORT:-5433}:5432"
|
|
||||||
volumes:
|
|
||||||
- postgres-data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_NAME:-hrms}"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 20s
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
redis-data:
|
redis-data:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
attachments-data:
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,8 @@
|
||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
#
|
#
|
||||||
# Postgres image — BUILT, BUT NOT USED BY THE DEFAULT STACK.
|
# Postgres image used by the production Compose stack (DB_HOST=postgres).
|
||||||
#
|
# The volume starts empty on first boot; Alembic rebuilds the schema. It does
|
||||||
# Every service in docker-compose.yml points at the Postgres already running on the
|
# not share the host server's data — migrate with pg_dump/pg_restore if needed.
|
||||||
# host (DB_HOST=host.docker.internal). This image exists so the database can be
|
|
||||||
# containerised on demand — a clean machine, a throwaway test run, a second dev — and
|
|
||||||
# it is kept in its own file (docker-compose.postgres.yml) so bringing it up is always
|
|
||||||
# a deliberate act:
|
|
||||||
#
|
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.postgres.yml build postgres
|
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d postgres
|
|
||||||
#
|
#
|
||||||
# Context is ./docker/postgres.
|
# Context is ./docker/postgres.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,20 @@ server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# CV / multipart uploads (MAX_PDF_SIZE_MB is 10; leave headroom for form fields).
|
||||||
|
client_max_body_size 25m;
|
||||||
|
|
||||||
|
# Security headers on every response.
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-Frame-Options DENY always;
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
|
|
||||||
# Same-origin API proxy. The SPA is built with an empty VITE_API_BASE so
|
# Same-origin API proxy. The SPA is built with an empty VITE_API_BASE so
|
||||||
# fetch('/jobs/fetch') stays on this host; without this block nginx would
|
# fetch('/jobs/fetch') stays on this host; without this block nginx would
|
||||||
# serve index.html for those paths (200 HTML) and the Jobs board would
|
# serve index.html for those paths (200 HTML) and the Jobs board would
|
||||||
# parse an empty payload. backend-api is the compose service name.
|
# parse an empty payload. OpenAPI (/docs, /redoc, /openapi.json) is
|
||||||
location ~ ^/(users|roles|permissions|permission-tags|managers|inbox|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search|docs|openapi\.json|redoc)(/|$) {
|
# intentionally NOT proxied — keep the schema off the public edge.
|
||||||
|
location ~ ^/(health|users|roles|permissions|permission-tags|managers|inbox|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search)(/|$) {
|
||||||
proxy_pass http://backend-api:8000;
|
proxy_pass http://backend-api:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|
@ -17,11 +26,12 @@ server {
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header Authorization $http_authorization;
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# One SPA at `/`. `vite dev` and `vite preview` serve index.html for every
|
# One SPA at `/`. Without this, /auth/confirm-email (a router path, not a
|
||||||
# unmatched path; vite.config.js notes that a static deploy needs the equivalent
|
|
||||||
# rewrite rule. This is it — without it /auth/confirm-email (a router path, not a
|
|
||||||
# file) 404s when a confirmation email link is opened cold.
|
# file) 404s when a confirmation email link is opened cold.
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|
@ -31,11 +41,17 @@ server {
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-Frame-Options DENY always;
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
}
|
}
|
||||||
|
|
||||||
# index.html must never be cached, or a redeploy keeps serving the old asset hashes.
|
# index.html must never be cached, or a redeploy keeps serving the old asset hashes.
|
||||||
location = /index.html {
|
location = /index.html {
|
||||||
add_header Cache-Control "no-store";
|
add_header Cache-Control "no-store";
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-Frame-Options DENY always;
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
}
|
}
|
||||||
|
|
||||||
gzip on;
|
gzip on;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue