diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cf852d34..588808838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -162,6 +162,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Backend startup now verifies `guild_role_grants` relation availability before booting the web app and fails fast with a migration-required error when schema state is invalid +- Deploy/runtime DB guardrails now share the same required-relation verifier + (`guild_role_grants`, `guild_automation_manifests`, + `guild_automation_runs`, `guild_automation_drifts`) and deploy now enforces + `prisma migrate status` pre-rollout, failing fast on migration/schema drift + before restarting runtime services - AutoMod web client now includes template listing/apply flows wired to `/api/guilds/:guildId/automod/templates` and `/api/guilds/:guildId/automod/templates/:templateId/apply` diff --git a/docs/CI_CD.md b/docs/CI_CD.md index 702299041..16c287efa 100644 --- a/docs/CI_CD.md +++ b/docs/CI_CD.md @@ -72,6 +72,18 @@ If the configured URL is missing the deploy path, the workflow appends de-duplicated, so it will never retry with malformed paths such as `/webhook/deploy/webhook/deploy`. +Deploy webhook runtime (`scripts/deploy.sh`) now blocks rollout before restarting +services unless all database preconditions pass: + +1. `npx prisma migrate deploy` +2. `npx prisma migrate status` +3. required relation guard + (`guild_role_grants`, `guild_automation_manifests`, + `guild_automation_runs`, `guild_automation_drifts`) + +If any pre-rollout DB guard fails, deploy exits immediately with a classified +migration/schema error and runtime services are not restarted. + ### Deploy secrets (how to add) Add these repository secrets in **Settings → Secrets and variables → Actions**: diff --git a/packages/backend/src/startup/verifyRequiredDatabaseState.ts b/packages/backend/src/startup/verifyRequiredDatabaseState.ts index b748757d4..c251c3385 100644 --- a/packages/backend/src/startup/verifyRequiredDatabaseState.ts +++ b/packages/backend/src/startup/verifyRequiredDatabaseState.ts @@ -1,33 +1,5 @@ -import { getPrismaClient } from '@lucky/shared/utils' - -const MISSING_SCHEMA_ERROR_CODE = 'ERR_DB_SCHEMA_MISSING' - -type DatabaseStartupError = Error & { code?: string } +import { verifyRequiredDatabaseRelations } from '@lucky/shared/utils' export async function verifyRequiredDatabaseState(): Promise { - const prisma = getPrismaClient() - - try { - await prisma.guildRoleGrant.count({ - take: 1, - }) - } catch (error) { - const maybePrismaError = error as { - code?: string - message?: string - meta?: { table?: string } - } - - if (maybePrismaError.code === 'P2021') { - const table = maybePrismaError.meta?.table ?? 'guild_role_grants' - const startupError = new Error( - `Required database relation "${table}" is missing. Run migrations before starting backend.`, - { cause: error }, - ) as DatabaseStartupError - startupError.code = MISSING_SCHEMA_ERROR_CODE - throw startupError - } - - throw error - } + await verifyRequiredDatabaseRelations() } diff --git a/packages/backend/tests/unit/startup/requiredDatabaseRelations.test.ts b/packages/backend/tests/unit/startup/requiredDatabaseRelations.test.ts new file mode 100644 index 000000000..77df3ecef --- /dev/null +++ b/packages/backend/tests/unit/startup/requiredDatabaseRelations.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test, jest } from '@jest/globals' +import { + REQUIRED_DATABASE_RELATIONS, + verifyRequiredDatabaseRelations, +} from '@lucky/shared/utils/requiredDatabaseRelations' + +type CountMock = jest.MockedFunction<(args: { take: 1 }) => Promise> + +function createPrismaStub() { + const guildRoleGrantCount = jest.fn(async () => 1) as CountMock + const guildAutomationManifestCount = jest.fn(async () => 1) as CountMock + const guildAutomationRunCount = jest.fn(async () => 1) as CountMock + const guildAutomationDriftCount = jest.fn(async () => 1) as CountMock + + const prisma = { + guildRoleGrant: { count: guildRoleGrantCount }, + guildAutomationManifest: { count: guildAutomationManifestCount }, + guildAutomationRun: { count: guildAutomationRunCount }, + guildAutomationDrift: { count: guildAutomationDriftCount }, + } + + return { + prisma, + guildRoleGrantCount, + guildAutomationManifestCount, + guildAutomationRunCount, + guildAutomationDriftCount, + } +} + +function missingRelationMessage(table: string): string { + return ( + `Required database relation "${table}" is missing. ` + + 'Run `npx prisma migrate deploy` before starting backend.' + ) +} + +describe('verifyRequiredDatabaseRelations', () => { + test('verifies all sentinel relations when schema is healthy', async () => { + const { + prisma, + guildRoleGrantCount, + guildAutomationManifestCount, + guildAutomationRunCount, + guildAutomationDriftCount, + } = createPrismaStub() + + await expect( + verifyRequiredDatabaseRelations(prisma), + ).resolves.toBeUndefined() + + expect(REQUIRED_DATABASE_RELATIONS).toEqual([ + 'guild_role_grants', + 'guild_automation_manifests', + 'guild_automation_runs', + 'guild_automation_drifts', + ]) + expect(guildRoleGrantCount).toHaveBeenCalledWith({ take: 1 }) + expect(guildAutomationManifestCount).toHaveBeenCalledWith({ take: 1 }) + expect(guildAutomationRunCount).toHaveBeenCalledWith({ take: 1 }) + expect(guildAutomationDriftCount).toHaveBeenCalledWith({ take: 1 }) + }) + + test('maps missing guild_role_grants relation to migration guidance', async () => { + const { prisma, guildRoleGrantCount } = createPrismaStub() + guildRoleGrantCount.mockRejectedValueOnce({ + code: 'P2021', + meta: { table: 'guild_role_grants' }, + }) + + await expect(verifyRequiredDatabaseRelations(prisma)).rejects.toMatchObject( + { + code: 'ERR_DB_SCHEMA_MISSING', + message: missingRelationMessage('guild_role_grants'), + }, + ) + }) + + test('maps missing guild_automation_manifests relation to migration guidance', async () => { + const { prisma, guildAutomationManifestCount } = createPrismaStub() + guildAutomationManifestCount.mockRejectedValueOnce({ + code: 'P2021', + meta: { table: 'guild_automation_manifests' }, + }) + + await expect(verifyRequiredDatabaseRelations(prisma)).rejects.toMatchObject( + { + code: 'ERR_DB_SCHEMA_MISSING', + message: missingRelationMessage('guild_automation_manifests'), + }, + ) + }) + + test('maps missing guild_automation_runs relation to migration guidance', async () => { + const { prisma, guildAutomationRunCount } = createPrismaStub() + guildAutomationRunCount.mockRejectedValueOnce({ + code: 'P2021', + meta: { table: 'guild_automation_runs' }, + }) + + await expect(verifyRequiredDatabaseRelations(prisma)).rejects.toMatchObject( + { + code: 'ERR_DB_SCHEMA_MISSING', + message: missingRelationMessage('guild_automation_runs'), + }, + ) + }) + + test('maps missing guild_automation_drifts relation to migration guidance', async () => { + const { prisma, guildAutomationDriftCount } = createPrismaStub() + guildAutomationDriftCount.mockRejectedValueOnce({ + code: 'P2021', + meta: { table: 'guild_automation_drifts' }, + }) + + await expect(verifyRequiredDatabaseRelations(prisma)).rejects.toMatchObject( + { + code: 'ERR_DB_SCHEMA_MISSING', + message: missingRelationMessage('guild_automation_drifts'), + }, + ) + }) + + test('rethrows non-P2021 errors unchanged', async () => { + const { prisma, guildRoleGrantCount } = createPrismaStub() + const error = new Error('database offline') + guildRoleGrantCount.mockRejectedValueOnce(error) + + await expect(verifyRequiredDatabaseRelations(prisma)).rejects.toBe(error) + }) +}) diff --git a/packages/backend/tests/unit/startup/verifyRequiredDatabaseState.test.ts b/packages/backend/tests/unit/startup/verifyRequiredDatabaseState.test.ts index 73e4c74db..eadb7313c 100644 --- a/packages/backend/tests/unit/startup/verifyRequiredDatabaseState.test.ts +++ b/packages/backend/tests/unit/startup/verifyRequiredDatabaseState.test.ts @@ -1,64 +1,47 @@ import { beforeEach, describe, expect, test, jest } from '@jest/globals' -const countMock = jest.fn() +const verifyRequiredDatabaseRelationsMock = jest.fn() jest.mock('@lucky/shared/utils', () => ({ - getPrismaClient: () => ({ - guildRoleGrant: { - count: (...args: unknown[]) => countMock(...args), - }, - }), + verifyRequiredDatabaseRelations: (...args: unknown[]) => + verifyRequiredDatabaseRelationsMock(...args), })) import { verifyRequiredDatabaseState } from '../../../src/startup/verifyRequiredDatabaseState' describe('verifyRequiredDatabaseState', () => { + const missingRelationMessage = + 'Required database relation "guild_automation_runs" is missing. ' + + 'Run `npx prisma migrate deploy` before starting backend.' + beforeEach(() => { jest.clearAllMocks() }) - test('passes when guild_role_grants relation is available', async () => { - countMock.mockResolvedValue(1) + test('delegates schema verification to shared relation guard', async () => { + verifyRequiredDatabaseRelationsMock.mockResolvedValue(undefined) await expect(verifyRequiredDatabaseState()).resolves.toBeUndefined() - expect(countMock).toHaveBeenCalledWith({ take: 1 }) + expect(verifyRequiredDatabaseRelationsMock).toHaveBeenCalledTimes(1) }) - test('maps missing relation error to actionable migration message', async () => { - const prismaError = { - code: 'P2021', - meta: { table: 'guild_role_grants' }, - } - countMock.mockRejectedValue(prismaError) - - try { - await verifyRequiredDatabaseState() - throw new Error('Expected verification to throw') - } catch (error) { - expect(error).toBeInstanceOf(Error) - expect(error).toMatchObject({ - code: 'ERR_DB_SCHEMA_MISSING', - message: - 'Required database relation "guild_role_grants" is missing. Run migrations before starting backend.', - }) - expect(error).toHaveProperty('cause', prismaError) - } - }) - - test('uses default table name when prisma error omits meta.table', async () => { - countMock.mockRejectedValue({ - code: 'P2021', - meta: {}, - }) + test('propagates actionable missing-relation startup error unchanged', async () => { + const missingRelationError = new Error( + missingRelationMessage, + ) as Error & { code?: string } + missingRelationError.code = 'ERR_DB_SCHEMA_MISSING' + verifyRequiredDatabaseRelationsMock.mockRejectedValueOnce( + missingRelationError, + ) - await expect(verifyRequiredDatabaseState()).rejects.toThrow( - 'Required database relation "guild_role_grants" is missing. Run migrations before starting backend.', + await expect(verifyRequiredDatabaseState()).rejects.toBe( + missingRelationError, ) }) - test('rethrows unknown prisma errors unchanged', async () => { + test('propagates unknown errors unchanged', async () => { const error = new Error('database offline') - countMock.mockRejectedValue(error) + verifyRequiredDatabaseRelationsMock.mockRejectedValueOnce(error) await expect(verifyRequiredDatabaseState()).rejects.toBe(error) }) diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 41956b7e5..f72d79523 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -6,3 +6,4 @@ export * from './ffmpeg/ffmpegWrapper' export * from './timerManager' export * from './general/embeds' export * from './database/prismaClient' +export * from './requiredDatabaseRelations' diff --git a/packages/shared/src/utils/requiredDatabaseRelations.ts b/packages/shared/src/utils/requiredDatabaseRelations.ts new file mode 100644 index 000000000..9ed876ce0 --- /dev/null +++ b/packages/shared/src/utils/requiredDatabaseRelations.ts @@ -0,0 +1,95 @@ +import type { PrismaClient } from '../generated/prisma/client.js' + +import { getPrismaClient } from './database/prismaClient' + +export const REQUIRED_DATABASE_STATE_ERROR_CODE = 'ERR_DB_SCHEMA_MISSING' +export const REQUIRED_DATABASE_RELATIONS = [ + 'guild_role_grants', + 'guild_automation_manifests', + 'guild_automation_runs', + 'guild_automation_drifts', +] as const + +type RequiredDatabaseRelationsPrismaModels = Pick< + PrismaClient, + | 'guildRoleGrant' + | 'guildAutomationManifest' + | 'guildAutomationRun' + | 'guildAutomationDrift' +> + +export type RequiredDatabaseStatePrisma = { + guildRoleGrant: Pick< + RequiredDatabaseRelationsPrismaModels['guildRoleGrant'], + 'count' + > + guildAutomationManifest: Pick< + RequiredDatabaseRelationsPrismaModels['guildAutomationManifest'], + 'count' + > + guildAutomationRun: Pick< + RequiredDatabaseRelationsPrismaModels['guildAutomationRun'], + 'count' + > + guildAutomationDrift: Pick< + RequiredDatabaseRelationsPrismaModels['guildAutomationDrift'], + 'count' + > +} + +type PrismaRelationError = { + code?: string + meta?: { table?: string } +} + +type DatabaseStateError = Error & { + code?: string +} + +type RelationCheck = { + table: (typeof REQUIRED_DATABASE_RELATIONS)[number] + check: (prisma: RequiredDatabaseStatePrisma) => Promise +} + +const relationChecks: RelationCheck[] = [ + { + table: 'guild_role_grants', + check: (prisma) => prisma.guildRoleGrant.count({ take: 1 }), + }, + { + table: 'guild_automation_manifests', + check: (prisma) => prisma.guildAutomationManifest.count({ take: 1 }), + }, + { + table: 'guild_automation_runs', + check: (prisma) => prisma.guildAutomationRun.count({ take: 1 }), + }, + { + table: 'guild_automation_drifts', + check: (prisma) => prisma.guildAutomationDrift.count({ take: 1 }), + }, +] + +export async function verifyRequiredDatabaseRelations( + prisma: RequiredDatabaseStatePrisma = getPrismaClient(), +): Promise { + for (const relationCheck of relationChecks) { + try { + await relationCheck.check(prisma) + } catch (error) { + const prismaError = error as PrismaRelationError + if (prismaError.code !== 'P2021') { + throw error + } + + const table = prismaError.meta?.table ?? relationCheck.table + const startupError = new Error( + `Required database relation "${table}" is missing. ` + + 'Run `npx prisma migrate deploy` before starting backend.', + { cause: error }, + ) as DatabaseStateError + startupError.code = REQUIRED_DATABASE_STATE_ERROR_CODE + throw startupError + } + } +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 7d8b44bd7..d489ce90a 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -73,7 +73,11 @@ notify() { \"description\": \"$desc\", \"color\": $color, \"fields\": [ - {\"name\": \"Commit\", \"value\": \"\`$commit_sha\` $commit_msg\", \"inline\": false} + { + \"name\": \"Commit\", + \"value\": \"\`$commit_sha\` $commit_msg\", + \"inline\": false + } ], \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" }] @@ -241,13 +245,38 @@ docker_compose up -d postgres redis log "Running database migrations..." if ! docker_compose run --rm --no-deps backend sh -lc "npx prisma migrate deploy"; then + log "ERROR: prisma migrate deploy failed (migration execution error)" notify 16711680 "Deploy Failed" "Database migration failed" exit 1 fi +log "Checking migration status..." +if ! docker_compose run --rm --no-deps backend sh -lc "npx prisma migrate status"; then + log "ERROR: prisma migrate status failed (migration drift/history mismatch)" + notify 16711680 "Deploy Failed" "Database migration status guard failed" + exit 1 +fi + +relation_guard_script=$( + cat <<'NODE' +import { verifyRequiredDatabaseRelations } from '@lucky/shared/utils' + +try { + await verifyRequiredDatabaseRelations() + console.log('DB schema guard passed') +} catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + process.exit(1) +} +NODE +) + log "Verifying required database relations..." -if ! docker_compose run --rm --no-deps backend node --input-type=module -e "import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); try { await prisma.guildRoleGrant.count({ take: 1 }); console.log('DB schema guard passed'); } finally { await prisma.\$disconnect(); }"; then - notify 16711680 "Deploy Failed" "Database schema guard failed" +if ! docker_compose run --rm --no-deps backend \ + node --input-type=module -e "$relation_guard_script"; then + log "ERROR: required database relation verification failed (schema drift)" + notify 16711680 "Deploy Failed" "Database relation guard failed" exit 1 fi