ops: add production PostgreSQL migration tooling

This commit is contained in:
Jesse_Chen
2026-08-09 13:20:06 +08:00
parent 04c4bd3ead
commit f53e2d09ed
11 changed files with 2165 additions and 7 deletions
+18
View File
@@ -56,6 +56,24 @@ test("database roles have no cluster privileges", () => {
`),
"t",
);
assert.equal(
fixture.psql(`
select role_name || ':' || case when pg_has_role(role_name, 'schema_owner', 'MEMBER') then 'true' else 'false' end
from unnest(array[
'migration_runner', 'identity_runtime', 'app_runtime',
'service_runtime', 'admin_runtime', 'backup_reader'
]) role_name
order by role_name
`),
[
"admin_runtime:f",
"app_runtime:f",
"backup_reader:f",
"identity_runtime:f",
"migration_runner:true",
"service_runtime:f",
].join("\n"),
);
assert.equal(
fixture.psql(`
select coalesce(string_agg(privilege_type, ',' order by privilege_type), '')
+89
View File
@@ -437,3 +437,92 @@ test("production API probes health rapidly while a replacement container starts"
assert.match(compose, /healthcheck:[\s\S]*start_period:\s*30s[\s\S]*start_interval:\s*1s/);
});
test("self-hosted production Caddy isolates user and admin hosts", () => {
const caddy = readFileSync(
new URL("../../deploy/Caddyfile.production.selfhosted", import.meta.url),
"utf8",
);
assert.match(caddy, /\{\$SITE_ADDRESS:https:\/\/jyotisha\.chat\}/);
assert.match(caddy, /@adminPaths path \/admin \/admin\/\* \/api\/admin\/\*/);
assert.match(caddy, /respond @adminPaths "Not found" 404/);
assert.match(caddy, /^https:\/\/admin\.jyotisha\.chat \{$/m);
assert.equal((caddy.match(/reverse_proxy web:3000/g) ?? []).length, 2);
assert.match(caddy, /@root path \/\n\s+redir @root \/admin 308/);
assert.doesNotMatch(caddy, /staging\.jyotisha\.chat|:443 \{/);
});
test("production env validators accept only self-hosted production selectors and role URLs", () => {
const appValidator = fileURLToPath(
new URL("../../deploy/validate-production-env.sh", import.meta.url),
);
const databaseValidator = fileURLToPath(
new URL("../../deploy/validate-production-database-env.sh", import.meta.url),
);
const root = mkdtempSync(join(tmpdir(), "jyotisha-production-env-"));
const appEnv = join(root, ".env.production");
const databaseEnv = join(root, ".env.production.database");
const appLines = [
"APP_ENV_FILE=../.env.production",
"CADDYFILE_PATH=./Caddyfile.production.selfhosted",
"SITE_ADDRESS=https://jyotisha.chat",
"AUTH_PROVIDER=self-hosted",
"SELF_HOSTED_IDENTITY_ENABLED=true",
"AUTH_USER_ORIGIN=https://jyotisha.chat",
"ADMIN_USER_ORIGIN=https://admin.jyotisha.chat",
"IDENTITY_DATABASE_URL=postgresql://identity_runtime:identity-runtime-test-password@postgres:5432/jyotisha",
"APP_DATABASE_URL=postgresql://app_runtime:app-runtime-test-password@postgres:5432/jyotisha",
"SERVICE_DATABASE_URL=postgresql://service_runtime:service-runtime-test-password@postgres:5432/jyotisha",
"ADMIN_DATABASE_URL=postgresql://admin_runtime:admin-runtime-test-password@postgres:5432/jyotisha",
"BETTER_AUTH_USER_SECRET=user-secret-that-is-at-least-32-bytes-long",
"RESEND_API_KEY=re_test_key_that_must_not_be_printed",
"RESEND_FROM_EMAIL=Jyotisha Production <login@jyotisha.chat>",
"ADMIN_EMAILS=admin@example.com",
"EPAY_CONFIG_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
"EPAY_CHAT_ENABLED=false",
"JYOTISH_DYNAMIC_RECTIFICATION_TOKEN=dynamic-token-that-is-at-least-32-bytes",
"PERSONAL_REPORT_ENABLED=true",
"PERSONAL_REPORT_DAILY_LIMIT=5",
];
const databaseLines = [
"POSTGRES_DB=jyotisha",
"POSTGRES_USER=postgres",
"POSTGRES_PASSWORD=postgres-password",
"SCHEMA_OWNER_PASSWORD=schema-owner-password",
"IDENTITY_RUNTIME_PASSWORD=identity-runtime-password",
"APP_RUNTIME_PASSWORD=app-runtime-password",
"SERVICE_RUNTIME_PASSWORD=service-runtime-password",
"ADMIN_RUNTIME_PASSWORD=admin-runtime-password",
"MIGRATION_RUNNER_PASSWORD=migration-runner-password",
"BACKUP_READER_PASSWORD=backup-reader-password",
"PRODUCTION_BACKUP_ENCRYPTION_KEY=backup-encryption-key",
"SCHEMA_DATABASE_URL=postgresql://schema_owner:schema-owner-password@postgres:5432/jyotisha",
];
const writeEnv = (path: string, lines: string[]) => {
writeFileSync(path, `${lines.join("\n")}\n`);
chmodSync(path, 0o600);
};
try {
writeEnv(appEnv, appLines);
writeEnv(databaseEnv, databaseLines);
assert.equal(spawnSync("bash", [appValidator, appEnv], { encoding: "utf8" }).status, 0);
assert.equal(spawnSync("bash", [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
writeEnv(appEnv, appLines.map((line) =>
line.startsWith("ADMIN_USER_ORIGIN=")
? "ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat"
: line,
));
assert.notEqual(spawnSync("bash", [appValidator, appEnv], { encoding: "utf8" }).status, 0);
writeEnv(databaseEnv, databaseLines.filter((line) =>
!line.startsWith("PRODUCTION_BACKUP_ENCRYPTION_KEY="),
));
assert.notEqual(spawnSync("bash", [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,277 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import test from "node:test";
import {
SafeProductionMigrationError,
assertActiveAdminUsers,
assertTargetEmpty,
normalizeAuthUser,
normalizeAuthUsers,
parseMode,
readConfiguration,
readSchema,
rowsSha256,
transferPlan,
} from "../scripts/migrate-supabase-production.mjs";
const scriptPath = fileURLToPath(
new URL("../scripts/migrate-supabase-production.mjs", import.meta.url),
);
function table(
columns: Array<{
name: string;
nullable?: boolean;
defaultValue?: string | null;
generated?: boolean;
identity?: boolean;
identityGeneration?: string | null;
}>,
primaryKey = ["id"],
foreignKeys: Array<{
columns: string[];
refSchema: string;
refTable: string;
refColumns: string[];
}> = [],
) {
return {
columns: columns.map((column) => ({
nullable: false,
defaultValue: null,
generated: false,
identity: false,
identityGeneration: null,
...column,
})),
primaryKey,
foreignKeys,
};
}
test("CLI requires one explicit migration mode", () => {
assert.equal(parseMode(["--preflight"]), "preflight");
assert.equal(parseMode(["--apply"]), "apply");
assert.equal(parseMode(["--verify"]), "verify");
assert.throws(() => parseMode([]), SafeProductionMigrationError);
assert.throws(() => parseMode(["--apply", "--verify"]), SafeProductionMigrationError);
});
test("configuration requires an explicit Owner and ciphertext decision", () => {
const base = {
SUPABASE_SOURCE_DATABASE_URL: "postgresql://source.invalid/jyotisha",
PRODUCTION_TARGET_DATABASE_URL: "postgresql://target.invalid/jyotisha",
PRODUCTION_OWNER_USER_ID: "018f4e6d-7a11-7000-8000-000000000001",
};
assert.equal(
readConfiguration({ ...base, PRODUCTION_CIPHERTEXT_MODE: "exclude" }).ciphertextMode,
"exclude",
);
assert.throws(
() => readConfiguration({ ...base, PRODUCTION_CIPHERTEXT_MODE: "preserve" }),
/confirmed production encryption keys/,
);
assert.equal(
readConfiguration({
...base,
PRODUCTION_CIPHERTEXT_MODE: "preserve",
PRODUCTION_CIPHERTEXT_KEYS_CONFIRMED: "true",
}).ciphertextMode,
"preserve",
);
});
test("Supabase identity transform preserves UUID and ban state without credentials", () => {
const source = {
id: "018F4E6D-7A11-7000-8000-000000000001",
email: " Person@Example.com ",
raw_user_meta_data: { full_name: "Person One", avatar_url: "https://example.invalid/a.png" },
email_confirmed_at: new Date("2026-07-01T00:00:00Z"),
banned_until: new Date("2027-01-01T00:00:00Z"),
created_at: new Date("2026-06-01T00:00:00Z"),
updated_at: new Date("2026-07-02T00:00:00Z"),
encrypted_password: "must-not-migrate",
refresh_token: "must-not-migrate",
mfa_secret: "must-not-migrate",
};
const user = normalizeAuthUser(source, new Date("2026-08-09T00:00:00Z"));
assert.equal(user.id, source.id.toLowerCase());
assert.equal(user.email, "person@example.com");
assert.equal(user.banned, true);
assert.deepEqual(user.ban_expires, source.banned_until);
assert.equal(user.two_factor_enabled, false);
assert.doesNotMatch(JSON.stringify(user), /must-not-migrate|password|refresh_token|mfa_secret/);
});
test("production identity preflight rejects duplicate canonical emails", () => {
const base = {
id: "018f4e6d-7a11-7000-8000-000000000001",
email: "person@example.com",
raw_user_meta_data: {},
email_confirmed_at: null,
created_at: new Date("2026-06-01T00:00:00Z"),
updated_at: new Date("2026-06-01T00:00:00Z"),
};
assert.throws(
() => normalizeAuthUsers([
base,
{ ...base, id: "018f4e6d-7a11-7000-8000-000000000002", email: " PERSON@example.com " },
]),
/invalid or duplicate auth identities/,
);
});
test("active administrators become usable identity admins", () => {
const ownerId = "018f4e6d-7a11-7000-8000-000000000001";
const adminId = "018f4e6d-7a11-7000-8000-000000000002";
const sourceUsers = [ownerId, adminId].map((id) => ({
id,
email: `${id}@example.com`,
raw_user_meta_data: {},
email_confirmed_at: null,
banned_until: null,
created_at: new Date("2026-06-01T00:00:00Z"),
updated_at: new Date("2026-06-01T00:00:00Z"),
}));
const activeAdminUserIds = new Set([ownerId, adminId]);
const users = normalizeAuthUsers(
sourceUsers,
new Date("2026-08-09T00:00:00Z"),
activeAdminUserIds,
);
assert.deepEqual(users.map((user) => user.role), ["admin", "admin"]);
assert.doesNotThrow(() => assertActiveAdminUsers(users, activeAdminUserIds, ownerId));
assert.throws(
() => assertActiveAdminUsers(users, new Set([adminId]), ownerId),
/Owner is not an active source administrator/,
);
assert.throws(
() => assertActiveAdminUsers([{ ...users[0], banned: true }, users[1]], activeAdminUserIds, ownerId),
/Owner is blocked/,
);
});
test("schema reader preserves PostgreSQL identity metadata", async () => {
const client = {
async query(text: string) {
if (text.includes("information_schema.columns")) {
return {
rows: [{
table_name: "redemption_attempts",
column_name: "id",
nullable: false,
column_default: null,
generated: false,
identity: true,
identity_generation: "ALWAYS",
}],
};
}
if (text.includes("PRIMARY KEY")) {
return { rows: [{ table_name: "redemption_attempts", columns: ["id"] }] };
}
return { rows: [] };
},
};
const schema = await readSchema(client, "public");
assert.deepEqual(schema.get("redemption_attempts")?.columns[0], {
name: "id",
nullable: false,
defaultValue: null,
generated: false,
identity: true,
identityGeneration: "ALWAYS",
});
});
test("transfer plan rejects source-only public columns", () => {
const source = new Map([
["profiles", table([{ name: "id" }, { name: "legacy_value" }])],
]);
const target = new Map([
["profiles", table([{ name: "id" }])],
]);
assert.throws(() => transferPlan(source, target), /target schema is missing a source column/);
});
test("transfer plan uses non-nullable dependencies and rejects unsafe cycles", () => {
const source = new Map([
["parent", table([{ name: "id" }])],
["child", table([{ name: "id" }, { name: "parent_id" }])],
]);
const target = new Map([
["parent", table([{ name: "id" }])],
["child", table(
[{ name: "id" }, { name: "parent_id" }],
["id"],
[{ columns: ["parent_id"], refSchema: "public", refTable: "parent", refColumns: ["id"] }],
)],
]);
assert.deepEqual(transferPlan(source, target).ordered, ["parent", "child"]);
target.get("parent")!.columns.push({
name: "child_id", nullable: false, defaultValue: null, generated: false,
});
source.get("parent")!.columns.push({
name: "child_id", nullable: false, defaultValue: null, generated: false,
});
target.get("parent")!.foreignKeys.push({
columns: ["child_id"], refSchema: "public", refTable: "child", refColumns: ["id"],
});
assert.throws(() => transferPlan(source, target), /foreign-key cycle/);
});
test("target preflight rejects existing business rows but permits migration seeds", async () => {
const counts = new Map([
["identity.users", 0],
["auth.users", 0],
["public.admin_roles", 6],
["public.profiles", 1],
]);
const client = {
async query(text: string) {
const match = text.match(/from\s+"(identity|auth|public)"\."([a-z_]+)"/i);
assert.ok(match, text);
return { rows: [{ count: String(counts.get(`${match[1]}.${match[2]}`) ?? 0) }] };
},
};
const targetTables = new Map([
["admin_roles", table([{ name: "id" }])],
["profiles", table([{ name: "id" }])],
]);
await assert.rejects(() => assertTargetEmpty(client, targetTables), /not empty/);
counts.set("public.profiles", 0);
await assert.doesNotReject(() => assertTargetEmpty(client, targetTables));
});
test("reconciliation hashes are stable and the script has no wildcard data reads", () => {
const rows = [
{ id: "b", payload: { z: 2, a: 1 } },
{ id: "a", payload: { a: 1, z: 2 } },
];
assert.equal(rowsSha256(rows, ["id", "payload"]), rowsSha256([...rows].reverse(), ["id", "payload"]));
const source = readFileSync(scriptPath, "utf8");
assert.doesNotMatch(source, /select\s+\*/i);
assert.match(source, /begin isolation level repeatable read read only/i);
assert.match(source, /pg_advisory_xact_lock/);
assert.match(source, /target business database is not empty/);
assert.match(source, /rollback/);
assert.match(source, /--preflight/);
assert.match(source, /--apply/);
assert.match(source, /--verify/);
assert.match(source, /overriding system value/i);
assert.match(source, /setval\(pg_get_serial_sequence/i);
assert.match(source, /target_primary_key_sha256/);
assert.match(source, /sourceKeyHash === targetKeyHash/);
assert.match(source, /users\.role = 'admin' and users\.banned = false/);
});
@@ -42,6 +42,10 @@ const giteaProductionWorkflow = new URL(
"../../.gitea/workflows/deploy-production.yml",
import.meta.url,
);
const giteaProductionMigrationWorkflow = new URL(
"../../.gitea/workflows/migrate-production-database.yml",
import.meta.url,
);
const resetStagingAccountWorkflow = new URL(
"../../.github/workflows/reset-staging-account.yml",
import.meta.url,
@@ -62,10 +66,22 @@ const productionDeployScript = new URL(
"../../deploy/run-production-deploy.sh",
import.meta.url,
);
const productionMigrationScript = new URL(
"../../deploy/run-production-migration.sh",
import.meta.url,
);
const productionSyncScript = new URL(
"../../deploy/sync-production-tree.sh",
import.meta.url,
);
const productionEnvValidator = new URL(
"../../deploy/validate-production-env.sh",
import.meta.url,
);
const productionDatabaseEnvValidator = new URL(
"../../deploy/validate-production-database-env.sh",
import.meta.url,
);
function read(url: URL): string {
return readFileSync(url, "utf8");
@@ -89,6 +105,7 @@ test("changed staging workflows are syntactically valid YAML", () => {
giteaDeployWorkflow,
giteaMigrationWorkflow,
giteaProductionWorkflow,
giteaProductionMigrationWorkflow,
]) {
const result = spawnSync(
"python",
@@ -844,13 +861,16 @@ test("production deploy is manual-only and consumes the accepted staging artifac
});
test("staging scripts pass shell syntax validation", () => {
test("deployment scripts pass shell syntax validation", () => {
for (const script of [
deployScript,
migrationScript,
syncScript,
productionDeployScript,
productionMigrationScript,
productionSyncScript,
productionEnvValidator,
productionDatabaseEnvValidator,
]) {
const path = fileURLToPath(script);
chmodSync(path, 0o755);
@@ -858,3 +878,128 @@ test("staging scripts pass shell syntax validation", () => {
assert.equal(result.status, 0, result.stderr);
}
});
test("Gitea production deploy consumes only gate-attested digests under manual control", () => {
const workflow = read(giteaProductionWorkflow);
assert.match(workflow, /^on:\n\s+workflow_dispatch:/m);
assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:/);
assert.match(workflow, /deploy_sha:[\s\S]*allow_rollback:[\s\S]*verification_mode:/);
assert.match(workflow, /runs-on: manman-linux/);
assert.match(workflow, /group: production-mutation/);
assert.match(workflow, /branch=staging&event=push&status=success/);
assert.match(workflow, /endswith\("backend-quality-gate\.yml"\)/);
assert.match(workflow, /endswith\("release-quality-gate\.yml"\)/);
assert.match(workflow, /public staging has not accepted the requested SHA/);
assert.match(workflow, /main and staging must identify the same reviewed release/);
assert.match(workflow, /controller_sha256/);
assert.match(workflow, /sha256sum --check --status/);
assert.match(workflow, /steps\.images\.outputs\.api_image/);
assert.match(workflow, /steps\.images\.outputs\.web_image/);
assert.match(workflow, /SSH_PRIVATE_KEY_BASE64: \$\{\{ secrets\.PRODUCTION_SSH_PRIVATE_KEY \}\}/);
assert.match(workflow, /\[\[ "\$DEPLOY_HOST" == "118\.194\.235\.34" \]\]/);
assert.match(workflow, /\[\[ "\$DEPLOY_USER" == "deploy" \]\]/);
assert.match(workflow, /\[\[ "\$DEPLOY_PATH" == "\/opt\/jyotisha-production" \]\]/);
assert.match(workflow, /bash '\$incoming\/deploy\/run-production-deploy\.sh'/);
assert.doesNotMatch(workflow, /docker compose[^\n]*build|db:migrate/);
});
test("production runner validates state and migrations before switching exact images", () => {
const runner = read(productionDeployScript);
const sync = read(productionSyncScript);
assert.match(runner, /^#!\/usr\/bin\/env bash\nset -euo pipefail\nset \+x\n/);
assert.match(runner, /another production mutation holds the host lock/);
assert.match(runner, /sync-production-tree\.sh/);
assertOrder(runner, [
"validate-production-env.sh",
"validate-production-database-env.sh",
"compose=(",
'"${compose[@]}" config --quiet',
"pull api web",
"up -d --no-build --pull never --wait postgres",
"--profile migration-check run --rm migration-checker",
]);
assert.match(runner, /-f deploy\/docker-compose\.production\.yml/);
assert.match(runner, /pending migrations: run Migrate Production Database/);
assert.doesNotMatch(runner, /--profile migration run --rm migrator/);
assert.match(runner, /if \[ "\$VERIFICATION_MODE" = "public" \]/);
assert.match(runner, /up -d --no-build api web/);
assert.match(runner, /up -d --no-build --force-recreate --no-deps caddy/);
assert.match(runner, /\["identity", "IDENTITY_DATABASE_URL"\]/);
assert.match(runner, /\["service", "SERVICE_DATABASE_URL"\]/);
assert.match(runner, /publicBody\.deployment\?\.gitCommit === process\.env\.EXPECTED_SHA/);
assert.match(runner, /production verification predicates did not converge/);
assert.match(runner, /mv -f "\$revision_file" "\$state_directory\/deployed-revision"/);
assert.match(sync, /--exclude='\/\.env\*'/);
assert.match(sync, /--exclude='\/backups\/'/);
assert.match(sync, /--exclude='\/\.state\/'/);
});
test("Gitea production schema migration is exact-SHA gated and isolated from ETL and deploy", () => {
const workflow = read(giteaProductionMigrationWorkflow);
const runner = read(productionMigrationScript);
assert.match(workflow, /^on:\n\s+workflow_dispatch:/m);
assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:/);
assert.match(workflow, /\^\[0-9a-f\]\{40\}\$/);
assert.match(workflow, /main_head.*DEPLOY_SHA.*staging_head.*DEPLOY_SHA/s);
assert.match(workflow, /GITEA_SHA.*DEPLOY_SHA/);
assert.match(workflow, /STAGING_URL: \$\{\{ vars\.STAGING_URL \}\}/);
assert.match(workflow, /\[\[ "\$STAGING_URL" == "https:\/\/staging\.jyotisha\.chat" \]\]/);
assert.match(workflow, /"\$STAGING_URL\/api\/health"/);
assert.match(workflow, /observed_staging_sha.*DEPLOY_SHA/s);
assert.match(workflow, /branch=staging&event=push&status=success/);
assert.match(workflow, /endswith\("backend-quality-gate\.yml"\)/);
assert.match(workflow, /endswith\("release-quality-gate\.yml"\)/);
assert.match(workflow, /group: production-mutation/);
assert.match(workflow, /SSH_PRIVATE_KEY_BASE64: \$\{\{ secrets\.PRODUCTION_SSH_PRIVATE_KEY \}\}/);
assert.match(workflow, /PRODUCTION_KNOWN_HOSTS/);
assert.match(workflow, /\[\[ "\$DEPLOY_HOST" == "118\.194\.235\.34" \]\]/);
assert.match(workflow, /\[\[ "\$DEPLOY_USER" == "deploy" \]\]/);
assert.match(workflow, /\[\[ "\$DEPLOY_PATH" == "\/opt\/jyotisha-production" \]\]/);
assert.match(workflow, /sha256sum --check --status/);
assert.match(workflow, /recovery_reference:[\s\S]*required: true[\s\S]*type: string/);
assert.match(workflow, /recovery_created_at:[\s\S]*required: true[\s\S]*type: string/);
assert.match(workflow, /restore_verified:[\s\S]*required: true[\s\S]*default: false[\s\S]*type: boolean/);
assert.match(workflow, /\[\[ "\$RESTORE_VERIFIED" == "true" \]\]/);
assert.match(workflow, /%Y-%m-%dT%H:%M:%SZ/);
assert.match(workflow, /age > timedelta\(hours=24\)/);
assert.match(workflow, /migration files run sequentially and are not atomic as a whole/);
assert.match(workflow, /RECOVERY_REFERENCE='\$RECOVERY_REFERENCE'/);
assert.match(workflow, /RECOVERY_CREATED_AT='\$RECOVERY_CREATED_AT'/);
assert.match(workflow, /RESTORE_VERIFIED='\$RESTORE_VERIFIED'/);
assert.match(workflow, /run-production-migration\.sh/);
assert.doesNotMatch(workflow, /migrate-supabase-production|run-production-deploy|verification_mode|PRODUCTION_URL|CADDY/);
assert.match(runner, /^#!\/usr\/bin\/env bash\nset -euo pipefail\nset \+x\n/);
assert.match(runner, /another production mutation holds the host lock/);
assert.match(runner, /\[ "\$DEPLOY_PATH" = "\/opt\/jyotisha-production" \]/);
assert.match(runner, /sync-production-tree\.sh/);
assert.match(runner, /validate-production-env\.sh/);
assert.match(runner, /validate-production-database-env\.sh/);
assert.match(runner, /RECOVERY_REFERENCE RECOVERY_CREATED_AT RESTORE_VERIFIED/);
assert.match(runner, /date -u -d "\$RECOVERY_CREATED_AT" \+%s/);
assert.match(runner, /recovery_age_seconds <= 24 \* 60 \* 60/);
assert.match(runner, /production recovery point must have restore_verified=true/);
assert.match(runner, /migration files run sequentially and are not atomic as a whole/);
assert.match(runner, /migration_runner_database_url="postgresql:\/\/migration_runner:/);
assert.match(runner, /role%3Dschema_owner/);
assert.match(runner, /pg_has_role\('migration_runner', 'schema_owner', 'member'\)/);
assert.match(
runner,
/printf 'SCHEMA_DATABASE_URL=%s\\n' "\$migration_runner_database_url" >"\$migration_environment"/,
);
assert.doesNotMatch(runner, /(?:awk|cat)[^\n]*\.env\.production\.database[^\n]*migration_environment/);
assert.doesNotMatch(runner, />>"\$migration_environment"/);
assert.equal((runner.match(/--profile migration-check run --rm migration-checker/g) ?? []).length, 2);
assertOrder(runner, [
"--profile migration-check run --rm migration-checker",
"--profile migration run --rm migrator",
"postcheck_status",
]);
assert.match(runner, /production migration postcheck did not converge/);
assert.doesNotMatch(runner, /migrate-supabase-production|docker-compose\.server\.yml|docker-compose\.production\.yml/);
assert.doesNotMatch(runner, /\bup\b[^\n]*(?:api|web|caddy)|Caddyfile|PRODUCTION_URL|PRODUCTION_ADMIN_URL|mv -f[^\n]*deployed-revision/);
});