Files
codex-app-server/cloud/tests/test_p2_migrations.py
T
host 64995fafab P2-ops: migration alignment, deploy test list, monitor alerts/single-host, backup/rollback closure, Caddy template
- migrations: MIGRATION_TABLES now equals POSTGRES_SCHEMA tables
  (uploads, task_attachments, task_followups, environments added);
  SEQUENCE_TABLES matches IDENTITY tables; task_followups
  sender_task_id column ensured before insert; UNMIGRATED_TABLES
  and migration_coverage() report tables with no PG schema instead
  of silently skipping them; CLI prints the coverage report.
- deploy: DEFAULT_TEST_SUITES gains deploy/tests (discover -s only,
  never package path); regression tests pin the default list.
- monitor: MONITOR_SINGLE_HOST=1 makes the server check single-host;
  default stays dual to avoid silent downgrade; server_mode is in
  the JSON. Optional fail-only alert outlet (webhook or mail,
  default off, alert body is pass/fail JSON only, no secrets).
- backup/rollback: README and restore-drill document what is
  recoverable (DB, code) vs not (worktrees, traces, uploads, host
  configs); rollback.sh shares the server-unit list and dual-port
  health success standard with host_deploy.sh (sync test guards it).
- public entry: Caddyfile uses native {$DOMAIN}/{$ACME_EMAIL}
  variables plus a caddy.service template; README documents the
  steps and the current BT-nginx production status.
2026-10-27 16:37:28 +00:00

204 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""P2-ops 迁移对齐:迁移清单必须等于 Postgres schema 表集合,
未迁移项必须被显式报告,绝不能静默跳过。
不用真实 Postgres:迁移本体用一个假 PG 连接跑(只记录
INSERT / ALTER,不连库);schema 对齐直接读 POSTGRES_SCHEMA。
真实 PG 上的幂等性仍由 test_postgres_integration.py 覆盖
(有 CLOUD_TEST_PG_DSN 时才跑)。
"""
import os
import re
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path
try:
from cloud.db import POSTGRES_SCHEMA
from cloud.migrations import (
MIGRATION_TABLES,
SEQUENCE_TABLES,
UNMIGRATED_TABLES,
migrate_sqlite_to_postgres,
migration_coverage,
schema_tables,
)
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.db import POSTGRES_SCHEMA
from cloud.migrations import (
MIGRATION_TABLES,
SEQUENCE_TABLES,
UNMIGRATED_TABLES,
migrate_sqlite_to_postgres,
migration_coverage,
schema_tables,
)
REPO_ROOT = Path(__file__).resolve().parents[2]
class FakeCursor:
def __init__(self, rows=None, rowcount=1):
self._rows = rows or []
self.rowcount = rowcount
def fetchall(self):
return self._rows
class FakePgConn:
"""只记录 SQL 的假 PG 连接:init_postgres_schema 的 DDL
直接忽略;PRAGMA 按构造时给的列集合回答(row[1] 为列名,
与 init_postgres_schema / 迁移补列代码的取法一致)。"""
def __init__(self, pragma_columns=None):
# pragma_columns: {表名: {列名}};缺省表示“列齐全”。
self.pragma_columns = pragma_columns or {}
self.statements = []
self.inserts = [] # (表名, 列名元组)
self.alters = []
self.commits = 0
def execute(self, sql, params=()):
self.statements.append(sql)
if sql.startswith("PRAGMA table_info("):
table = sql[len("PRAGMA table_info("):].rstrip(")")
cols = self.pragma_columns.get(table)
if cols is None:
# 未指定时假装列齐全:projects 含 repo_url,
# task_followups 含 sender_task_id。
cols = {"repo_url"} if table == "projects" \
else {"sender_task_id"}
rows = [(i, c, None) for i, c in enumerate(sorted(cols))]
return FakeCursor(rows=rows)
m = re.search(r'INSERT INTO "(\w+)" \(([^)]*)\)', sql)
if m:
cols = tuple(c.strip().strip('"')
for c in m.group(2).split(","))
self.inserts.append((m.group(1), cols))
return FakeCursor(rowcount=1)
if sql.startswith("ALTER TABLE"):
self.alters.append(sql)
return FakeCursor(rowcount=0)
return FakeCursor(rowcount=0)
def commit(self):
self.commits += 1
def _make_sqlite(path):
conn = sqlite3.connect(path)
conn.execute("CREATE TABLE uploads(id TEXT PRIMARY KEY, created_by TEXT,"
" created_at REAL, status TEXT)")
conn.execute("CREATE TABLE task_attachments(task_id TEXT, bundle_id TEXT,"
" rel_path TEXT, size INTEGER,"
" PRIMARY KEY(task_id, rel_path))")
conn.execute("CREATE TABLE task_followups(id TEXT PRIMARY KEY,"
" task_id TEXT, project_id TEXT, body TEXT, mode TEXT,"
" status TEXT, created_by TEXT, sender_task_id TEXT,"
" created_at REAL, updated_at REAL)")
conn.execute("CREATE TABLE environments(id TEXT PRIMARY KEY,"
" project_id TEXT, name TEXT, branch TEXT, executor TEXT,"
" status TEXT, error_text TEXT, repo_url TEXT,"
" created_by TEXT, created_at REAL, updated_at REAL)")
conn.execute("INSERT INTO uploads VALUES(?, ?, ?, ?)",
("up1", "u1", 1.0, "done"))
conn.execute("INSERT INTO task_attachments VALUES(?, ?, ?, ?)",
("t1", "b1", "a.txt", 3))
conn.execute("INSERT INTO task_followups VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
("f1", "t1", "p1", "hi", "queued", "pending", "u1",
"sender-task", 1.0, 1.0))
conn.execute("INSERT INTO environments VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
("e1", "p1", "dev", "main", "docker", "ready", "",
"https://example.invalid/r", "u1", 1.0, 1.0))
conn.commit()
conn.close()
class TestMigrationAlignment(unittest.TestCase):
def test_migration_tables_equal_schema_tables(self):
# 核心对齐断言:以前漏掉的 4 张表补进后,两边集合必须一致。
self.assertEqual(set(MIGRATION_TABLES), schema_tables())
for table in ("uploads", "task_attachments",
"task_followups", "environments", "user_quotas"):
self.assertIn(table, MIGRATION_TABLES)
def test_sequence_tables_equal_identity_tables(self):
# 序列拨动清单必须等于 schema 里 IDENTITY 列的表集合:
# TEXT 主键的新表不需要拨序列,漏拨或多拨都会在这里变红。
identity = set()
for statement in POSTGRES_SCHEMA:
m = re.search(r"CREATE TABLE IF NOT EXISTS (\w+)", statement)
if m and "GENERATED BY DEFAULT AS IDENTITY" in statement:
identity.add(m.group(1))
self.assertEqual(set(SEQUENCE_TABLES), identity)
def test_unmigrated_are_real_sqlite_tables_not_in_schema(self):
# 未迁移清单不得是凭空编的:每项都必须在 cloud/ 源码里
# 真有 CREATE TABLE,且确实不在 PG schema 里;同时不得
# 与迁移清单重叠(重叠即“既迁又报未迁”,自相矛盾)。
sources = ""
for name in os.listdir(REPO_ROOT / "cloud"):
if name.endswith(".py"):
with open(REPO_ROOT / "cloud" / name) as f:
sources += f.read()
schema = schema_tables()
for table in UNMIGRATED_TABLES:
self.assertIn("CREATE TABLE IF NOT EXISTS %s" % table, sources,
"%s 不在任何 SQLite DDL 里,清单已过期" % table)
self.assertNotIn(table, schema)
self.assertNotIn(table, MIGRATION_TABLES)
def test_coverage_reports_no_missing(self):
coverage = migration_coverage()
self.assertEqual(coverage["missing_from_migration"], ())
self.assertEqual(coverage["unmigrated_no_pg_schema"],
tuple(UNMIGRATED_TABLES))
self.assertEqual(set(coverage["skipped_by_design"]),
{"kb_fts", "sqlite_sequence"})
class TestMigrateNewTables(unittest.TestCase):
def test_new_tables_migrated_and_sender_column_added(self):
# PG 侧 task_followups 尚无 sender_task_id:迁移必须先
# ALTER 补列,再把含该列的行插过去,而不是撞未知列失败。
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, "src.db")
_make_sqlite(src)
fake = FakePgConn(pragma_columns={
"projects": {"repo_url"},
"task_followups": {"id", "task_id", "project_id", "body",
"mode", "status", "created_by",
"created_at", "updated_at"},
})
result = migrate_sqlite_to_postgres(src, pg_conn=fake)
for table in ("uploads", "task_attachments",
"task_followups", "environments"):
self.assertEqual(result.get(table), 1, table)
self.assertTrue(
any("task_followups" in a and "sender_task_id" in a
for a in fake.alters),
"未发出 sender_task_id 补列 ALTER:%s" % fake.alters)
fu_inserts = [cols for t, cols in fake.inserts
if t == "task_followups"]
self.assertEqual(len(fu_inserts), 1)
self.assertIn("sender_task_id", fu_inserts[0])
def test_sender_column_present_skips_alter(self):
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, "src.db")
_make_sqlite(src)
fake = FakePgConn() # 缺省:列齐全
migrate_sqlite_to_postgres(src, pg_conn=fake,
tables=("task_followups",))
# init_postgres_schema 自身会给 projects/tasks 补列,
# 这里只断言 task_followups 没有重复补列。
self.assertEqual(
[a for a in fake.alters if "task_followups" in a], [])
if __name__ == "__main__":
unittest.main()