-
Notifications
You must be signed in to change notification settings - Fork 2
Deploy safety: staging tier, real smoke test, destructive-migration gate, failing health checks #2125
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Deploy safety: staging tier, real smoke test, destructive-migration gate, failing health checks #2125
Changes from all commits
fd32f79
039c91b
903c28e
15203ac
0c3eea5
6966df3
96f840e
bfd35a6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -42,18 +42,44 @@ describe('docker-images.yml admin-DB migrate step', () => { | |||||||||||||||||||||||||||||||||||||||||||||
| expect(adminStep.if).toContain('ADMIN_DB_MIGRATIONS_ENABLED'); | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| it('given the admin migrations step, should run db:migrate:admin on a one-shot machine', () => { | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(adminStep.run).toContain('flyctl machine run'); | ||||||||||||||||||||||||||||||||||||||||||||||
| // The one-shot-machine mechanics (flyctl machine run, polling for | ||||||||||||||||||||||||||||||||||||||||||||||
| // failed/destroyed states) live in scripts/deploy/run-fly-migration.sh — | ||||||||||||||||||||||||||||||||||||||||||||||
| // shared by both the main and admin migration steps, so it's tested once | ||||||||||||||||||||||||||||||||||||||||||||||
| // there (scripts/deploy is not a vitest project; verified by reading the | ||||||||||||||||||||||||||||||||||||||||||||||
| // script directly below) instead of duplicated inline in each step. | ||||||||||||||||||||||||||||||||||||||||||||||
| const RUN_FLY_MIGRATION_SCRIPT = readFileSync( | ||||||||||||||||||||||||||||||||||||||||||||||
| resolve(__dirname, '../../scripts/deploy/run-fly-migration.sh'), | ||||||||||||||||||||||||||||||||||||||||||||||
| 'utf-8' | ||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| it('given the admin migrations step, should run db:migrate:admin via the shared one-shot-machine script', () => { | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(adminStep.run).toContain('scripts/deploy/run-fly-migration.sh'); | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(adminStep.run).toContain('db:migrate:admin'); | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| it('given the shared one-shot-machine script, should run flyctl machine run', () => { | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(RUN_FLY_MIGRATION_SCRIPT).toContain('flyctl machine run'); | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| it('given the admin migrations step, should use the same migrate image as the main step', () => { | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(adminStep.run).toContain('pagespace-migrate:latest'); | ||||||||||||||||||||||||||||||||||||||||||||||
| const imageOf = (run?: string) => run?.match(/ghcr\.io\/2witstudios\/pagespace-migrate\b/)?.[0]; | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(imageOf(adminStep.run)).toBeDefined(); | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(imageOf(adminStep.run)).toBe(imageOf(steps[mainIdx].run)); | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| it('given the admin and main migrations steps, should never pin the mutable :latest tag', () => { | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(adminStep.run).not.toContain('pagespace-migrate:latest'); | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(steps[mainIdx].run).not.toContain('pagespace-migrate:latest'); | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
64
to
+73
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Compare the migration tag argument, not only the image prefix. Line [65] stops at Suggested fix- const imageOf = (run?: string) => run?.match(/ghcr\.io\/2witstudios\/pagespace-migrate\b/)?.[0];
- expect(imageOf(adminStep.run)).toBeDefined();
- expect(imageOf(adminStep.run)).toBe(imageOf(steps[mainIdx].run));
+ const migrationTagOf = (run?: string) =>
+ run?.match(/ghcr\.io\/2witstudios\/pagespace-migrate\s+["']?([^"'\s]+)["']?/)?.[1];
+ expect(migrationTagOf(adminStep.run)).toBe('sha-${GITHUB_SHA::7}');
+ expect(migrationTagOf(adminStep.run)).toBe(migrationTagOf(steps[mainIdx].run));
- expect(adminStep.run).not.toContain('pagespace-migrate:latest');
- expect(steps[mainIdx].run).not.toContain('pagespace-migrate:latest');
+ expect(migrationTagOf(adminStep.run)).not.toBe('latest');
+ expect(migrationTagOf(steps[mainIdx].run)).not.toBe('latest');📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| it('given the admin migrations step, should delegate to the same script as the main step (so it fails on failed/destroyed machine states identically)', () => { | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(adminStep.run).toContain('scripts/deploy/run-fly-migration.sh'); | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(steps[mainIdx].run).toContain('scripts/deploy/run-fly-migration.sh'); | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| it('given the admin migrations step, should fail on failed/destroyed machine states like the main step', () => { | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(adminStep.run).toContain('failed'); | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(adminStep.run).toContain('destroyed'); | ||||||||||||||||||||||||||||||||||||||||||||||
| it('given the shared one-shot-machine script, should fail on failed/destroyed machine states', () => { | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(RUN_FLY_MIGRATION_SCRIPT).toContain('failed'); | ||||||||||||||||||||||||||||||||||||||||||||||
| expect(RUN_FLY_MIGRATION_SCRIPT).toContain('destroyed'); | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| it('given the main migrations step, should NOT be conditional (main DB always migrates)', () => { | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { ACK_PATTERN, findDestructiveReasons, statementsOf, stripSqlComments } from '../check-destructive-migrations'; | ||
|
|
||
| describe('statementsOf', () => { | ||
| it('given a Drizzle-style file, should split on statement-breakpoint markers', () => { | ||
| const sql = 'DROP TABLE "a";--> statement-breakpoint\nDROP TABLE "b";'; | ||
| expect(statementsOf(sql)).toEqual(['DROP TABLE "a";', 'DROP TABLE "b";']); | ||
| }); | ||
|
|
||
| it('given a single-statement file, should return one statement', () => { | ||
| expect(statementsOf('TRUNCATE "x";')).toEqual(['TRUNCATE "x";']); | ||
| }); | ||
| }); | ||
|
|
||
| describe('findDestructiveReasons', () => { | ||
| it('given DROP TABLE, should flag it', () => { | ||
| expect(findDestructiveReasons('DROP TABLE "alert_history";')).toEqual(['DROP TABLE']); | ||
| }); | ||
|
|
||
| it('given DROP COLUMN, should flag it', () => { | ||
| expect(findDestructiveReasons('ALTER TABLE "users" DROP COLUMN IF EXISTS "password";')).toEqual([ | ||
| 'DROP COLUMN', | ||
| ]); | ||
| }); | ||
|
|
||
| it('given TRUNCATE, should flag it', () => { | ||
| expect(findDestructiveReasons('TRUNCATE security_audit_log;')).toEqual(['TRUNCATE']); | ||
| }); | ||
|
|
||
| it('given DROP TYPE, should flag it', () => { | ||
| expect(findDestructiveReasons('DROP TYPE "WorkflowRunStatus";')).toEqual(['DROP TYPE']); | ||
| }); | ||
|
|
||
| it('given an enum-swap rename (RENAME TO ..._old), should flag it', () => { | ||
| expect( | ||
| findDestructiveReasons('ALTER TYPE "WorkflowRunStatus" RENAME TO "WorkflowRunStatus_old";') | ||
| ).toEqual(['enum-swap rename (RENAME TO ..._old)']); | ||
| }); | ||
|
|
||
| it('given ALTER COLUMN ... TYPE, should flag it', () => { | ||
| expect( | ||
| findDestructiveReasons( | ||
| 'ALTER TABLE "activity_logs" ALTER COLUMN "contentFormat" SET DATA TYPE content_format USING "contentFormat"::content_format;' | ||
| ) | ||
| ).toEqual(['ALTER COLUMN ... TYPE (data type change)']); | ||
| }); | ||
|
|
||
| it('given ADD COLUMN ... NOT NULL with no DEFAULT, should flag it', () => { | ||
| expect( | ||
| findDestructiveReasons('ALTER TABLE "calendar_triggers" ADD COLUMN "workflowId" text NOT NULL;') | ||
| ).toEqual(['ADD COLUMN ... NOT NULL without a DEFAULT']); | ||
| }); | ||
|
|
||
| it('given ADD COLUMN ... NOT NULL with a DEFAULT, should not flag it', () => { | ||
| expect( | ||
| findDestructiveReasons('ALTER TABLE "t" ADD COLUMN "x" boolean DEFAULT false NOT NULL;') | ||
| ).toEqual([]); | ||
| }); | ||
|
|
||
| it('given ADD COLUMN ... bigserial NOT NULL, should not flag it (self-populating)', () => { | ||
| expect( | ||
| findDestructiveReasons('ALTER TABLE "activity_logs" ADD COLUMN "chainSeq" bigserial NOT NULL;') | ||
| ).toEqual([]); | ||
| }); | ||
|
|
||
| it('given a purely additive statement, should not flag anything', () => { | ||
| expect(findDestructiveReasons('ALTER TABLE "t" ADD COLUMN "x" text;')).toEqual([]); | ||
| }); | ||
|
|
||
| it('given multiple destructive statements in one file, should flag each distinct reason once', () => { | ||
| const sql = [ | ||
| 'TRUNCATE TABLE "calendar_triggers";--> statement-breakpoint', | ||
| 'ALTER TABLE "calendar_triggers" DROP COLUMN IF EXISTS "status";--> statement-breakpoint', | ||
| 'DROP TYPE "CalendarTriggerStatus";', | ||
| ].join('\n'); | ||
| const reasons = findDestructiveReasons(sql); | ||
| expect(reasons).toContain('TRUNCATE'); | ||
| expect(reasons).toContain('DROP COLUMN'); | ||
| expect(reasons).toContain('DROP TYPE'); | ||
| expect(reasons).toHaveLength(3); | ||
| }); | ||
|
|
||
| it('given ADD COLUMN NOT NULL with a comment mentioning DEFAULT (not a real one), should still flag it', () => { | ||
| expect( | ||
| findDestructiveReasons( | ||
| '-- table is empty, so no DEFAULT is needed\nALTER TABLE "t" ADD COLUMN "x" text NOT NULL;' | ||
| ) | ||
| ).toEqual(['ADD COLUMN ... NOT NULL without a DEFAULT']); | ||
| }); | ||
|
|
||
| it('given ADD COLUMN NOT NULL with a comment mentioning SERIAL (not a real one), should still flag it', () => { | ||
| expect( | ||
| findDestructiveReasons( | ||
| '-- not a serial column, just named that way\nALTER TABLE "t" ADD COLUMN "x" text NOT NULL;' | ||
| ) | ||
| ).toEqual(['ADD COLUMN ... NOT NULL without a DEFAULT']); | ||
| }); | ||
|
|
||
| it('given a real DROP TABLE only inside a comment, should not flag it', () => { | ||
| expect(findDestructiveReasons('-- old code used to DROP TABLE "x" here, no longer true\nSELECT 1;')).toEqual( | ||
| [] | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('stripSqlComments', () => { | ||
| it('given a line comment, should remove it', () => { | ||
| expect(stripSqlComments('-- a comment\nDROP TABLE "x";')).toBe('\nDROP TABLE "x";'); | ||
| }); | ||
|
|
||
| it('given a block comment, should remove it', () => { | ||
| expect(stripSqlComments('/* a block comment */ DROP TABLE "x";')).toBe(' DROP TABLE "x";'); | ||
| }); | ||
|
|
||
| it('given a multi-line block comment, should remove it', () => { | ||
| expect(stripSqlComments('/* line one\nline two */\nDROP TABLE "x";')).toBe('\nDROP TABLE "x";'); | ||
| }); | ||
|
|
||
| it('given no comments, should return the input unchanged', () => { | ||
| expect(stripSqlComments('DROP TABLE "x";')).toBe('DROP TABLE "x";'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('ACK_PATTERN', () => { | ||
| it('given a destructive-migration-ack comment, should match', () => { | ||
| expect(ACK_PATTERN.test('-- destructive-migration-ack: no old code reads this table\nDROP TABLE "x";')).toBe( | ||
| true | ||
| ); | ||
| }); | ||
|
|
||
| it('given an ack comment with no reason text, should not match', () => { | ||
| expect(ACK_PATTERN.test('-- destructive-migration-ack:\nDROP TABLE "x";')).toBe(false); | ||
| }); | ||
|
|
||
| it('given no ack comment, should not match', () => { | ||
| expect(ACK_PATTERN.test('DROP TABLE "x";')).toBe(false); | ||
| }); | ||
|
|
||
| it('given an unrelated comment, should not match', () => { | ||
| expect(ACK_PATTERN.test('-- this table is old\nDROP TABLE "x";')).toBe(false); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Test executable failure behavior, not source-text presence.
The assertions only search the shell script text. The script comments and log messages already contain
flyctl machine run,failed, anddestroyed. The tests can pass if the actual command orexit 1handling is removed. Add an executable test with mocked Fly API responses forfailedanddestroyed, and assert a non-zero exit and cleanup.Also applies to: 80-82
🤖 Prompt for AI Agents