Skip to content

Commit 60b08c3

Browse files
authored
[codex] Harden deployment config startup (#347)
* Harden deployment config startup * Limit docker smoke workflow token permissions * Allow on-demand docker smoke checks * Address deployment startup review feedback * Enforce production resume scan before parsing * Add ClamAV sidecar resume scanning * Keep ClamAV from gating Compose startup * Clarify optional ClamAV startup behavior * Avoid suppressing ClamAV compose defaults * Fix health and scanner command defaults
1 parent 80f1c8e commit 60b08c3

20 files changed

Lines changed: 639 additions & 97 deletions

File tree

.env.example

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,10 @@ ONBOARDING_TALLY_WEBHOOK_SIGNING_SECRET=
247247
ONBOARDING_TALLY_ALLOWED_FORM_IDS=
248248
# Resume files are untrusted. Keep scanning required in production and configure
249249
# a scanner command; use "{path}" where the downloaded resume path should go.
250+
# Compose deployments provide a ClamAV sidecar command by default. Uncomment
251+
# and set this only when intentionally overriding or disabling that command.
250252
INTAKE_RESUME_REQUIRE_VIRUS_SCAN=false
251-
INTAKE_RESUME_VIRUS_SCAN_COMMAND=
253+
# INTAKE_RESUME_VIRUS_SCAN_COMMAND=
252254
INTAKE_RESUME_VIRUS_SCAN_TIMEOUT_SECONDS=30.0
253255
EMAIL_RESUME_INTAKE_ENABLED=false
254256
EMAIL_RESUME_ALLOWED_EXTENSIONS=pdf,doc,docx
@@ -271,7 +273,7 @@ EMAIL_REQUIRE_SENDER_AUTH_HEADERS=true
271273
# KEILA_API_KEY=
272274
# KEILA_API_BASE_URL=https://app.keila.io
273275
# KEILA_API_TIMEOUT_SECONDS=20.0
274-
# NEWSLETTER_SYNC_ENABLED=true
276+
# NEWSLETTER_SYNC_ENABLED=false
275277
# NEWSLETTER_SYNC_INTERVAL_SECONDS=604800
276278
# NEWSLETTER_SYNC_EXCLUDED_MAILBOXES=system,service-account
277279

.github/workflows/docker-smoke.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: Docker Smoke
2+
3+
on:
4+
merge_group:
5+
pull_request:
6+
types: [labeled, synchronize]
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
docker-smoke:
14+
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'docker-smoke')
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v6
18+
19+
- name: Run Docker smoke check
20+
run: ./scripts/docker-smoke.sh

DEVELOPMENT.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ Use Compose when you need container parity with deployment:
142142
./scripts/docker-compose.sh up --build
143143
./scripts/docker-compose.sh down
144144
./scripts/docker-compose.sh print-ports
145+
./scripts/docker-smoke.sh
145146
```
146147

147148
`compose.yaml` is the canonical Coolify/base stack. `compose.local.yaml` adds
@@ -163,6 +164,10 @@ docker network create 508-infra
163164
BuildKit-capable Docker / `docker compose build` support is required because the
164165
service Dockerfiles use BuildKit cache mounts.
165166

167+
Use `./scripts/docker-smoke.sh` when you need a deployment-style startup check.
168+
It builds the API image, starts an isolated Redis/Postgres/web stack with only
169+
the core env vars, and verifies `GET /health` reaches a healthy response.
170+
166171
## Testing And Quality
167172

168173
```bash

ENVIRONMENT.md

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,21 @@ Postgres `+1`, Compose web `+2`, MinIO API `+3`, MinIO console `+4`, host-run
88
web/API `+5`, and bot health `+6`. Explicit service port overrides keep their
99
current precedence rules.
1010

11-
## Required
11+
## Required For A Healthy Non-Local Runtime
1212

13-
- `ESPO_BASE_URL`
14-
- `ESPO_API_KEY`
1513
- `API_SHARED_SECRET` (required for protected endpoints)
16-
- `MINIO_ROOT_PASSWORD` (required in non-local environments)
14+
- `POSTGRES_URL` (required for database-backed API and worker health)
15+
- `MINIO_ROOT_PASSWORD` (required for internal transfer storage)
1716
- `DISCORD_BOT_TOKEN` (Discord bot runtime)
1817

18+
The app avoids eager settings-construction failures where possible so failed
19+
deployments can still expose logs and health responses. Missing runtime
20+
dependencies should surface as degraded health or route/job failures rather than
21+
Pydantic import errors.
22+
1923
## Core Runtime (Bot + Worker)
2024

21-
- `Optional` (non-local): `ENVIRONMENT` (default: `local`; non-local environments must set explicit `POSTGRES_URL` and `MINIO_ROOT_PASSWORD`)
25+
- `Optional` (non-local): `ENVIRONMENT` (default: `local`; non-local environments should set explicit `POSTGRES_URL` and `MINIO_ROOT_PASSWORD`)
2226
- `Optional`: `SENTRY_DSN` (default: unset; set to enable Sentry event capture)
2327
- `Optional`: `SENTRY_SEND_DEFAULT_PII` (default: `false`)
2428
- `Optional`: `SENTRY_DEBUG` (default: `false`)
@@ -40,7 +44,7 @@ current precedence rules.
4044

4145
## Postgres + Compose Exposure
4246

43-
- `Optional`: `POSTGRES_URL` (default: `postgresql://postgres:postgres@127.0.0.1:5432/workflows`; `./scripts/dev.sh` overrides it to a deterministic per-worktree localhost port, Compose injects a Docker-network URL)
47+
- `Required for healthy non-local runtime`: `POSTGRES_URL` (local default: `postgresql://postgres:postgres@127.0.0.1:5432/workflows`; `./scripts/dev.sh` overrides it to a deterministic per-worktree localhost port, Compose injects a Docker-network URL)
4448
- `Optional` (Compose DB container): `POSTGRES_DB` (default: `workflows`)
4549
- `Optional` (Compose DB container): `POSTGRES_USER` (default: `postgres`)
4650
- `Optional` (Compose DB container): `POSTGRES_PASSWORD` (default: `postgres`)
@@ -106,7 +110,8 @@ current precedence rules.
106110

107111
## Worker CRM Sync + Skills Extraction
108112

109-
- `Optional`: `CRM_SYNC_ENABLED` (default: `true`)
113+
- `Optional`: `CRM_SYNC_ENABLED` (default: `true`; scheduler starts only when `ESPO_BASE_URL` and `ESPO_API_KEY` are configured)
114+
- `Required for CRM-backed jobs and sync`: `ESPO_BASE_URL`, `ESPO_API_KEY`
110115
- `Optional`: `CRM_SYNC_INTERVAL_SECONDS` (default: `900`)
111116
- `Optional`: `CRM_SYNC_PAGE_SIZE` (default: `200`)
112117
- `Optional`: `CHECK_EMAIL_WAIT` (default: `2`; minutes between mailbox polls)
@@ -127,6 +132,9 @@ current precedence rules.
127132
- `Optional`: `INTAKE_RESUME_FETCH_TIMEOUT_SECONDS` (default: `20.0`; timeout for intake resume URL downloads)
128133
- `Optional`: `INTAKE_RESUME_MAX_REDIRECTS` (default: `3`; max redirects followed for intake resume URL downloads)
129134
- `Optional`: `INTAKE_RESUME_ALLOWED_HOSTS` (default: empty; optional comma-separated host allowlist for intake resume URL downloads)
135+
- `Optional`: `INTAKE_RESUME_REQUIRE_VIRUS_SCAN` (default: `false` locally; resume parsing requires scanning automatically outside local/dev/test)
136+
- `Required for non-local resume parsing`: `INTAKE_RESUME_VIRUS_SCAN_COMMAND` (Compose default: `clamdscan --stream --no-summary --config-file=/etc/clamav/clamdscan.conf {path}` against the ClamAV sidecar)
137+
- `Optional`: `INTAKE_RESUME_VIRUS_SCAN_TIMEOUT_SECONDS` (default: `30.0`; timeout for the configured scan command)
130138
- `Optional`: `EMAIL_RESUME_INTAKE_ENABLED` (default: `false`; enables worker-side mailbox resume processing loop)
131139
- `Optional`: `EMAIL_RESUME_ALLOWED_EXTENSIONS` (default: `pdf,doc,docx`)
132140
- `Optional`: `EMAIL_RESUME_MAX_FILE_SIZE_MB` (default: `10`)
@@ -182,7 +190,7 @@ current precedence rules.
182190
- `Optional for Keila contact sync`: `KEILA_API_KEY`
183191
- `Optional`: `KEILA_API_BASE_URL` (default: `https://app.keila.io`)
184192
- `Optional`: `KEILA_API_TIMEOUT_SECONDS` (default: `20.0`)
185-
- `Optional`: `NEWSLETTER_SYNC_ENABLED` (default: `true`)
193+
- `Optional`: `NEWSLETTER_SYNC_ENABLED` (default: `false`)
186194
- `Optional`: `NEWSLETTER_SYNC_INTERVAL_SECONDS` (default: `604800`, one week)
187195
- `Optional`: `NEWSLETTER_SYNC_EXCLUDED_MAILBOXES` (comma-separated mailbox local-parts or full addresses to skip during Migadu resync)
188196
- Note: mailbox and backup email subscription to configured newsletter tools is best effort. Failures are reported as warnings and do not block mailbox or account creation.

apps/api/Dockerfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ COPY --from=uv /uv /uvx /usr/local/bin/
77
WORKDIR /app
88
ENV UV_LINK_MODE=copy
99

10+
RUN apt-get update \
11+
&& apt-get install -y --no-install-recommends clamdscan \
12+
&& rm -rf /var/lib/apt/lists/* \
13+
&& printf 'TCPSocket 3310\nTCPAddr clamav\n' >/etc/clamav/clamdscan.conf
14+
1015
COPY pyproject.toml uv.lock ./
1116
COPY apps/discord_bot/pyproject.toml apps/discord_bot/pyproject.toml
1217
COPY apps/worker/pyproject.toml apps/worker/pyproject.toml

apps/api/src/five08/backend/api.py

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -971,6 +971,18 @@ async def _crm_sync_scheduler(app: FastAPI) -> None:
971971
await asyncio.sleep(interval_seconds)
972972

973973

974+
def _should_start_crm_sync_scheduler() -> bool:
975+
return _crm_sync_scheduler_skip_reason() is None
976+
977+
978+
def _crm_sync_scheduler_skip_reason() -> str | None:
979+
if not settings.crm_sync_enabled:
980+
return "disabled"
981+
if settings.espo_configured:
982+
return None
983+
return "missing_espo"
984+
985+
974986
async def _newsletter_sync_scheduler(app: FastAPI) -> None:
975987
queue = app.state.queue
976988
interval_seconds = max(60, settings.newsletter_sync_interval_seconds)
@@ -1015,7 +1027,9 @@ async def _email_resume_scheduler() -> None:
10151027
await asyncio.sleep(interval_seconds)
10161028

10171029

1018-
def _check_postgres_connection(connection: Connection) -> bool:
1030+
def _check_postgres_connection(connection: Connection | None) -> bool:
1031+
if connection is None:
1032+
return False
10191033
try:
10201034
with connection.cursor() as cursor:
10211035
cursor.execute("SELECT 1")
@@ -1032,8 +1046,9 @@ async def _is_postgres_connection_healthy(app: FastAPI) -> bool:
10321046
if healthy:
10331047
return True
10341048

1035-
with contextlib.suppress(Exception):
1036-
await asyncio.to_thread(connection.close)
1049+
if connection is not None:
1050+
with contextlib.suppress(Exception):
1051+
await asyncio.to_thread(connection.close)
10371052

10381053
try:
10391054
refreshed = await asyncio.to_thread(get_postgres_connection, settings)
@@ -3424,18 +3439,30 @@ async def health_handler(request: Request) -> JSONResponse:
34243439
except Exception:
34253440
redis_ok = False
34263441

3442+
postgres_migrations_ok = getattr(request.app.state, "postgres_migrations_ok", True)
34273443
if hasattr(request.app.state, "postgres_conn"):
34283444
postgres_ok = await _is_postgres_connection_healthy(request.app)
34293445
else:
34303446
postgres_ok = await asyncio.to_thread(is_postgres_healthy, settings)
34313447

3448+
intake_resume_scan_required = settings.effective_intake_resume_require_virus_scan
3449+
intake_resume_scan_configured = settings.intake_resume_virus_scan_configured
3450+
healthy = (
3451+
redis_ok
3452+
and postgres_ok
3453+
and postgres_migrations_ok
3454+
and intake_resume_scan_configured
3455+
)
34323456
payload = {
3433-
"status": "healthy" if redis_ok and postgres_ok else "degraded",
3457+
"status": "healthy" if healthy else "degraded",
34343458
"redis_connected": redis_ok,
34353459
"postgres_connected": postgres_ok,
3460+
"postgres_migrations_ok": postgres_migrations_ok,
3461+
"intake_resume_scan_required": intake_resume_scan_required,
3462+
"intake_resume_scan_configured": intake_resume_scan_configured,
34363463
"queue_name": settings.redis_queue_name,
34373464
}
3438-
return JSONResponse(payload, status_code=200 if redis_ok and postgres_ok else 503)
3465+
return JSONResponse(payload, status_code=200 if healthy else 503)
34393466

34403467

34413468
async def ingest_handler(request: Request, source: str) -> JSONResponse:
@@ -9080,20 +9107,36 @@ async def auth_discord_link_consume_handler(
90809107

90819108
@asynccontextmanager
90829109
async def _lifespan(app: FastAPI) -> Any:
9083-
await asyncio.to_thread(run_job_migrations)
9084-
90859110
redis_conn = get_redis_connection(settings)
90869111
app.state.redis_conn = redis_conn
90879112
app.state.postgres_conn_lock = asyncio.Lock()
9088-
app.state.postgres_conn = await asyncio.to_thread(get_postgres_connection, settings)
9113+
app.state.postgres_migrations_ok = True
9114+
try:
9115+
await asyncio.to_thread(run_job_migrations)
9116+
except Exception:
9117+
logger.exception("Failed to run job migrations during startup")
9118+
app.state.postgres_migrations_ok = False
9119+
try:
9120+
app.state.postgres_conn = await asyncio.to_thread(
9121+
get_postgres_connection,
9122+
settings,
9123+
)
9124+
except Exception:
9125+
logger.exception("Failed to open startup Postgres connection")
9126+
app.state.postgres_conn = None
90899127
app.state.queue = build_queue_client()
90909128
app.state.auth_store = RedisAuthStore(redis_conn)
90919129
app.state.oidc_client = OIDCProviderClient(settings)
90929130
app.state.discord_admin_verifier = DiscordAdminVerifier(settings)
90939131
app.state.http_client = httpx.AsyncClient(follow_redirects=False)
90949132

9095-
if settings.crm_sync_enabled:
9133+
crm_sync_skip_reason = _crm_sync_scheduler_skip_reason()
9134+
if crm_sync_skip_reason is None:
90969135
app.state.crm_sync_task = asyncio.create_task(_crm_sync_scheduler(app))
9136+
elif crm_sync_skip_reason == "missing_espo":
9137+
logger.warning(
9138+
"CRM sync scheduler enabled but ESPO_BASE_URL/ESPO_API_KEY are not configured; skipping scheduler startup"
9139+
)
90979140
else:
90989141
logger.info("CRM sync scheduler disabled by config")
90999142

apps/discord_bot/src/five08/discord_bot/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class Settings(SharedSettings):
2525
Required settings must be provided via environment variables or .env file.
2626
"""
2727

28-
discord_bot_token: str
28+
discord_bot_token: str = ""
2929

3030
discord_admin_roles: str = "Admin,Owner"
3131
discord_default_job_forum_channels: str = "gigs:part_time,fulltime-roles:full_time"

apps/discord_bot/src/five08/discord_bot/main.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,14 @@
2222

2323
async def main() -> None:
2424
"""Main entry point for the bot."""
25+
token = settings.discord_bot_token.strip()
26+
if not token:
27+
raise RuntimeError("DISCORD_BOT_TOKEN is required to start the Discord bot.")
28+
2529
bot = create_bot()
2630

2731
try:
28-
await bot.start(settings.discord_bot_token)
32+
await bot.start(token)
2933
except KeyboardInterrupt:
3034
logger.info("Bot stopped by user")
3135
finally:

apps/worker/Dockerfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ COPY --from=uv /uv /uvx /usr/local/bin/
77
WORKDIR /app
88
ENV UV_LINK_MODE=copy
99

10+
RUN apt-get update \
11+
&& apt-get install -y --no-install-recommends clamdscan \
12+
&& rm -rf /var/lib/apt/lists/* \
13+
&& printf 'TCPSocket 3310\nTCPAddr clamav\n' >/etc/clamav/clamdscan.conf
14+
1015
COPY pyproject.toml uv.lock ./
1116
COPY apps/discord_bot/pyproject.toml apps/discord_bot/pyproject.toml
1217
COPY apps/worker/pyproject.toml apps/worker/pyproject.toml

apps/worker/src/five08/worker/config.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -139,18 +139,25 @@ def _strip_optional_runtime_config_string(cls, value: object) -> object:
139139
return value.strip()
140140
return value
141141

142-
@model_validator(mode="after")
143-
def validate_required_crm_settings(self) -> "WorkerSettings":
144-
"""Require EspoCRM settings outside local/test runtime environments."""
142+
@property
143+
def espo_configured(self) -> bool:
144+
"""Return true when EspoCRM credentials are available."""
145+
return bool(self.espo_base_url.strip() and self.espo_api_key.strip())
146+
147+
@property
148+
def effective_intake_resume_require_virus_scan(self) -> bool:
149+
"""Return whether resume parsing must be preceded by malware scanning."""
145150
env = self.environment.strip().lower()
146-
if env in {"local", "dev", "development", "test"}:
147-
return self
148-
if not self.espo_base_url or not self.espo_api_key:
149-
raise ValueError(
150-
"ESPO_BASE_URL and ESPO_API_KEY must be set when ENVIRONMENT "
151-
"is non-local."
152-
)
153-
return self
151+
if env not in {"local", "dev", "development", "test"}:
152+
return True
153+
return self.intake_resume_require_virus_scan
154+
155+
@property
156+
def intake_resume_virus_scan_configured(self) -> bool:
157+
"""Return whether the effective resume scan requirement has a command."""
158+
if not self.effective_intake_resume_require_virus_scan:
159+
return True
160+
return bool((self.intake_resume_virus_scan_command or "").strip())
154161

155162
@property
156163
def google_forms_allowed_form_ids_set(self) -> set[str]:
@@ -193,13 +200,6 @@ def validate_email_resume_intake_settings(self) -> "WorkerSettings":
193200
@model_validator(mode="after")
194201
def validate_intake_resume_scan_settings(self) -> "WorkerSettings":
195202
"""Require a scanner command when intake resume scanning is enabled."""
196-
env = self.environment.strip().lower()
197-
if env not in {"local", "dev", "development", "test"} and not (
198-
self.intake_resume_require_virus_scan
199-
):
200-
raise ValueError(
201-
"INTAKE_RESUME_REQUIRE_VIRUS_SCAN must be true when ENVIRONMENT is non-local"
202-
)
203203
if (
204204
self.intake_resume_require_virus_scan
205205
and not (self.intake_resume_virus_scan_command or "").strip()

0 commit comments

Comments
 (0)