ops: add production PostgreSQL migration tooling
This commit is contained in:
@@ -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), '')
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user