|
@@ -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)
|
|
|
|
|
+ })
|