From 2b18c99296113effd2d1743fa2f38ed88a6c19f5 Mon Sep 17 00:00:00 2001 From: DJP Date: Mon, 20 Apr 2026 20:45:44 -0400 Subject: [PATCH] Fix stuck deploy: seed deps missing from prod image + health check too strict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught on the first real deploy to optical-dev. Two separate bugs. Dockerfile — runner stage was missing tsx + @prisma/adapter-pg + bcryptjs The Next.js standalone bundle covers the app, but prisma/seed-dow.ts is a separate .ts file executed via tsx (not bundled). Runner only explicitly installed prisma + dotenv, so `npm run db:seed` failed with "sh: tsx: not found" and deploys couldn't run the one-time seed. → Added tsx, @prisma/adapter-pg (seed uses PrismaPg directly), and bcryptjs (seed hashes the admin's temp password) to the `npm install --no-save` line in the runner stage. Adds ~15 MB to the final image — worth it for a working seed path. /api/health was 503 pre-seed, which made deploy.sh unwillingly block itself The probe in deploy.sh uses `curl -sf` and treats any non-2xx as "not ready". The health endpoint flipped the entire `healthy` flag to false when `organizations` or `pipeline_templates` counted zero — meaning a freshly-migrated-but-not-yet-seeded app was classified as unhealthy, deploy.sh gave up at Step 6, and we never got to Step 7 (Apache config) or Step 8 (UFW). End result: the URL 404'd because Apache wasn't proxying anything to the container. → Split liveness from readiness: - GET /api/health (default) — DB reachable, pgvector installed, AUTH_SECRET set, DEV_BYPASS off. Empty tables are reported as "warn" but do NOT 503. This is what deploy.sh waits on. - GET /api/health?strict=1 — same checks PLUS org + templates present. Use post-seed to verify everything landed. - Added a "mode" field ("liveness" | "strict") so which mode was used is visible in the response. - Pre-seed content-level checks now return status: "warn" with a hint to run `npm run db:seed`, instead of hard-failing. Net effect for a fresh deploy: ./deploy.sh → builds, runs migrations, reports healthy once DB + env are good, configures Apache, DONE. Then you can `docker compose -p dow-prod-tracker exec app npm run db:seed` at your leisure. Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 16 +++++++++-- src/app/api/health/route.ts | 57 +++++++++++++++++++++++++++---------- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/Dockerfile b/Dockerfile index eb1c8ea..2ba17c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,8 +42,20 @@ COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma.config.ts ./prisma.config.ts COPY --from=builder /app/src/generated ./src/generated -# Install prisma + dotenv for migrate deploy (prisma.config.ts imports dotenv/config) -RUN npm install --no-save prisma@7.4.2 dotenv@17.3.1 +# Install runtime deps needed for migrations + seed. The Next.js standalone +# bundle covers the app itself, but the seed script (prisma/seed-dow.ts) is +# a separate .ts file executed via tsx and needs its own module graph: +# prisma — the migrate-deploy CLI +# dotenv — prisma.config.ts imports dotenv/config +# tsx — runs the seed without an ahead-of-time compile +# @prisma/adapter-pg — the driver the seed instantiates directly +# bcryptjs — the seed hashes the admin's temp password +RUN npm install --no-save \ + prisma@7.4.2 \ + dotenv@17.3.1 \ + tsx@4 \ + @prisma/adapter-pg@7.4.2 \ + bcryptjs@3 # Create uploads directories for media storage RUN mkdir -p /data/uploads && chown nextjs:nodejs /data/uploads diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index a3e840f..6d511a3 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -3,11 +3,27 @@ import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; -export async function GET() { - const checks: Record = {}; +/** + * GET /api/health + * + * Liveness probe by default — returns 200 as long as the app is responsive + * and correctly configured. Empty tables (no orgs, no templates) are + * reported but do NOT flip the status to unhealthy — a freshly-deployed + * tracker hasn't run its seed yet, and that's not a runtime failure. + * + * Pass `?strict=1` for the full readiness check including seed content — + * useful after running the seed to confirm everything landed. + */ +export async function GET(request: Request) { + const strict = new URL(request.url).searchParams.get("strict") === "1"; + + const checks: Record< + string, + { status: "ok" | "warn" | "fail"; detail?: string } + > = {}; let healthy = true; - // 1. Database connectivity + // 1. Database connectivity — liveness-critical try { await prisma.$queryRawUnsafe("SELECT 1"); checks.database = { status: "ok" }; @@ -16,7 +32,7 @@ export async function GET() { healthy = false; } - // 2. pgvector extension + // 2. pgvector extension — liveness-critical (embeddings + search need it) try { const result: any[] = await prisma.$queryRawUnsafe( "SELECT extversion FROM pg_extension WHERE extname = 'vector'" @@ -32,34 +48,44 @@ export async function GET() { healthy = false; } - // 3. Organization exists + // 3. Organization exists — content-level, only blocks health in strict mode try { const orgCount = await prisma.organization.count(); if (orgCount > 0) { checks.organization = { status: "ok", detail: `${orgCount} org(s)` }; } else { - checks.organization = { status: "fail", detail: "no organizations found" }; - healthy = false; + checks.organization = { + status: "warn", + detail: "no organizations — run `npm run db:seed`", + }; + if (strict) healthy = false; } } catch { checks.organization = { status: "fail", detail: "query failed" }; healthy = false; } - // 4. Pipeline templates exist + // 4. Pipeline templates — content-level, only blocks health in strict mode try { const templateCount = await prisma.pipelineTemplate.count(); - checks.pipeline_templates = { - status: templateCount > 0 ? "ok" : "fail", - detail: `${templateCount} template(s)`, - }; - if (templateCount === 0) healthy = false; + if (templateCount > 0) { + checks.pipeline_templates = { + status: "ok", + detail: `${templateCount} template(s)`, + }; + } else { + checks.pipeline_templates = { + status: "warn", + detail: "no pipeline templates — run `npm run db:seed`", + }; + if (strict) healthy = false; + } } catch { checks.pipeline_templates = { status: "fail", detail: "query failed" }; healthy = false; } - // 5. DEV_BYPASS_AUTH safety + // 5. DEV_BYPASS_AUTH safety — always a hard failure in production const devBypassActive = process.env.DEV_BYPASS_AUTH === "true" && process.env.NODE_ENV === "production"; if (devBypassActive) { @@ -69,7 +95,7 @@ export async function GET() { checks.dev_bypass = { status: "ok" }; } - // 6. AUTH_SECRET set (required for next-auth v5) + // 6. AUTH_SECRET set — liveness-critical (NextAuth can't sign anything without it) if (process.env.NODE_ENV === "production" && !process.env.AUTH_SECRET) { checks.auth_secret = { status: "fail", detail: "AUTH_SECRET not set" }; healthy = false; @@ -80,6 +106,7 @@ export async function GET() { return NextResponse.json( { status: healthy ? "healthy" : "unhealthy", + mode: strict ? "strict" : "liveness", checks, timestamp: new Date().toISOString(), },