diff --git a/deploy/README.md b/deploy/README.md index b2a21423..8b1eb3dc 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -401,7 +401,8 @@ report list / detail / create smoke”): `GET /api/reports` 200, owned ### 备份 - 工作流:`.gitea/workflows/backup-production-daily.yml`。北京时间每天 04:00,也可手动触发。只从 `main` 运行,调用 `deploy/run-production-recovery.sh`,不另写一套导出。 -- 每日文件前缀是 `production-daily-`。手动恢复点仍是 `production-pre-migration-`。清理脚本 `deploy/prune-production-daily-backups.sh` 只删超过 7 天的每日文件和对应的校验文件。 +- 每日文件前缀是 `production-daily-`。手动恢复点仍是 `production-pre-migration-`。清理脚本 `deploy/prune-production-daily-backups.sh` 只删超过 7 天的每日文件和对应的校验文件,不会删 `production-pre-migration-` 手动恢复点。 +- 每日备份只做存档和还原核对,不是发布用的恢复点。它不改 `/opt/jyotisha-production/.state/recovery-baseline.env`。核对结果另写在同目录的 `daily-backup-latest.env`,只有文件名、sha256、核对时间和格式版本,没有数据和密钥。发布、迁移、回退都不读这个文件。上线前仍要手动点 Create production recovery。 - Gitea 产物默认保留 14 天。手动恢复点工作流仍是 30 天,没有改。 - 还原步骤:`docs/operations/production-restore-drill-20261014.md`。 diff --git a/deploy/run-production-recovery.sh b/deploy/run-production-recovery.sh index ba3a1ffa..c2fc90e7 100755 --- a/deploy/run-production-recovery.sh +++ b/deploy/run-production-recovery.sh @@ -6,10 +6,26 @@ umask 077 : "${DEPLOY_PATH:?DEPLOY_PATH is required}" : "${RECOVERY_RUN_ID:?RECOVERY_RUN_ID is required}" -[ "$DEPLOY_PATH" = "/opt/jyotisha-production" ] || { - echo "unexpected production path" >&2 - exit 1 -} +# Tests set JYOTISHA_RECOVERY_FIXTURE=1 and point DEPLOY_PATH at a temp directory. +# Production workflows never set it, so the live path check stays closed. +if [ "${JYOTISHA_RECOVERY_FIXTURE:-}" = "1" ]; then + [ -d "$DEPLOY_PATH" ] && [ ! -L "$DEPLOY_PATH" ] || { + echo "unexpected production path" >&2 + exit 1 + } + case "$DEPLOY_PATH" in + /*) ;; + *) + echo "unexpected production path" >&2 + exit 1 + ;; + esac +else + [ "$DEPLOY_PATH" = "/opt/jyotisha-production" ] || { + echo "unexpected production path" >&2 + exit 1 + } +fi [[ "$RECOVERY_RUN_ID" =~ ^[0-9]+$ ]] || { echo "recovery run id must be numeric" >&2 exit 1 @@ -140,6 +156,8 @@ created_compact="$(date -u +%Y%m%dT%H%M%SZ)" created_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" # Daily backups set RECOVERY_BASENAME_PREFIX=production-daily so retention can # tell them from manual recovery points. Unset keeps the original name. +# That same prefix is the switch that skips recovery-baseline.env. Daily runs +# still export, encrypt, and restore-verify, then record daily-backup-latest.env. backup_prefix="production-pre-migration" if [ -n "${RECOVERY_BASENAME_PREFIX:-}" ]; then case "$RECOVERY_BASENAME_PREFIX" in @@ -226,11 +244,21 @@ printf '{\n "mode": "restore_verify",\n "ok": true,\n "backup": "%s",\n "sha chmod 600 "$verify_file" recovery_reference="gitea-actions-run-${RECOVERY_RUN_ID}/${backup_basename}" -state_tmp="$state_directory/recovery-baseline.env.tmp.$$" -printf 'RECOVERY_REFERENCE=%s\nRECOVERY_CREATED_AT=%s\nRECOVERY_SHA256=%s\nRESTORE_VERIFIED=true\nRECOVERY_FORMAT=2\nVERIFY_FILE=%s\n' \ - "$recovery_reference" "$created_at" "$backup_sha256" "$verify_basename" >"$state_tmp" -chmod 600 "$state_tmp" -mv "$state_tmp" "$state_directory/recovery-baseline.env" +if [ "$backup_prefix" = "production-daily" ]; then + daily_tmp="$state_directory/daily-backup-latest.env.tmp.$$" + printf 'BACKUP_BASENAME=%s\nBACKUP_SHA256=%s\nVERIFIED_AT=%s\nBACKUP_FORMAT=2\n' \ + "$backup_basename" "$backup_sha256" "$verified_at" >"$daily_tmp" + chmod 600 "$daily_tmp" + mv "$daily_tmp" "$state_directory/daily-backup-latest.env" + echo "baseline_updated=false" >&2 +else + state_tmp="$state_directory/recovery-baseline.env.tmp.$$" + printf 'RECOVERY_REFERENCE=%s\nRECOVERY_CREATED_AT=%s\nRECOVERY_SHA256=%s\nRESTORE_VERIFIED=true\nRECOVERY_FORMAT=2\nVERIFY_FILE=%s\n' \ + "$recovery_reference" "$created_at" "$backup_sha256" "$verify_basename" >"$state_tmp" + chmod 600 "$state_tmp" + mv "$state_tmp" "$state_directory/recovery-baseline.env" + echo "baseline_updated=true" >&2 +fi printf 'RECOVERY_REFERENCE=%s\nRECOVERY_CREATED_AT=%s\nRECOVERY_VERIFIED_AT=%s\nRECOVERY_SHA256=%s\nRESTORE_VERIFIED=true\nBACKUP_BASENAME=%s\nVERIFY_BASENAME=%s\n' \ "$recovery_reference" "$created_at" "$verified_at" "$backup_sha256" \ diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 1c135ef4..a99cca9c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36268,9 +36268,9 @@ - 修复:新增只从 `main` 运行的每日备份工作流,复用现有加密与还原校验脚本。每日文件用单独前缀,过期清理不会碰到手动恢复点。工作流要等进入 `main` 后才会在北京时间 04:00 跑。本机没有生产主机,没有手动触发。 - 验证:合同测试锁定有每日时刻、只在 `main` 上跑、调用同一个恢复脚本、清理只删过期的每日前缀。虚构目录里,手动恢复点和其他文件都还在。 - 防复发:上述合同测试。清理函数不接受外部传入的前缀。 -- 相关记录:BUG-1350、BUG-1356、BUG-1357。 +- 相关记录:BUG-1350、BUG-1356、BUG-1357、BUG-1358。 - 复发自:无。 -- 修复版本:分支 `codex/beta-safety-net-20261014`。开工时本文件最大号是 BUG-1353,当时写成 BUG-1354。合并 staging 后该号已被更早的答题强度记录占用,按台账规则改为 BUG-1355。 +- 修复版本:分支 `codex/beta-safety-net-20261014`。开工时本文件最大号是 BUG-1353,当时写成 BUG-1354。合并 staging 后该号已被更早的答题强度记录占用,按台账规则改为 BUG-1355。每日备份曾会改写当前恢复点,已由 BUG-1358 分开。 ## BUG-1356 | 站点打不开或磁盘过满时没有告警 @@ -36302,3 +36302,18 @@ - 复发自:无。 - 修复版本:分支 `codex/beta-safety-net-20261014`。原先写成 BUG-1356,合并 staging 后顺延为 BUG-1357。 +## BUG-1358 | 每日自动备份改写了当前恢复点 + +- 状态:resolved +- 首次发现 / 最近更新:2026-10-15 / 2026-10-15 +- 来源:issue #75。产品选 A。修复单 `docs/tasks/TASK-beta-safety-net-fix-20261015.md`。检索本文件的「recovery-baseline」和「每日备份」,没有更早的同类记录。 +- 现象:每天凌晨的自动备份会把「当前恢复点」换成当天的自动备份。上线前手动建的恢复点,隔一夜就没了。 +- 触发条件:每日备份调用 `deploy/run-production-recovery.sh`,前缀是 `production-daily`。脚本结尾无条件写 `.state/recovery-baseline.env`。发布、迁移、回退都读这份文件。 +- 根因:每日备份复用了手动恢复点脚本,脚本不区分「只存档」和「更新当前恢复点」。 +- 修复:前缀为 `production-daily` 时仍完成导出、加密和还原核对(含表权限、列权限、属主),并写校验文件,但跳过 `recovery-baseline.env`。另写 `daily-backup-latest.env`,只有文件名、sha256、核对时间和格式版本。手动创建恢复点的路径不变。 +- 验证:临时目录加假 docker。已有恢复点的内容和修改时间不变;手动路径仍写出 `RECOVERY_FORMAT=2`。发布、迁移、回退所读的文件不引用 `daily-backup-latest.env`。清理仍只删过期的 `production-daily-` 文件。 +- 防复发:上述合同测试。生产工作流不设置测试用的路径开关。 +- 相关记录:BUG-1355。 +- 复发自:无。 +- 修复版本:分支 `codex/beta-safety-net-20261014`。本机没有装到生产主机,也没有点生产按钮。 + diff --git a/docs/operations/production-release-runbook-20261014.md b/docs/operations/production-release-runbook-20261014.md index 63b0edf1..5bc9d3b3 100644 --- a/docs/operations/production-release-runbook-20261014.md +++ b/docs/operations/production-release-runbook-20261014.md @@ -39,6 +39,8 @@ Gitea → Create Production Recovery Point。Use workflow from:`main`。`deplo 通过:成功结束,记下三样东西:`recovery_reference`、`recovery_created_at`、以及它已经写明还原验证通过。主机上的 `/opt/jyotisha-production/.state/recovery-baseline.env` 里必须有 `RECOVERY_FORMAT=2`。没有这一行,或者数字小于 2,都不要往下走:先再点一次创建恢复点。8 月以来不带权限的旧备份不能再拿来回退。加密备份在生产主机的备份目录里,不要打开、不要把密钥贴出来。 +每日自动备份不是这一步的恢复点。它每天只把加密文件存档并做还原核对,不会改 `recovery-baseline.env`。隔夜的自动备份不会换掉你上线前手动建的恢复点。上线前仍然要点 Create production recovery。 + 失败:停。不要迁移。 ## 5. 在主机上演练迁移 diff --git a/docs/tasks/PROGRESS-beta-safety-net-fix-20261015.md b/docs/tasks/PROGRESS-beta-safety-net-fix-20261015.md new file mode 100644 index 00000000..6d900627 --- /dev/null +++ b/docs/tasks/PROGRESS-beta-safety-net-fix-20261015.md @@ -0,0 +1,34 @@ +# 进度 · 内测安全网修复(2026-10-15) + +- 分支:`codex/beta-safety-net-20261014` +- 任务书:`docs/tasks/TASK-beta-safety-net-fix-20261015.md` +- 上一轮已推送:`53d8df257ddef8918034acef36df0b4b7daade2c` +- 产品选 A:每天的自动备份照做,但不改当前恢复点。 + +## 做了什么 + +- 先合并最新 `origin/staging`,没有改写已经推送的提交。合并时本分支的 BUG-1354 和更早的答题强度记录撞号,安全网三条顺延为 BUG-1355、BUG-1356、BUG-1357。这次的修复是 BUG-1358。 +- 用已经存在的 `RECOVERY_BASENAME_PREFIX=production-daily` 当开关,没有另加模式参数。这个前缀下,脚本仍完成导出、加密、还原核对(含表权限、列权限、属主)并写校验文件,但跳过 `recovery-baseline.env`。标准错误打印 `baseline_updated=false`。另写 `.state/daily-backup-latest.env`,只有文件名、sha256、核对时间、`BACKUP_FORMAT=2`。 +- 不设这个前缀时,仍写 `recovery-baseline.env`,里面有 `RECOVERY_FORMAT=2`,标准错误打印 `baseline_updated=true`,不改已经存在的每日记录。 +- 标准输出仍是原来的 7 行。因此没有改 `.gitea` 里「恰好 7 行」的检查,也没有改四个已有生产工作流。 +- 再确认清理脚本只删严格匹配的过期 `production-daily-` 文件。既有测试里的 `production-pre-migration-` 手动恢复点仍被保留。 +- `docs/operations/production-release-runbook-20261014.md` 和 `deploy/README.md` 写明:每日备份只是存档和核对,上线前仍要手动点 Create production recovery。 + +## 跑过的检查 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 安全网与隐私 | `.venv\Scripts\python.exe -m pytest tests/test_beta_safety_net.py tests/test_repo_privacy_markers.py -q` | 78 通过(安全网 16,隐私 62),退出码 0 | +| 前端合同 | `node node_modules/tsx/dist/cli.mjs --test tests/runtime-sources-no-test-imports.test.ts tests/staging-backend-workflows.test.ts` | 46 项:41 通过,5 失败,0 取消。失败是本机缺口,见下方偏离。恢复点工作流的文本检查通过 | +| shellcheck | 无 | 本机没有这条命令 | +| 前端 tsc / lint / 全量测试 | 未跑 | 没有改网页 | +| Python 快速门 | 未跑 | 按本轮约定不跑 | +| 数据库测试 | 未跑 | 没有改数据库 | + +## 偏离 + +- `baseline_updated` 打在标准错误,避免把每日备份和手动恢复点工作流对标准输出「恰好 7 行」的检查撑破。没有改 `.gitea/**`。 +- 测试要在临时目录里跑脚本。生产路径检查保持原样;只有 `JYOTISHA_RECOVERY_FIXTURE=1` 时才允许别的目录。会调用恢复脚本的两个工作流都没有设置这个变量。 +- 本机 Git Bash 没有 `flock`,pytest 的临时目录也不能改 Unix 权限。测试只在自己的 PATH 里放了假的 `docker`、`sudo`、`df`、`install`、`chmod`、`flock`。生产脚本仍调用真命令。 +- 没有在这台 Windows 上安装到生产,没有点任何生产按钮。主机安装和一次恢复演练仍留给产品。 +- 前端合同 5 项失败都不是这次改的脚本:本机 Python 没有 yaml 模块(直接跑时退出码 9009)、没有 rsync(退出码 127)、临时目录不能改权限(install 报 Permission denied)、文档范围脚本退出码 49。这四类脚本本次都没改。恢复点脚本的语法检查和文本合同是通过的。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index b31d3ab7..8c770907 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -339,7 +339,7 @@ | `TASK-release-path-production-fix-20261014.md` | `PROGRESS-release-path-production-fix-20261014.md` | **#74 第 1 次打回**:发布规则成立;测试写死 Windows 路径(Linux 全红)、主机演练依赖主机 node 且会留下生产数据副本、docs-only 头时回滚被拒;另补 CHECK_RELEASE_GATE 断言与 OTP 守卫规范化 | 已修,第 2 次仍未过 | `codex/release-path-production-20261014` | | `TASK-release-path-production-fix2-20261014.md` | `PROGRESS-release-path-production-fix2-20261014.md` | **#74 修复单 2(产品选 A)**:恢复点要带属主与权限,旧格式不得回退;演练按备份里的授权比对,并在真实 PostgreSQL 17 上跑通一次 | 待验收 | `codex/release-path-production-20261014` | | `TASK-beta-safety-net-20261014.md` | `PROGRESS-beta-safety-net-20261014.md` | **内测安全网**:每日备份 + 恢复演练、探活告警、本地错误汇总、日志轮转、按 requestId 查日志。发布通路与 #58 已在开工时的 `origin/staging` 上。主机安装和一次演练留给产品 | 待验收 | `codex/beta-safety-net-20261014`(BUG-1355~1357) | -| `TASK-beta-safety-net-fix-20261015.md` | — | **#75 产品选 A**:授权新增每日备份定时工作流,但每日备份不得改写 `recovery-baseline.env`(恢复点仍只手动创建);另记 `daily-backup-latest.env` | 待领取 | `codex/beta-safety-net-20261014` | +| `TASK-beta-safety-net-fix-20261015.md` | `PROGRESS-beta-safety-net-fix-20261015.md` | **#75 产品选 A**:每日备份照常做,但不改当前恢复点;另记 `daily-backup-latest.env` | 待验收 | `codex/beta-safety-net-20261014`(BUG-1358) | | `TASK-beta-capacity-20261014.md` | — | **内测容量**:按用户限流、重计算短排队与对话重试、兑换码当邀请码、staging 10/20 并发压测。**等 #58 合入后开工,压测等 #57 合入** | 待前置 | — | | `TASK-beta-capacity-fix-20261015.md` | — | **#76 第 1 次打回**:`a54f75e5` 限流与短排队逻辑通过;源码文本断言未跟改、插入新测试使隐私白名单行号错位 | 待领取 | `codex/beta-capacity-20261014` | | `TASK-special-lagnas-native-20261008.md` | `PROGRESS-special-lagnas-native-20261008.md` | **特殊上升点原生计算(线上缺陷)**:sync6 U6 运行时调 PyJHora,生产镜像没有 PyJHora → staging 上 Hora/Ghati/Sree/Indu/Pranapada 全 blocked;原生实现、PyJHora 只作冻结对照、快速门禁止运行时 import jhora;最先做 | 已验收(已部署 staging `795b8845`,BUG-1273) | `codex/special-lagnas-native-20261008`(未上线;部署以 `/api/health` 为准) | diff --git a/tests/test_beta_safety_net.py b/tests/test_beta_safety_net.py index 8c72f92d..2376e200 100644 --- a/tests/test_beta_safety_net.py +++ b/tests/test_beta_safety_net.py @@ -167,6 +167,262 @@ def test_recovery_script_keeps_manual_names_unless_daily_prefix() -> None: assert "openssl enc -aes-256-cbc -salt -pbkdf2" in text assert "usage_percent < 70" in text assert "flock -n 9" in text + assert '[ "$DEPLOY_PATH" = "/opt/jyotisha-production" ]' in text + assert 'echo "baseline_updated=false" >&2' in text + assert 'echo "baseline_updated=true" >&2' in text + assert "daily-backup-latest.env" in text + + +def install_fake_recovery_bin(bin_dir: Path) -> None: + bin_dir.mkdir() + write_text( + bin_dir / "sudo", + "#!/usr/bin/env bash\nset -euo pipefail\nif [[ \"${1:-}\" == \"-n\" ]]; then shift; fi\nexec \"$@\"\n", + ) + write_text( + bin_dir / "df", + "#!/usr/bin/env bash\nprintf '%s\\n' 'Filesystem 1024-blocks Used Available Capacity Mounted' 'tmpfs 100 10 90 10% /'\n", + ) + # Windows cannot chmod pytest's temp directories. These stand-ins only + # exist on the test PATH. The production script still calls the real commands. + write_text( + bin_dir / "chmod", + "#!/usr/bin/env bash\nexit 0\n", + ) + # Git Bash on this Windows machine has no flock. The production script still calls it. + write_text( + bin_dir / "flock", + "#!/usr/bin/env bash\nexit 0\n", + ) + write_text( + bin_dir / "install", + """#!/usr/bin/env bash +set -euo pipefail +dirs=() +while [[ $# -gt 0 ]]; do + case "$1" in + -d) shift ;; + -m) shift; shift ;; + --) shift; break ;; + -*) shift ;; + *) dirs+=("$1"); shift ;; + esac +done +mkdir -p "${dirs[@]}" +""", + ) + write_text( + bin_dir / "docker", + """#!/usr/bin/env bash +set -euo pipefail +cmd="${1:-}" +shift || true +case "$cmd" in + ps) + printf '%s\\n' fixturecontainer + ;; + inspect) + printf '%s\\n' sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + ;; + run) + exit 0 + ;; + exec) + tool="" + sql="" + prev="" + for arg in "$@"; do + if [[ "$prev" == "-c" ]]; then + sql="$arg" + fi + case "$arg" in + pg_dump|pg_restore|psql) tool="$arg" ;; + esac + prev="$arg" + done + case "$tool" in + pg_dump) + printf '%s\\n' fixture-dump + ;; + pg_restore) + cat >/dev/null + ;; + psql) + case "$sql" in + *role_table_grants*) printf '%s\\n' 'reader|SELECT|profiles' ;; + *column_privileges*) printf '%s\\n' 'reader|SELECT|profiles|id' ;; + *pg_get_userbyid*) printf '%s\\n' 'profiles|app' ;; + *identity.users*) printf '%s\\n' '1|2|3|4' ;; + *"CREATE DATABASE"*|*"DROP DATABASE"*) ;; + *) + printf 'unexpected psql\\n' >&2 + exit 1 + ;; + esac + ;; + *) + printf 'unexpected docker exec\\n' >&2 + exit 1 + ;; + esac + ;; + *) + printf 'unexpected docker %s\\n' "$cmd" >&2 + exit 1 + ;; +esac +""", + ) + subprocess.run( + [BASH, "-lc", 'chmod +x "$1/sudo" "$1/df" "$1/docker" "$1/chmod" "$1/install" "$1/flock"', "chmod", bash_path(bin_dir)], + check=True, + ) + + +def write_recovery_env(deploy: Path) -> None: + write_text( + deploy / ".env.production.database", + "\n".join( + [ + "POSTGRES_DB=fixturedatabase", + "POSTGRES_USER=fixtureuser", + "POSTGRES_PASSWORD=fixturepassword", + "PRODUCTION_BACKUP_ENCRYPTION_KEY=fixtureencryptionkey", + "", + ] + ), + ) + + +def run_recovery(deploy: Path, bin_dir: Path, prefix: str | None) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env.pop("FEISHU_WEBHOOK_URL", None) + env.pop("FEISHU_SIGN_SECRET", None) + env.pop("RECOVERY_BASENAME_PREFIX", None) + env["DEPLOY_PATH"] = bash_path(deploy) + env["RECOVERY_RUN_ID"] = "4242" + env["JYOTISHA_RECOVERY_FIXTURE"] = "1" + if prefix: + env["RECOVERY_BASENAME_PREFIX"] = prefix + return subprocess.run( + [ + BASH, + "-lc", + 'export PATH="$1:$PATH"; exec bash "$2"', + "bash", + bash_path(bin_dir), + bash_path(RECOVERY_SCRIPT), + ], + cwd=ROOT, + env=env, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + + +def parse_env(text: str) -> dict[str, str]: + parsed: dict[str, str] = {} + for line in text.splitlines(): + key, value = line.split("=", 1) + parsed[key] = value + return parsed + + +def test_daily_backup_does_not_replace_the_recovery_point(tmp_path: Path) -> None: + deploy = tmp_path / "deploy" + state = deploy / ".state" + state.mkdir(parents=True) + write_recovery_env(deploy) + baseline = state / "recovery-baseline.env" + original = ( + "RECOVERY_REFERENCE=gitea-actions-run-1/production-pre-migration-20261014T120000Z.dump.enc\n" + "RECOVERY_CREATED_AT=2026-10-14T12:00:00Z\n" + "RECOVERY_SHA256=" + ("ab" * 32) + "\n" + "RESTORE_VERIFIED=true\n" + "RECOVERY_FORMAT=2\n" + "VERIFY_FILE=production-pre-migration-20261014T120000Z-restore-verify.json\n" + ) + write_text(baseline, original) + subprocess.run( + [BASH, "-lc", 'touch -d "@$1" "$2"', "touch", "1700000000", bash_path(baseline)], + check=True, + ) + before_ns = baseline.stat().st_mtime_ns + before_bytes = baseline.read_bytes() + bin_dir = tmp_path / "bin" + install_fake_recovery_bin(bin_dir) + completed = run_recovery(deploy, bin_dir, "production-daily") + assert completed.returncode == 0, completed.stderr + assert baseline.read_bytes() == before_bytes + assert baseline.stat().st_mtime_ns == before_ns + assert "baseline_updated" not in completed.stdout + assert "baseline_updated=false" in completed.stderr.splitlines() + assert "baseline_updated=true" not in completed.stderr + assert len(completed.stdout.splitlines()) == 7 + stdout_env = parse_env(completed.stdout) + assert stdout_env["BACKUP_BASENAME"].startswith("production-daily-") + daily = state / "daily-backup-latest.env" + daily_env = parse_env(daily.read_text(encoding="utf-8")) + assert set(daily_env) == {"BACKUP_BASENAME", "BACKUP_SHA256", "VERIFIED_AT", "BACKUP_FORMAT"} + assert daily_env["BACKUP_FORMAT"] == "2" + assert daily_env["BACKUP_BASENAME"] == stdout_env["BACKUP_BASENAME"] + assert daily_env["BACKUP_SHA256"] == stdout_env["RECOVERY_SHA256"] + daily_text = daily.read_text(encoding="utf-8") + assert "fixturepassword" not in daily_text + assert "fixtureencryptionkey" not in daily_text + assert "fixturedatabase" not in daily_text + verify = deploy / "backups" / stdout_env["VERIFY_BASENAME"] + payload = json.loads(verify.read_text(encoding="utf-8")) + assert payload["checks"]["table_grants_match"] is True + assert payload["checks"]["column_privileges_match"] is True + assert payload["checks"]["table_owners_match"] is True + assert "fixturepassword" not in verify.read_text(encoding="utf-8") + + +def test_manual_recovery_still_writes_format_two(tmp_path: Path) -> None: + deploy = tmp_path / "deploy" + state = deploy / ".state" + state.mkdir(parents=True) + write_recovery_env(deploy) + sentinel = state / "daily-backup-latest.env" + write_text(sentinel, "BACKUP_BASENAME=keep\nBACKUP_FORMAT=2\n") + before = sentinel.read_bytes() + bin_dir = tmp_path / "bin" + install_fake_recovery_bin(bin_dir) + completed = run_recovery(deploy, bin_dir, None) + assert completed.returncode == 0, completed.stderr + assert "baseline_updated=true" in completed.stderr.splitlines() + assert "baseline_updated=false" not in completed.stderr + assert len(completed.stdout.splitlines()) == 7 + baseline_text = (state / "recovery-baseline.env").read_text(encoding="utf-8") + baseline = parse_env(baseline_text) + assert baseline["RECOVERY_FORMAT"] == "2" + assert baseline["VERIFY_FILE"].startswith("production-pre-migration-") + assert baseline["RECOVERY_REFERENCE"].startswith("gitea-actions-run-4242/production-pre-migration-") + assert "fixturepassword" not in baseline_text + assert "fixtureencryptionkey" not in baseline_text + assert parse_env(completed.stdout)["BACKUP_BASENAME"].startswith("production-pre-migration-") + assert sentinel.read_bytes() == before + + +def test_release_migration_and_rollback_ignore_daily_backup_record() -> None: + readers = [ + ROOT / ".gitea" / "workflows" / "deploy-production.yml", + ROOT / ".gitea" / "workflows" / "migrate-production-database.yml", + ROOT / "deploy" / "run-production-deploy.sh", + ROOT / "deploy" / "run-production-migration.sh", + ] + for path in readers: + text = path.read_text(encoding="utf-8") + assert "daily-backup-latest.env" not in text, path.name + assert "recovery-baseline.env" in text + for path in (WORKFLOW, RECOVERY_WORKFLOW): + text = path.read_text(encoding="utf-8") + assert "JYOTISHA_RECOVERY_FIXTURE" not in text + assert '[[ "$(wc -l <"$ssh_root/recovery-output.env" | tr -d \' \')" == 7 ]]' in text def test_prune_deletes_only_expired_daily_files(tmp_path: Path) -> None: