diff --git a/tools/migrations/scripts/check_blocking.mjs b/tools/migrations/scripts/check_blocking.mjs new file mode 100644 index 0000000000..97295b5316 --- /dev/null +++ b/tools/migrations/scripts/check_blocking.mjs @@ -0,0 +1,111 @@ +// Predeploy migration gate. +// +// Run as a Cloud Deploy predeploy hook (a K8s Job using the service's released image). +// Blocks the rollout when there are pending migrations the image expects to have been applied. +// +// Exit codes: +// 0 nothing pending, or all pending migrations are non-blocking +// 1 one or more blocking pending migrations - rollout must not proceed +// 2 the check itself failed (e.g. Mongo unreachable) - fail closed +// +// A migration is non-blocking when either: +// - it is tagged "nonblocking" (preferred; safe to set on an already-applied migration), or +// - its filename ends with "_nonBlocking" before the extension, +// e.g. 20260101000000_backfill_thing_nonBlocking.mjs. + +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import east from 'east' + +const { MigrationManager } = east + +const NON_BLOCKING_SUFFIX = '_nonBlocking' +const NON_BLOCKING_TAG = 'nonblocking' +const TAG = process.env.MIGRATION_TAG || 'saas' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const migrationsDir = path.resolve(scriptDir, '..') + +function skipRequested() { + const value = process.env.SKIP_MIGRATION_CHECK + return Boolean(value) && value !== '0' && value !== 'false' +} + +async function findBlockingMigrations() { + // east resolves .eastrc, the migrations dir and the adapter relative to the + // working directory (same as `npm run migrations`). chdir so the check works + // regardless of how the container invokes it. + process.chdir(migrationsDir) + // The adapter aborts unless a tag is passed on argv; we pass the tag to east + // directly, so disable that CLI-only guard. + process.env.SKIP_TAG_CHECK = '1' + + const manager = new MigrationManager() + await manager.configure({ esModules: true }) + await manager.connect() + + try { + const pending = await manager.getMigrationNames({ status: 'new' }) + const candidates = pending.filter( + name => !name.endsWith(NON_BLOCKING_SUFFIX) + ) + const nonBlocking = pending.filter(name => + name.endsWith(NON_BLOCKING_SUFFIX) + ) + const blocking = candidates.length + ? await manager.getMigrationNames({ + migrations: candidates, + tag: `${TAG} & !${NON_BLOCKING_TAG}`, + }) + : [] + + return { pending, nonBlocking, blocking } + } finally { + await manager.disconnect() + } +} + +async function main() { + if (skipRequested()) { + console.warn( + `SKIP_MIGRATION_CHECK is set ("${process.env.SKIP_MIGRATION_CHECK}") - ` + + 'bypassing the pending-migration gate. Only use this for incident recovery.' + ) + return 0 + } + + const { pending, nonBlocking, blocking } = await findBlockingMigrations() + + if (nonBlocking.length) { + console.log( + `Ignoring ${nonBlocking.length} non-blocking pending migration(s):\n` + + nonBlocking.map(name => ` - ${name}`).join('\n') + ) + } + + if (blocking.length === 0) { + console.log( + pending.length === 0 + ? `No pending "${TAG}" migrations. Safe to deploy.` + : `No blocking pending "${TAG}" migrations. Safe to deploy.` + ) + return 0 + } + + console.error( + `${blocking.length} blocking pending "${TAG}" migration(s) must be applied before deploying:\n` + + blocking.map(name => ` - ${name}`).join('\n') + + `\n\nApply them, then retry the rollout. To intentionally ship a non-blocking ` + + `migration, add the "${NON_BLOCKING_TAG}" tag (preferred — safe on already-applied ` + + `migrations) or rename the file with the "${NON_BLOCKING_SUFFIX}" suffix.` + ) + return 1 +} + +main() + .then(code => process.exit(code)) + .catch(err => { + console.error('Migration check failed; blocking rollout (fail closed):') + console.error(err) + process.exit(2) + })