gongxiangp2/scripts/safe-production-migrate.mjs

835 lines
35 KiB
JavaScript

import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { createReadStream, realpathSync } from 'node:fs'
import { lstat, mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const MIGRATIONS_ROOT = path.join(ROOT, 'prisma', 'migrations')
const BAKED_COMMIT_FILE = path.join(ROOT, 'DEPLOYMENT_COMMIT')
const TARGET_MIGRATIONS = Object.freeze([
'20260811_add_video_transcode_lease',
'20260811_update_brand_logo_defaults',
'20260812_add_product_package_upload_job_lease',
'20260812_align_material_upload_idempotency_index',
'20260813_add_material_custody_department',
'20260813_add_organization_units_and_employment_state',
'20260813_add_user_employee_binding',
'20260817_add_material_is_incomplete_shell',
'20260820_add_ding_workflow_snapshots',
'20260824_expand_ding_workflow_insights',
'20260826_add_ding_workflow_demand_type',
'20260827_add_ding_workflow_detail_fields',
'20260826_add_pack_video_early_access',
])
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
const VERIFIED_BACKUP_SUMMARY_TABLES = Object.freeze([
'materials',
'users',
'systemSettings',
'prismaMigrations',
'productPackageUploadJobs',
])
const ISOLATED_DATABASE_PATTERN = /^gongxiangp2_restore_[a-z0-9][a-z0-9_]{7,55}$/
function fail(message) {
throw new Error(message)
}
export function requireExactCommit(value, label) {
const normalized = String(value || '').trim()
if (!/^[0-9a-f]{40}$/.test(normalized)) {
fail(`${label} must be an exact 40-character lowercase Git SHA`)
}
return normalized
}
export function sha256(buffer) {
return createHash('sha256').update(buffer).digest('hex')
}
export function normalizeColumnDefault(value) {
let normalized = String(value ?? '').trim().replace(/::(?:text|character varying)$/i, '')
if (normalized.startsWith("'") && normalized.endsWith("'")) {
normalized = normalized.slice(1, -1)
}
return normalized
}
function normalizedPort(value) {
const port = String(value || '').trim()
if (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
fail('Database port must be an integer between 1 and 65535')
}
return String(Number(port))
}
export function assertExactBtreeIndex(indexDefinition, expectedTable, expectedColumns) {
const definition = String(indexDefinition || '').trim().replace(/\s+/g, ' ')
if (!definition || /\bUNIQUE\b/i.test(definition) || /\bWHERE\b/i.test(definition)) {
fail(`Database index must be a non-unique, non-partial btree index on ${expectedTable}`)
}
const match = definition.match(
/^CREATE INDEX \S+ ON (?:ONLY )?(?:(?:"[^"]+"|[a-zA-Z_][\w$]*)\.)?("[^"]+"|[a-zA-Z_][\w$]*) USING ([a-zA-Z0-9_]+) \(([^()]*)\)$/i,
)
if (!match) fail(`Database index definition is not a simple index on ${expectedTable}`)
const table = match[1].replace(/^"|"$/g, '')
const method = match[2].toLowerCase()
const columns = match[3].split(',').map((part) => {
const column = part.trim()
const identifier = column.match(/^"?([a-zA-Z_][\w$]*)"?$/)
if (!identifier) fail(`Database index contains an expression or sort modifier: ${column}`)
return identifier[1]
})
if (
table !== expectedTable
|| method !== 'btree'
|| columns.length !== expectedColumns.length
|| columns.some((column, index) => column !== expectedColumns[index])
) {
fail(`Database index columns or method mismatch: ${expectedTable}`)
}
}
function normalizedPredicate(value) {
return String(value || '')
.replace(/[()\"]/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export function assertExactActiveUploadIdIndex(index) {
if (
!index
|| String(index.tablename || '') !== 'materials'
|| String(index.indexname || '') !== 'materials_uploadId_key'
|| index.indisunique !== true
|| index.indisprimary !== false
|| index.indisvalid !== true
|| index.indisready !== true
|| String(index.access_method || '') !== 'btree'
|| !Array.isArray(index.columns)
|| index.columns.length !== 1
|| String(index.columns[0]) !== '"uploadId"'
|| normalizedPredicate(index.predicate) !== 'uploadId IS NOT NULL AND deletedAt IS NULL'
) {
fail('Database index mismatch: materials_uploadId_key must be the exact active-only uploadId unique btree')
}
}
async function sha256File(filePath) {
const hash = createHash('sha256')
await new Promise((resolve, reject) => {
const stream = createReadStream(filePath)
stream.on('data', (chunk) => hash.update(chunk))
stream.once('error', reject)
stream.once('end', resolve)
})
return hash.digest('hex')
}
export function databaseFingerprintHash(database) {
return sha256(Buffer.from(JSON.stringify({
configuredHost: String(database.configuredHost || ''),
configuredPort: String(database.configuredPort || ''),
configuredDatabase: String(database.configuredDatabase || ''),
configuredUser: String(database.configuredUser || ''),
serverAddress: String(database.serverAddress || ''),
serverPort: String(database.serverPort || ''),
})))
}
function stableJson(value) {
if (Array.isArray(value)) return value.map(stableJson)
if (value && typeof value === 'object') {
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableJson(value[key])]))
}
return value
}
export function verifiedBackupSummaryHash(summary) {
return sha256(Buffer.from(JSON.stringify(stableJson(summary))))
}
function assertBoundedBackupSummary(summary, label) {
if (
!summary
|| typeof summary !== 'object'
|| Array.isArray(summary)
|| summary.schemaVersion !== 1
|| summary.sampleLimit !== 32
) {
fail(`${label} must use the bounded backup summary schemaVersion 1`)
}
for (const table of VERIFIED_BACKUP_SUMMARY_TABLES) {
const evidence = summary[table]
if (
!evidence
|| typeof evidence !== 'object'
|| Array.isArray(evidence)
|| !/^(?:0|[1-9]\d*)$/.test(String(evidence.count || ''))
|| !/^[0-9a-f]{32}$/.test(String(evidence.sampleSha256InputMd5 || ''))
) {
fail(`${label} is missing valid bounded evidence for ${table}`)
}
}
}
function assertIsolatedRestoreFingerprint(production, isolated) {
const keys = [
'configuredHost',
'configuredPort',
'configuredDatabase',
'configuredUser',
'serverAddress',
'serverPort',
]
if (
!isolated
|| typeof isolated !== 'object'
|| Array.isArray(isolated)
|| keys.some((key) => !String(isolated[key] || '').trim())
|| !ISOLATED_DATABASE_PATTERN.test(String(isolated.configuredDatabase))
) {
fail('Backup receipt must contain the exact isolated restore database fingerprint')
}
normalizedPort(isolated.configuredPort)
normalizedPort(isolated.serverPort)
if (
production.configuredDatabase === isolated.configuredDatabase
|| (
production.configuredHost === isolated.configuredHost
&& String(production.configuredPort) === String(isolated.configuredPort)
)
|| (
production.serverAddress === isolated.serverAddress
&& String(production.serverPort) === String(isolated.serverPort)
)
) {
fail('Backup receipt isolated restore target is not distinct from production')
}
}
function redactDatabaseFingerprint(url) {
return {
configuredHost: url.hostname,
configuredPort: url.port || '5432',
configuredDatabase: decodeURIComponent(url.pathname.replace(/^\//, '')),
configuredUser: decodeURIComponent(url.username),
}
}
async function loadLocalMigrations() {
const entries = await readdir(MIGRATIONS_ROOT, { withFileTypes: true })
const result = new Map()
for (const entry of entries) {
if (!entry.isDirectory()) continue
const sqlPath = path.join(MIGRATIONS_ROOT, entry.name, 'migration.sql')
try {
const sql = await readFile(sqlPath)
result.set(entry.name, { sqlPath, checksum: sha256(sql) })
} catch {
// Directories without migration.sql are not Prisma migrations.
}
}
return result
}
async function queryEvidence(prisma, localMigrations) {
const names = Array.from(localMigrations.keys())
const migrationRows = await prisma.$queryRawUnsafe(
`SELECT migration_name, checksum, finished_at::text AS finished_at,
rolled_back_at::text AS rolled_back_at, logs
FROM "_prisma_migrations"
WHERE migration_name = ANY($1::text[])`,
names,
)
const identityRows = await prisma.$queryRawUnsafe(
`SELECT current_database() AS database,
current_user AS "user",
COALESCE(inet_server_addr()::text, '') AS server_address,
COALESCE(inet_server_port()::text, '') AS server_port`,
)
const columns = await prisma.$queryRawUnsafe(
`SELECT table_name, column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = current_schema()
AND ((table_name = 'materials' AND column_name IN ('uploadId', 'transcode_lease_token', 'transcode_lease_expires_at'))
OR (table_name = 'materials' AND column_name IN ('custody_department_id', 'custody_at', 'custody_reason', 'is_incomplete_shell'))
OR (table_name = 'product_package_upload_jobs' AND column_name IN ('worker_id', 'lease_token', 'lease_expires_at'))
OR (table_name = 'system_settings' AND column_name IN ('headerLogoUrl', 'loginLogoUrl', 'logoUrl'))
OR (table_name = 'organization_units' AND column_name IN ('id', 'source_dept_id', 'parent_id', 'path', 'level', 'is_active'))
OR (table_name = 'performance_survey_employee_profiles' AND column_name IN ('employment_state', 'resigned_at', 'handover_dingtalk_user_id'))
OR (table_name = 'user_employee_bindings' AND column_name IN ('id', 'user_id', 'employee_profile_id', 'binding_source', 'status'))
OR (table_name = 'ding_workflow_snapshots' AND column_name IN ('process_instance_id', 'status', 'is_ongoing', 'source_updated_at_raw', 'scheme_field_count', 'task_count', 'active_task_count', 'synced_at', 'brand', 'demand_type', 'requester_name', 'requester_department', 'project_type', 'active_tasks', 'form_fields'))
OR (table_name = 'ding_workflow_sync_states' AND column_name IN ('id', 'status', 'source_cursor_raw', 'last_completed_at', 'lease_owner', 'lease_expires_at', 'current_ongoing_count')))
ORDER BY table_name, column_name`,
)
const indexes = await prisma.$queryRawUnsafe(
`SELECT tbl.relname AS tablename,
idx.relname AS indexname,
pg_get_indexdef(i.indexrelid) AS indexdef,
i.indisunique,
i.indisprimary,
i.indisvalid,
i.indisready,
am.amname AS access_method,
ARRAY(
SELECT pg_get_indexdef(i.indexrelid, ordinal, true)
FROM generate_series(1, i.indnkeyatts) AS ordinal
ORDER BY ordinal
) AS columns,
COALESCE(pg_get_expr(i.indpred, i.indrelid), '') AS predicate
FROM pg_index i
JOIN pg_class idx ON idx.oid = i.indexrelid
JOIN pg_class tbl ON tbl.oid = i.indrelid
JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
JOIN pg_am am ON am.oid = idx.relam
WHERE ns.nspname = current_schema()
AND idx.relname IN (
'materials_transcode_lease_idx',
'materials_uploadId_key',
'materials_is_incomplete_shell_idx',
'product_package_upload_jobs_lease_idx',
'ding_workflow_snapshots_ongoing_updated_idx',
'ding_workflow_snapshots_brand_idx',
'ding_workflow_snapshots_demand_type_idx',
'ding_workflow_snapshots_requester_department_idx',
'ding_workflow_sync_states_lease_idx'
)
ORDER BY idx.relname`,
)
const brandRows = await prisma.$queryRawUnsafe(
`SELECT id, "headerLogoUrl", "loginLogoUrl"
FROM "system_settings"
ORDER BY id`,
)
return { migrationRows, identity: identityRows[0], columns, indexes, brandRows }
}
function completedMigrationMap(rows) {
const map = new Map()
for (const row of rows) {
if (!row.finished_at || row.rolled_back_at) continue
map.set(String(row.migration_name), row)
}
return map
}
export function assertTargetMigrationEvidence(localMigrations, evidence) {
const completed = completedMigrationMap(evidence.migrationRows)
for (const name of TARGET_MIGRATIONS) {
const local = localMigrations.get(name)
if (!local) fail(`Required migration file is missing: ${name}`)
const row = completed.get(name)
if (!row) fail(`Required migration is not completed: ${name}`)
if (String(row.checksum) !== local.checksum) {
fail(`Database checksum differs from the exact source tree: ${name}`)
}
}
const columnByKey = new Map(evidence.columns.map((row) => [
`${row.table_name}.${row.column_name}`,
row,
]))
const requireColumn = (table, column, type, nullable) => {
const row = columnByKey.get(`${table}.${column}`)
if (!row || row.data_type !== type || row.is_nullable !== nullable) {
fail(`Database column mismatch: ${table}.${column}`)
}
}
requireColumn('materials', 'transcode_lease_token', 'text', 'YES')
requireColumn('materials', 'transcode_lease_expires_at', 'timestamp without time zone', 'YES')
requireColumn('materials', 'uploadId', 'text', 'YES')
requireColumn('materials', 'custody_department_id', 'text', 'YES')
requireColumn('materials', 'custody_at', 'timestamp without time zone', 'YES')
requireColumn('materials', 'custody_reason', 'text', 'YES')
requireColumn('materials', 'is_incomplete_shell', 'boolean', 'NO')
requireColumn('product_package_upload_jobs', 'worker_id', 'text', 'YES')
requireColumn('product_package_upload_jobs', 'lease_token', 'text', 'YES')
requireColumn('product_package_upload_jobs', 'lease_expires_at', 'timestamp without time zone', 'YES')
requireColumn('organization_units', 'id', 'text', 'NO')
requireColumn('organization_units', 'source_dept_id', 'integer', 'NO')
requireColumn('organization_units', 'parent_id', 'text', 'YES')
requireColumn('organization_units', 'path', 'text', 'NO')
requireColumn('organization_units', 'level', 'integer', 'NO')
requireColumn('organization_units', 'is_active', 'boolean', 'NO')
requireColumn('performance_survey_employee_profiles', 'employment_state', 'text', 'YES')
requireColumn('performance_survey_employee_profiles', 'resigned_at', 'timestamp without time zone', 'YES')
requireColumn('performance_survey_employee_profiles', 'handover_dingtalk_user_id', 'text', 'YES')
requireColumn('user_employee_bindings', 'id', 'text', 'NO')
requireColumn('user_employee_bindings', 'user_id', 'text', 'NO')
requireColumn('user_employee_bindings', 'employee_profile_id', 'text', 'NO')
requireColumn('user_employee_bindings', 'binding_source', 'text', 'NO')
requireColumn('user_employee_bindings', 'status', 'text', 'NO')
requireColumn('ding_workflow_snapshots', 'process_instance_id', 'text', 'NO')
requireColumn('ding_workflow_snapshots', 'status', 'text', 'NO')
requireColumn('ding_workflow_snapshots', 'is_ongoing', 'boolean', 'NO')
requireColumn('ding_workflow_snapshots', 'source_updated_at_raw', 'text', 'YES')
requireColumn('ding_workflow_snapshots', 'scheme_field_count', 'integer', 'NO')
requireColumn('ding_workflow_snapshots', 'task_count', 'integer', 'NO')
requireColumn('ding_workflow_snapshots', 'active_task_count', 'integer', 'NO')
requireColumn('ding_workflow_snapshots', 'synced_at', 'timestamp without time zone', 'NO')
requireColumn('ding_workflow_snapshots', 'brand', 'text', 'YES')
requireColumn('ding_workflow_snapshots', 'demand_type', 'text', 'YES')
requireColumn('ding_workflow_snapshots', 'requester_name', 'text', 'YES')
requireColumn('ding_workflow_snapshots', 'requester_department', 'text', 'YES')
requireColumn('ding_workflow_snapshots', 'project_type', 'text', 'YES')
requireColumn('ding_workflow_snapshots', 'active_tasks', 'jsonb', 'YES')
requireColumn('ding_workflow_snapshots', 'form_fields', 'jsonb', 'YES')
requireColumn('ding_workflow_sync_states', 'id', 'text', 'NO')
requireColumn('ding_workflow_sync_states', 'status', 'text', 'NO')
requireColumn('ding_workflow_sync_states', 'source_cursor_raw', 'text', 'YES')
requireColumn('ding_workflow_sync_states', 'last_completed_at', 'timestamp without time zone', 'YES')
requireColumn('ding_workflow_sync_states', 'lease_owner', 'text', 'YES')
requireColumn('ding_workflow_sync_states', 'lease_expires_at', 'timestamp without time zone', 'YES')
requireColumn('ding_workflow_sync_states', 'current_ongoing_count', 'integer', 'NO')
requireColumn('system_settings', 'headerLogoUrl', 'text', 'NO')
requireColumn('system_settings', 'loginLogoUrl', 'text', 'NO')
const header = columnByKey.get('system_settings.headerLogoUrl')
const login = columnByKey.get('system_settings.loginLogoUrl')
const legacy = columnByKey.get('system_settings.logoUrl')
if (!header || normalizeColumnDefault(header.column_default) !== '/brand/header-logo.png') {
fail('Database default mismatch: system_settings.headerLogoUrl')
}
if (!login || normalizeColumnDefault(login.column_default) !== '/brand/login-logo.png') {
fail('Database default mismatch: system_settings.loginLogoUrl')
}
if (legacy && (legacy.data_type !== 'text' || legacy.is_nullable !== 'NO')) {
fail('Database column mismatch: system_settings.logoUrl')
}
if (legacy && normalizeColumnDefault(legacy.column_default) !== '/brand/header-logo.png') {
fail('Database default mismatch: system_settings.logoUrl')
}
const indexByName = new Map(evidence.indexes.map((row) => [String(row.indexname), String(row.indexdef)]))
assertExactBtreeIndex(
indexByName.get('materials_transcode_lease_idx'),
'materials',
['transcode_lease_expires_at', 'transcode_lease_token'],
)
assertExactBtreeIndex(
indexByName.get('product_package_upload_jobs_lease_idx'),
'product_package_upload_jobs',
['status', 'lease_expires_at'],
)
assertExactActiveUploadIdIndex(
evidence.indexes.find((row) => String(row.indexname) === 'materials_uploadId_key'),
)
assertExactBtreeIndex(
indexByName.get('materials_is_incomplete_shell_idx'),
'materials',
['is_incomplete_shell'],
)
assertExactBtreeIndex(
indexByName.get('ding_workflow_snapshots_ongoing_updated_idx'),
'ding_workflow_snapshots',
['is_ongoing', 'source_updated_at_raw'],
)
assertExactBtreeIndex(
indexByName.get('ding_workflow_snapshots_brand_idx'),
'ding_workflow_snapshots',
['brand'],
)
assertExactBtreeIndex(
indexByName.get('ding_workflow_snapshots_demand_type_idx'),
'ding_workflow_snapshots',
['demand_type'],
)
assertExactBtreeIndex(
indexByName.get('ding_workflow_snapshots_requester_department_idx'),
'ding_workflow_snapshots',
['requester_department'],
)
assertExactBtreeIndex(
indexByName.get('ding_workflow_sync_states_lease_idx'),
'ding_workflow_sync_states',
['lease_expires_at'],
)
const isTrustedBrandValue = (value, localDefault) => {
const normalized = String(value || '').trim()
if (normalized === localDefault) return true
try {
const parsed = new URL(normalized)
return parsed.protocol === 'https:'
&& parsed.hostname === 's3.jiansun.vip'
&& parsed.port === ''
&& parsed.username === ''
&& parsed.password === ''
&& parsed.pathname.startsWith('/gongxiangpan/')
&& parsed.search === ''
&& parsed.hash === ''
} catch {
return false
}
}
for (const row of evidence.brandRows || []) {
if (!isTrustedBrandValue(row.headerLogoUrl, '/brand/header-logo.png')) {
fail(`Stored brand logo is untrusted: system_settings.${row.id}.headerLogoUrl`)
}
if (!isTrustedBrandValue(row.loginLogoUrl, '/brand/login-logo.png')) {
fail(`Stored brand logo is untrusted: system_settings.${row.id}.loginLogoUrl`)
}
}
}
function assertNoUnexpectedPendingMigrations(localMigrations, evidence) {
const completed = completedMigrationMap(evidence.migrationRows)
const targetSet = new Set(TARGET_MIGRATIONS)
const unexpected = Array.from(localMigrations.keys()).filter(
(name) => !completed.has(name) && !targetSet.has(name),
)
if (unexpected.length > 0) {
fail(`Unexpected pending migrations exist; refusing a bundled production write: ${unexpected.join(', ')}`)
}
const failed = evidence.migrationRows.filter((row) => !row.finished_at && !row.rolled_back_at)
if (failed.length > 0) {
fail(`Unresolved failed migrations exist: ${failed.map((row) => row.migration_name).join(', ')}`)
}
}
export function assertExpectedDatabaseIdentity(databaseUrl, identity) {
const expected = {
configuredHost: String(process.env.EXPECTED_DATABASE_HOST || '').trim(),
configuredPort: String(process.env.EXPECTED_DATABASE_PORT || '').trim(),
configuredDatabase: String(process.env.EXPECTED_DATABASE_NAME || '').trim(),
configuredUser: String(process.env.EXPECTED_DATABASE_USER || '').trim(),
serverAddress: String(process.env.EXPECTED_DATABASE_SERVER_ADDRESS || '').trim(),
serverPort: String(process.env.EXPECTED_DATABASE_SERVER_PORT || '').trim(),
}
if (Object.values(expected).some((value) => !value)) {
fail('Apply mode requires the configured and server database host, port, name and user fingerprint')
}
expected.configuredPort = normalizedPort(expected.configuredPort)
expected.serverPort = normalizedPort(expected.serverPort)
const configured = redactDatabaseFingerprint(databaseUrl)
if (
configured.configuredHost !== expected.configuredHost
|| configured.configuredPort !== expected.configuredPort
|| configured.configuredDatabase !== expected.configuredDatabase
|| configured.configuredUser !== expected.configuredUser
|| String(identity.database) !== expected.configuredDatabase
|| String(identity.user) !== expected.configuredUser
|| String(identity.server_address) !== expected.serverAddress
|| normalizedPort(identity.server_port) !== expected.serverPort
) {
fail('Production database identity does not match the explicit expected fingerprint')
}
return { ...configured, serverAddress: expected.serverAddress, serverPort: expected.serverPort }
}
export function assertBackupReceiptDocument(receipt, context, now = new Date()) {
if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt) || receipt.schemaVersion !== 1) {
fail('Backup receipt must use schemaVersion 1')
}
const maxAgeMinutes = Number(context.maxAgeMinutes)
if (!Number.isInteger(maxAgeMinutes) || maxAgeMinutes < 1 || maxAgeMinutes > 1440) {
fail('BACKUP_MAX_AGE_MINUTES must be an integer from 1 to 1440')
}
if (receipt.sourceCommit !== context.expectedCommit) {
fail('Backup receipt sourceCommit does not match the migrator commit')
}
if (receipt.generatedBy !== VERIFIED_BACKUP_GENERATOR) {
fail('Backup receipt must be machine-generated by the verified backup helper')
}
const database = receipt.database
const expectedDatabase = context.database
if (!database || typeof database !== 'object' || [
'configuredHost',
'configuredPort',
'configuredDatabase',
'configuredUser',
'serverAddress',
'serverPort',
].some((key) => String(database[key] || '') !== String(expectedDatabase[key] || ''))) {
fail('Backup receipt database fingerprint does not match the verified production database')
}
const backup = receipt.backup
const restore = receipt.restoreValidation
if (!backup || typeof backup !== 'object' || !restore || typeof restore !== 'object') {
fail('Backup receipt must contain backup and restoreValidation evidence')
}
if (backup.format !== 'pg_dump-custom') {
fail('Backup receipt format must be pg_dump-custom')
}
if (!path.isAbsolute(String(backup.artifactPath || ''))) {
fail('Backup artifactPath must be absolute')
}
if (!Number.isSafeInteger(backup.artifactSize) || backup.artifactSize <= 0) {
fail('Backup artifactSize must be a positive integer')
}
if (!/^[0-9a-f]{64}$/.test(String(backup.artifactSha256 || ''))) {
fail('Backup artifactSha256 must be a lowercase SHA-256')
}
if (
restore.status !== 'passed'
|| restore.artifactSha256 !== backup.artifactSha256
|| restore.databaseFingerprint !== databaseFingerprintHash(database)
) {
fail('Backup receipt does not prove a restore validation for this artifact and database fingerprint')
}
const isolated = restore.isolatedDatabase
assertIsolatedRestoreFingerprint(database, isolated)
if (restore.isolatedDatabaseFingerprint !== databaseFingerprintHash(isolated)) {
fail('Backup receipt isolated restore database fingerprint hash mismatch')
}
const summary = restore.verificationSummary
if (
!summary
|| typeof summary !== 'object'
|| Array.isArray(summary)
|| summary.schemaVersion !== 1
|| summary.maintenanceWindowConfirmation !== VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION
|| summary.statementTimeoutMs !== 30_000
|| summary.sampleLimit !== 32
) {
fail('Backup receipt must contain machine-generated bounded restore verification evidence')
}
assertBoundedBackupSummary(summary.sourceBefore, 'Production pre-dump summary')
assertBoundedBackupSummary(summary.sourceAfter, 'Production post-dump summary')
assertBoundedBackupSummary(summary.restored, 'Isolated restore summary')
const sourceBeforeHash = verifiedBackupSummaryHash(summary.sourceBefore)
const sourceAfterHash = verifiedBackupSummaryHash(summary.sourceAfter)
const restoredHash = verifiedBackupSummaryHash(summary.restored)
if (
sourceBeforeHash !== sourceAfterHash
|| sourceAfterHash !== restoredHash
|| summary.sourceSummarySha256 !== sourceAfterHash
|| summary.restoredSummarySha256 !== restoredHash
) {
fail('Backup receipt bounded source and restore verification summaries do not match')
}
const startedAt = new Date(backup.startedAt)
const completedAt = new Date(backup.completedAt)
const restoreStartedAt = new Date(restore.startedAt)
const restoreCompletedAt = new Date(restore.completedAt)
if (
!Number.isFinite(startedAt.getTime())
|| !Number.isFinite(completedAt.getTime())
|| !Number.isFinite(restoreStartedAt.getTime())
|| !Number.isFinite(restoreCompletedAt.getTime())
) {
fail('Backup and restore validation timestamps must be valid ISO timestamps')
}
const futureToleranceMs = 5 * 60 * 1000
if (
completedAt.getTime() > now.getTime() + futureToleranceMs
|| restoreCompletedAt.getTime() > now.getTime() + futureToleranceMs
|| completedAt.getTime() < startedAt.getTime()
|| restoreStartedAt.getTime() < completedAt.getTime()
|| restoreCompletedAt.getTime() < restoreStartedAt.getTime()
|| restoreCompletedAt.getTime() < completedAt.getTime()
|| now.getTime() - completedAt.getTime() > maxAgeMinutes * 60 * 1000
) {
fail('Backup receipt is stale or has an invalid validation timeline')
}
return { backup, restore }
}
async function assertBackupReceipt(context) {
const receiptPathInput = String(process.env.BACKUP_RECEIPT_PATH || '').trim()
const artifactRootInput = String(process.env.BACKUP_ARTIFACT_ROOT || '').trim()
const receiptPath = receiptPathInput ? path.resolve(receiptPathInput) : ''
const expectedHash = String(process.env.EXPECTED_BACKUP_RECEIPT_SHA256 || '').trim().toLowerCase()
const artifactRoot = artifactRootInput ? path.resolve(artifactRootInput) : ''
const maxAgeMinutes = String(process.env.BACKUP_MAX_AGE_MINUTES || '').trim()
if (!receiptPath || !artifactRoot || !/^[0-9a-f]{64}$/.test(expectedHash) || !maxAgeMinutes) {
fail('Apply mode requires the backup receipt hash, artifact root and freshness limit')
}
const stat = await lstat(receiptPath)
if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0) {
fail('Backup receipt must be a non-empty regular file')
}
const receiptBytes = await readFile(receiptPath)
const actualHash = sha256(receiptBytes)
if (actualHash !== expectedHash) fail('Backup receipt checksum mismatch')
let receipt
try {
receipt = JSON.parse(receiptBytes.toString('utf8'))
} catch {
fail('Backup receipt must be valid JSON')
}
const { backup, restore } = assertBackupReceiptDocument(receipt, {
...context,
maxAgeMinutes,
})
const artifactPath = path.resolve(backup.artifactPath)
const relativeArtifact = path.relative(artifactRoot, artifactPath)
if (!relativeArtifact || relativeArtifact.startsWith('..') || path.isAbsolute(relativeArtifact)) {
fail('Backup artifact must be a file below BACKUP_ARTIFACT_ROOT')
}
const artifactStat = await lstat(artifactPath)
if (!artifactStat.isFile() || artifactStat.isSymbolicLink() || artifactStat.size !== backup.artifactSize) {
fail('Backup artifact is missing, unsafe or has an unexpected size')
}
const artifactHash = await sha256File(artifactPath)
if (artifactHash !== backup.artifactSha256) fail('Backup artifact checksum mismatch')
return {
receiptPath,
receiptSha256: actualHash,
receiptSize: stat.size,
artifactPath,
artifactSha256: artifactHash,
artifactSize: artifactStat.size,
backupCompletedAt: backup.completedAt,
restoreValidatedAt: restore.completedAt,
}
}
async function writeAuditRecord(record) {
const outputPath = String(process.env.MIGRATION_AUDIT_OUTPUT || '').trim()
if (!outputPath) {
process.stdout.write(`${JSON.stringify(record, null, 2)}\n`)
return
}
const resolved = path.resolve(outputPath)
await mkdir(path.dirname(resolved), { recursive: true, mode: 0o750 })
const temp = `${resolved}.tmp-${process.pid}-${Date.now()}`
await writeFile(temp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: 'wx' })
await rename(temp, resolved)
}
async function assertApplyAuditOutputWritable() {
const outputInput = String(process.env.MIGRATION_AUDIT_OUTPUT || '').trim()
if (!outputInput) fail('Apply mode requires MIGRATION_AUDIT_OUTPUT')
const outputPath = path.resolve(outputInput)
try {
await lstat(outputPath)
fail('MIGRATION_AUDIT_OUTPUT already exists; use a new immutable audit filename')
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
const parent = path.dirname(outputPath)
const parentStat = await lstat(parent)
if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) {
fail('Migration audit output parent must be a real directory')
}
const suffix = `.write-probe-${process.pid}-${Date.now()}`
const source = path.join(parent, suffix)
const destination = `${source}.renamed`
try {
await writeFile(source, 'audit-write-probe\n', { mode: 0o600, flag: 'wx' })
await rename(source, destination)
} finally {
await unlink(source).catch((error) => {
if (error?.code !== 'ENOENT') throw error
})
await unlink(destination).catch((error) => {
if (error?.code !== 'ENOENT') throw error
})
}
}
async function main() {
const userArgs = process.argv.slice(2)
const apply = userArgs.includes('--apply')
if (userArgs.some((arg) => !['--apply', '--audit'].includes(arg))) {
fail('Only --audit (default) or --apply is supported')
}
if (apply && userArgs.includes('--audit')) fail('Choose exactly one mode')
const bakedCommit = requireExactCommit(await readFile(BAKED_COMMIT_FILE, 'utf8'), 'Baked commit')
const expectedCommit = requireExactCommit(process.env.EXPECTED_COMMIT_SHA, 'EXPECTED_COMMIT_SHA')
if (bakedCommit !== expectedCommit) fail('Migrator image commit does not match EXPECTED_COMMIT_SHA')
if (!process.env.DATABASE_URL) fail('DATABASE_URL is required')
const databaseUrl = new URL(process.env.DATABASE_URL)
if (databaseUrl.protocol !== 'postgresql:' && databaseUrl.protocol !== 'postgres:') {
fail('DATABASE_URL must use PostgreSQL')
}
const localMigrations = await loadLocalMigrations()
const { PrismaClient } = await import('@prisma/client')
const prisma = new PrismaClient()
let before
let backup = null
let verifiedDatabaseFingerprint = null
try {
before = await queryEvidence(prisma, localMigrations)
assertNoUnexpectedPendingMigrations(localMigrations, before)
if (apply) {
if (process.env.ALLOW_PRODUCTION_MIGRATIONS !== 'APPLY') {
fail('Apply mode requires ALLOW_PRODUCTION_MIGRATIONS=APPLY')
}
await assertApplyAuditOutputWritable()
verifiedDatabaseFingerprint = assertExpectedDatabaseIdentity(databaseUrl, before.identity)
backup = await assertBackupReceipt({
expectedCommit,
database: verifiedDatabaseFingerprint,
})
}
} finally {
await prisma.$disconnect()
}
if (apply) {
const prismaCli = path.join(ROOT, 'node_modules', '.bin', 'prisma')
const result = spawnSync(
prismaCli,
['migrate', 'deploy', '--schema', path.join(ROOT, 'prisma', 'schema.prisma')],
{ cwd: ROOT, env: process.env, stdio: 'inherit', shell: process.platform === 'win32' },
)
if (result.error) throw result.error
if (result.status !== 0) fail(`prisma migrate deploy failed with exit code ${result.status}`)
}
const verifyClient = new PrismaClient()
let after
try {
after = await queryEvidence(verifyClient, localMigrations)
if (apply) {
const postApplyDatabaseFingerprint = assertExpectedDatabaseIdentity(databaseUrl, after.identity)
if (databaseFingerprintHash(postApplyDatabaseFingerprint) !== databaseFingerprintHash(verifiedDatabaseFingerprint)) {
fail('Production database identity changed while migrations were being applied')
}
}
if (apply || TARGET_MIGRATIONS.every((name) => completedMigrationMap(after.migrationRows).has(name))) {
assertTargetMigrationEvidence(localMigrations, after)
}
} finally {
await verifyClient.$disconnect()
}
const completed = completedMigrationMap(after.migrationRows)
const record = {
mode: apply ? 'apply' : 'audit',
timestamp: new Date().toISOString(),
sourceCommit: bakedCommit,
database: {
...redactDatabaseFingerprint(databaseUrl),
actualDatabase: String(after.identity.database || ''),
actualUser: String(after.identity.user || ''),
serverAddress: String(after.identity.server_address || ''),
serverPort: String(after.identity.server_port || ''),
},
backup,
targetMigrations: TARGET_MIGRATIONS.map((name) => ({
name,
sourceChecksum: localMigrations.get(name)?.checksum || null,
databaseChecksum: completed.get(name)?.checksum || null,
completed: completed.has(name),
})),
verified: apply || TARGET_MIGRATIONS.every((name) => completed.has(name)),
}
await writeAuditRecord(record)
if (apply) process.stdout.write('Production migrations applied and verified.\n')
else process.stdout.write('Read-only production migration audit completed.\n')
}
if (
process.argv[1]
&& realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
) {
main().catch((error) => {
process.stderr.write(`[SAFE_MIGRATION_ERROR] ${error instanceof Error ? error.message : String(error)}\n`)
process.exitCode = 1
})
}