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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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;
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user