From 60cbe8a75e92009722c73aa7f5eb1d4c74e67392 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 26 Aug 2026 20:21:49 +0800 Subject: [PATCH] fix(web): read migration filenames through a definer instead of app_runtime Staging web never became healthy because /api/health selected migration.schema_migrations as app_runtime, which is forbidden, so Docker rolled the image back. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 16 ++++++++++ frontend/src/app/api/health/route.ts | 30 ++++++++++++------- frontend/src/lib/health-database-contract.ts | 3 +- ..._rectification_migration_ledger_health.sql | 25 ++++++++++++++++ frontend/tests/health-deployment.test.ts | 8 +++-- 5 files changed, 69 insertions(+), 13 deletions(-) create mode 100644 frontend/supabase/migrations/20260826030000_rectification_migration_ledger_health.sql diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 5c0266aa..bb383955 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -5803,6 +5803,22 @@ - 复发自:BUG-394(holdout 预留后未把门槛改成 training);BUG-395(staging publish 被 holdout 类型和重复 re-export 挡住) - 修复版本:待发布 +## BUG-397 | staging web 因 health 直查 migration 账本而不健康 + +- 状态:resolved +- 首次发现:2026-08-26 +- 最近更新:2026-08-26 +- 影响面:`GET /api/health`、`deploy-staging.yml` run `2094`、`app_runtime` 权限、生时纠正 schema 合同 +- 用户现象:quality gate run `2093` 成功后自动 Deploy staging。web 容器一直不健康,部署回滚。公网仍为上一成功 SHA `0511bc48`。 +- 触发条件:self-hosted web 用 `APP_DATABASE_URL`(`app_runtime`)跑健康检查;compose healthcheck 把非 2xx 当成失败。 +- 根因:`app_runtime` 按 foundation 合同不得 `SELECT migration.schema_migrations`。health 直查该表后权限失败被标 `blocked`,接口 503,Docker 判定 web unhealthy。 +- 修复:新增 `20260826030000_rectification_migration_ledger_health.sql`,`SECURITY DEFINER` 函数 `rectification_schema_migration_filenames()` 只把文件名暴露给 `app_runtime`。health 改查该函数。账本可读且缺合同文件才 `blocked`/503;查询失败为 `degraded`,不把容器打挂。合同升到 `v4`。不把业务 SQL 拷进 `frontend/db/migrations`。 +- 验证:`frontend/tests/health-deployment.test.ts`。staging 必须先 `Migrate Staging Database` 再 `Deploy staging`;`GET /api/health` 的 `deployment.gitCommit` 等于本次 SHA,且 `database.requiredMigrationsPresent=true`、`rectificationContractVersion=v4`。 +- 防复发:health 不得直查 `migration.schema_migrations`。`app_runtime` 不得获得该表 SELECT。缺迁移仍由 checker 拦应用发布。不得只凭 `gitCommit` 声称迁移已执行。 +- 相关记录:BUG-396、BUG-144、BUG-127 +- 复发自:BUG-396(health 要证明迁移,但走了 `app_runtime` 禁止的账本查询) +- 修复版本:待发布 + ## BUG-379 | 生时纠正已记入学后仍编造高考年并再问入学 - 状态:resolved diff --git a/frontend/src/app/api/health/route.ts b/frontend/src/app/api/health/route.ts index 32dc6468..78c5054e 100644 --- a/frontend/src/app/api/health/route.ts +++ b/frontend/src/app/api/health/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { Pool } from "pg"; import { getTruthSourceRuntimeIdentity } from "@/lib/truth-source-runtime-identity"; import { loadLanguageModelCatalog } from "@/lib/model-catalog"; import { @@ -68,12 +69,17 @@ async function rectificationMigrationCheck(): Promise<{ return { check: { status: "blocked", message: "missing:APP_DATABASE_URL" }, database: empty }; } const started = Date.now(); - const { Client } = await import("pg"); - const client = new Client({ connectionString: url, connectionTimeoutMillis: 2000 }); + const pool = new Pool({ + connectionString: url, + max: 1, + connectionTimeoutMillis: 2000, + idleTimeoutMillis: 1000, + allowExitOnIdle: true, + application_name: "jyotisha-health", + }); try { - await client.connect(); - const result = await client.query<{ filename: string }>( - "select filename from migration.schema_migrations order by filename", + const result = await pool.query<{ filename: string }>( + "select filename from public.rectification_schema_migration_filenames()", ); const database = databaseHealthFromFilenames(result.rows.map((row) => row.filename)); return { @@ -89,14 +95,14 @@ async function rectificationMigrationCheck(): Promise<{ } catch (error) { return { check: { - status: "blocked", + status: "degraded", message: error instanceof Error ? error.name : "database_migration_query_failed", latencyMs: Date.now() - started, }, database: empty, }; } finally { - await client.end().catch(() => undefined); + await pool.end().catch(() => undefined); } } @@ -132,13 +138,17 @@ export async function GET() { supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]), supabaseServiceRole: envCheck(["SUPABASE_SERVICE_ROLE_KEY"]), }; - const migrations = await rectificationMigrationCheck(); + const [migrations, modelCatalog, jyotishApi] = await Promise.all([ + rectificationMigrationCheck(), + modelCatalogCheck(), + jyotishApiCheck(), + ]); const checks = { web: { status: "ok" } satisfies Check, ...databaseChecks, modelProviderEncryption: envCheck(["MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY"]), - modelCatalog: await modelCatalogCheck(), - jyotishApi: await jyotishApiCheck(), + modelCatalog, + jyotishApi, rectificationMigrations: migrations.check, researchTruthSource: { status: truthSourceIdentity.status, diff --git a/frontend/src/lib/health-database-contract.ts b/frontend/src/lib/health-database-contract.ts index 1450b653..d3c66c3f 100644 --- a/frontend/src/lib/health-database-contract.ts +++ b/frontend/src/lib/health-database-contract.ts @@ -2,11 +2,12 @@ * Health contract for rectification schema. gitCommit proves the web image; * latestMigration proves the business database actually applied the SQL. */ -export const RECTIFICATION_CONTRACT_VERSION = "v3"; +export const RECTIFICATION_CONTRACT_VERSION = "v4"; export const REQUIRED_RECTIFICATION_MIGRATIONS = [ "20260826010000_rectification_inference_round_audit.sql", "20260826020000_rectification_choice_focus_identity.sql", + "20260826030000_rectification_migration_ledger_health.sql", ] as const; export type DatabaseHealth = Readonly<{ diff --git a/frontend/supabase/migrations/20260826030000_rectification_migration_ledger_health.sql b/frontend/supabase/migrations/20260826030000_rectification_migration_ledger_health.sql new file mode 100644 index 00000000..4de600eb --- /dev/null +++ b/frontend/supabase/migrations/20260826030000_rectification_migration_ledger_health.sql @@ -0,0 +1,25 @@ +-- app_runtime must not SELECT migration.schema_migrations (foundation privilege +-- contract). Health still needs the applied filenames, so expose a definer that +-- returns only those names. Business schema only; do not copy into +-- frontend/db/migrations (BUG-127 / BUG-144). + +begin; + +create or replace function public.rectification_schema_migration_filenames() +returns table(filename text) +language sql +stable +security definer +set search_path = '' +as $$ + select m.filename + from migration.schema_migrations as m + order by m.filename; +$$; + +revoke all on function public.rectification_schema_migration_filenames() + from public, anon, authenticated; +grant execute on function public.rectification_schema_migration_filenames() + to app_runtime; + +commit; diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index c63209a4..7a65c71e 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -49,7 +49,8 @@ test("health endpoint exposes deployment identity for production verification", assert.match(source, /rectificationMigrations/); assert.match(contract, /20260826010000_rectification_inference_round_audit\.sql/); assert.match(contract, /20260826020000_rectification_choice_focus_identity\.sql/); - assert.match(contract, /RECTIFICATION_CONTRACT_VERSION = "v3"/); + assert.match(contract, /20260826030000_rectification_migration_ledger_health\.sql/); + assert.match(contract, /RECTIFICATION_CONTRACT_VERSION = "v4"/); assert.match(source, /loadLanguageModelCatalog/); assert.match(source, /const defaults = catalog\.models\.filter/); assert.match(source, /defaults\.length === 1/); @@ -58,6 +59,9 @@ test("health endpoint exposes deployment identity for production verification", assert.match(source, /: \{ status: "degraded", message \}/); assert.match(source, /status: response\.ok \? "ok" : "blocked"/); assert.match(source, /status === "blocked" \? 503 : 200/); + assert.match(source, /select filename from public\.rectification_schema_migration_filenames\(\)/); + assert.doesNotMatch(source, /select filename from migration\.schema_migrations/); + assert.doesNotMatch(source, /await import\("pg"\)/); assert.doesNotMatch(source, /anyEnvCheck\(\["LLM_MODELS_JSON"|OPENAI_API_KEY|DEEPSEEK_API_KEY|LLM_API_KEY/); }); @@ -70,7 +74,7 @@ test("health database contract requires the rectification identity migrations", ...REQUIRED_RECTIFICATION_MIGRATIONS, ]); assert.equal(present.requiredMigrationsPresent, true); - assert.equal(present.latestMigration, REQUIRED_RECTIFICATION_MIGRATIONS[1]); + assert.equal(present.latestMigration, REQUIRED_RECTIFICATION_MIGRATIONS.at(-1)); }); test("GitHub mirror cannot deploy production", () => {