Merge pull request #34112 from overleaf/dn0-migration-check-predeploy-hook

Add predeploy migration gate for Mongo-bundling services

GitOrigin-RevId: d9eb192ea32b5328fb24fd453ddb1370f373858e
This commit is contained in:
Daniel Kontšek
2026-06-04 14:26:19 +02:00
committed by Copybot
parent 6ce36a2606
commit 2570b6559d
+111
View File
@@ -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)
})