ops: align production ETL with legacy admin source

This commit is contained in:
Jesse_Chen
2026-08-09 13:53:07 +08:00
parent 664d8adbf0
commit d36ca7aac2
3 changed files with 31 additions and 6 deletions
@@ -11,6 +11,16 @@ This runbook moves production to the current reviewed `staging` release while al
This is not a volume copy. A full Supabase dump must not be restored over the target database.
## Confirmed migration decisions
- production Owner: `luna@copse.life` / `b8907d0c-6ed0-4270-b866-7e83bb4a1b26`;
- source Supabase project: `vtvnfqmonbfuxmqkqdlc`;
- the legacy production `ADMIN_EMAILS` allowlist contains only the designated Owner, and the legacy database has no `public.admin_users`; the ETL seeds that attested Owner into the target admin model;
- payment and model-provider ciphertext mode: `exclude`; re-enter both configurations after cutover with newly generated encryption keys;
- release policy: validate on `staging`, then promote the same accepted SHA to production;
- DNS remains unchanged until the final ETL and reconciliation pass;
- proposed maintenance window: `2026-08-12 02:0004:00 UTC+8`, pending operator confirmation after rehearsal timing.
## Release invariants
The production deployment and schema-migration workflows are manual-only and accept a full lowercase 40-character `deploy_sha`. A normal production mutation proceeds only when all of the following identify that exact SHA:
@@ -30,7 +40,7 @@ Repository variables:
| Name | Required value |
| --- | --- |
| `PRODUCTION_HOST` | `118.194.235.34` |
| `PRODUCTION_PORT` | Confirmed SSH port; do not assume `22` |
| `PRODUCTION_PORT` | `22` (confirmed on 2026-08-09) |
| `PRODUCTION_USER` | `deploy` |
| `PRODUCTION_PATH` | `/opt/jyotisha-production` |
| `PRODUCTION_URL` | `https://jyotisha.chat` |
@@ -392,10 +392,9 @@ async function readSourceUsers(source, sourceAuthSchema, activeAdminUserIds) {
);
}
async function readActiveAdminUserIds(source, sourceTables) {
if (!sourceTables.has("admin_users")) {
throw new SafeProductionMigrationError("source public.admin_users is missing");
}
export async function readActiveAdminUserIds(source, sourceTables, ownerUserId) {
// Legacy production used a single ADMIN_EMAILS allowlist and has no admin tables.
if (!sourceTables.has("admin_users")) return new Set([ownerUserId]);
const result = await source.query(
"select user_id from public.admin_users where revoked_at is null order by user_id",
);
@@ -442,6 +441,7 @@ async function assertNoUnmappedSourceTables(source, sourceTables, targetTables)
}
async function assertActiveAdminRoles(source, sourceTables, ownerUserId) {
if (!sourceTables.has("admin_users")) return;
if (!sourceTables.has("admin_user_roles") || !sourceTables.has("admin_roles")) {
const result = await source.query(
`select count(*)::bigint as count from public.admin_users where revoked_at is null and user_id <> $1`,
@@ -715,7 +715,7 @@ async function preflightContext(source, target, config, { requireEmpty = true }
readSchema(source, "auth"),
readSchema(target, "public"),
]);
const activeAdminUserIds = await readActiveAdminUserIds(source, sourcePublic);
const activeAdminUserIds = await readActiveAdminUserIds(source, sourcePublic, config.ownerUserId);
const users = await readSourceUsers(source, sourceAuth, activeAdminUserIds);
assertActiveAdminUsers(users, activeAdminUserIds, config.ownerUserId);
if (requireEmpty) await assertTargetEmpty(target, targetPublic);
@@ -10,6 +10,7 @@ import {
normalizeAuthUser,
normalizeAuthUsers,
parseMode,
readActiveAdminUserIds,
readConfiguration,
readSchema,
rowsSha256,
@@ -126,6 +127,20 @@ test("production identity preflight rejects duplicate canonical emails", () => {
);
});
test("legacy production without admin tables promotes only the designated Owner", async () => {
const ownerId = "018f4e6d-7a11-7000-8000-000000000001";
const source = {
async query() {
throw new Error("legacy fallback must not query a missing admin_users table");
},
};
assert.deepEqual(
[...await readActiveAdminUserIds(source, new Map(), ownerId)],
[ownerId],
);
});
test("active administrators become usable identity admins", () => {
const ownerId = "018f4e6d-7a11-7000-8000-000000000001";
const adminId = "018f4e6d-7a11-7000-8000-000000000002";