diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh index 976731f..0afee35 100755 --- a/deploy/entrypoint.sh +++ b/deploy/entrypoint.sh @@ -1,8 +1,23 @@ #!/bin/sh set -e +# Self-healing schema push: +# 1. Try once — for fresh installs and already-migrated clusters this is all +# that's needed. +# 2. On failure (typically a Phase 0 upgrade where the new SecretBackend FK +# can't attach because pre-existing Secret rows reference nothing), run +# the pre-migrate bootstrap to seed a default SecretBackend + backfill +# Secret.backendId, then retry. +# 3. If the retry still fails, let the error surface so the pod crashes +# visibly rather than starting in a half-migrated state. echo "mcpd: pushing database schema..." -pnpm -F @mcpctl/db exec prisma db push --schema=prisma/schema.prisma --accept-data-loss 2>&1 +if pnpm -F @mcpctl/db exec prisma db push --schema=prisma/schema.prisma --accept-data-loss 2>&1; then + : +else + echo "mcpd: schema push failed — running pre-migrate bootstrap + retrying..." + node src/db/dist/scripts/pre-migrate-bootstrap.js || true + pnpm -F @mcpctl/db exec prisma db push --schema=prisma/schema.prisma --accept-data-loss 2>&1 +fi echo "mcpd: seeding templates..." TEMPLATES_DIR=templates node src/mcpd/dist/seed-runner.js diff --git a/src/db/src/scripts/pre-migrate-bootstrap.ts b/src/db/src/scripts/pre-migrate-bootstrap.ts new file mode 100644 index 0000000..1e5843b --- /dev/null +++ b/src/db/src/scripts/pre-migrate-bootstrap.ts @@ -0,0 +1,105 @@ +/** + * Self-healing pre-migration step for the SecretBackend rollout (Phase 0). + * + * Why this exists: `prisma db push` applies schema changes sequentially. When + * a cluster upgrades from a pre-SecretBackend DB: + * 1. `Secret.backendId` column is added with `DEFAULT ''` + * 2. `SecretBackend` table is created (empty) + * 3. The FK `Secret.backendId → SecretBackend.id` is added — and FAILS + * because every Secret row now has `backendId = ''` which references no + * row in SecretBackend. + * + * This script runs AFTER a failed `prisma db push` attempt: + * - If SecretBackend table doesn't exist yet → noop (fresh install case; + * db push will create everything and the FK succeeds because there are + * no Secret rows to violate it). + * - If SecretBackend exists but is empty → insert a default plaintext row. + * - If any Secret rows have `backendId = ''` → point them at the default. + * + * Idempotent: safe to run multiple times. No-op on a fully-migrated cluster. + * Never throws; logs and exits 0 even on errors so the subsequent + * `prisma db push` retry is still attempted. + */ +import { PrismaClient, Prisma } from '@prisma/client'; + +const DEFAULT_ID = 'cdefault000backend00000001'; + +async function main(): Promise { + const prisma = new PrismaClient(); + try { + // Does the SecretBackend table exist yet? We check by querying the + // information_schema rather than catching Prisma's error — cleaner, and + // lets us distinguish "table missing" from "query succeeded but empty". + const tableExists = await prisma.$queryRaw>` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'SecretBackend' + ) AS exists + `; + if (!tableExists[0]?.exists) { + console.log('bootstrap: SecretBackend table not present yet — skipping'); + return; + } + + // Ensure at least one row exists, marked isDefault. + const existingDefault = await prisma.$queryRaw>` + SELECT id FROM "SecretBackend" WHERE "isDefault" = true LIMIT 1 + `; + let defaultId: string; + if (existingDefault.length === 0) { + await prisma.$executeRaw` + INSERT INTO "SecretBackend" + ("id", "name", "type", "config", "isDefault", "description", "version", "createdAt", "updatedAt") + VALUES ( + ${DEFAULT_ID}, + 'default', + 'plaintext', + '{}'::jsonb, + true, + 'Default in-database plaintext backend. Seeded by pre-migrate-bootstrap.', + 1, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + ON CONFLICT (name) DO NOTHING + `; + // Re-read — if there was an existing row with the same name but no + // isDefault flag we need its id, not the one we tried to insert. + const afterInsert = await prisma.$queryRaw>` + SELECT id FROM "SecretBackend" WHERE name = 'default' LIMIT 1 + `; + if (afterInsert.length === 0) { + console.log('bootstrap: could not establish a default SecretBackend — bailing'); + return; + } + defaultId = afterInsert[0]!.id; + // Make sure it's flagged default. + await prisma.$executeRaw` + UPDATE "SecretBackend" SET "isDefault" = true WHERE id = ${defaultId} + `; + console.log(`bootstrap: seeded default SecretBackend (id=${defaultId})`); + } else { + defaultId = existingDefault[0]!.id; + } + + // Backfill Secret.backendId for any rows left with an empty value. + // Using $executeRaw returns affected row count. + const updated = await prisma.$executeRaw( + Prisma.sql`UPDATE "Secret" SET "backendId" = ${defaultId} WHERE "backendId" = ''`, + ); + if (updated > 0) { + console.log(`bootstrap: backfilled ${updated} Secret row(s) with default backendId`); + } + } catch (err) { + // Never fail the deploy — worst case prisma db push tries again anyway. + // Log the error so it's visible in pod logs. + console.error('bootstrap: non-fatal error:', err instanceof Error ? err.message : err); + } finally { + await prisma.$disconnect(); + } +} + +main().catch((err: unknown) => { + console.error('bootstrap: fatal error (ignored):', err); + // Intentionally exit 0 — we don't want to block the deploy on this. +});