feat(dogfood-ops): enforce fail-closed wave evidence gates

This commit is contained in:
lili 2026-07-22 22:13:07 -07:00
parent 7618a890ae
commit af15ee1a32
8 changed files with 1532 additions and 0 deletions

View File

@ -0,0 +1,144 @@
# 内部狗粮运行保障
本目录把 Wave0/Wave1 的开波和收口条件落实为默认拒绝的本地证据门。工具只读取本地 JSON、TSV 和附件并重算结论,不连接 staging不发告警不执行停服、删档或回滚也不包含 Playwright/Puppeteer。浏览器采样、真玩和截图只能由 mini-desktop 上的项目统一入口 `/gstack` 产生。
## 1. 固定波次
| 波次 | 创作者 | 玩家 | 连续值班覆盖 |
|---|---:|---:|---:|
| wave0 | 3 | 5 | 至少 24 小时 |
| wave1 | 10 | 30 | 至少 72 小时 |
初始化只接受这两个名称。重复初始化会校验既有 `manifest.json` 后返回,不覆盖任何已填写证据。
```bash
deploy/dogfood-ops/init-wave.sh --root /受控证据根 --wave wave0
```
生成目录中的 JSON 带 `_template: true`,表示它还不是证据。填写完成后删除该字段。附件统一放在波次目录内的 `attachments/`JSON/TSV 只写相对路径。绝对路径、`..` 路径、符号链接和不存在的附件都会被拒绝。
## 2. 执行顺序
开波前按以下顺序执行,任一步非零都保持发布冻结:
```bash
# 1. 初始化并填写同一波次的全部结构化证据。
deploy/dogfood-ops/init-wave.sh --root /受控证据根 --wave wave0
# 2. 先跑开波门;模板、缺字段、阈值不达标都返回非零。
deploy/dogfood-ops/check-readiness.sh --wave-dir /受控证据根/wave0
# 3. 每日证据采集结束后冻结。当日清单已存在且内容变化时拒绝覆盖。
deploy/dogfood-ops/freeze-evidence.sh \
--wave-dir /受控证据根/wave0 \
--date 2026-07-22
# 4. 收口门除全部开波条件外,还会重算最近冻结清单。
deploy/dogfood-ops/check-close.sh --wave-dir /受控证据根/wave0
```
`GATE_PASS` 只表示本地证据在当前规则下可对账,不替代人工判断,不代表 staging 已部署、告警已送达或波次已完成。真实结论必须回到原始截图、网络记录、日志和数据库 ID 核验。
## 3. 证据清单
| 文件 | 必填内容 |
|---|---|
| `manifest.json` | 固定波次、人数和持续时间,不得修改 |
| `rc.json` | 完整 40 位 commit、tracked 干净标志、制品路径与实际 SHA-256 |
| `config-identity.json` | 同一 RC commit、配置包路径、实际 SHA-256、采集时间 |
| `snapshot.json` | MySQL 快照路径/哈希,以及非 staging/production 目标的隔离恢复 PASS、时间和报告 |
| `storage-audit.json` | 对象存储审计报告、实际哈希、采集时间 |
| `accounts.json` | batchId、精确创作者/玩家计数、FREE 额度wave0 至少 4wave1 至少 12 |
| `role-permissions.json` | 创作者本账号允许、跨账号拒绝、审核员只审、审核员改配置拒绝、只读写拒绝;每项带截图 |
| `main-chain.json` | 同一 project/version/artifact/game 的 create→generate→preview→submit-review→approve→feed→play→telemetry每段同时带截图、网络、日志、数据库证据 |
| `release-negatives.json` | DRAFT、REVIEWING、REJECTED、直调绕过、批准后产物漂移、下架后 feed/详情/深链八类拒绝证据 |
| `manual-play.json` | 每个发布游戏在进入 feed 前由 `/gstack@mini-desktop` 真玩,带测试人、时间和截图 |
| `performance.json` | 固定环境下至少 30 次禁用缓存的独立导航,只含 cold 样本 |
| `p0.tsv` | 单一 P0 账本;任一 `severity=P0` 行都会拒绝开波和收口 |
| `telemetry.json` | 期望完整事件数、完整收到数、原始证据;完整率至少 95% |
| `alerts.json` | 至少一条接收时间、接收人、外部消息 id 和原始证据,不接受仅写 PASS |
| `oncall.tsv` | 无空档覆盖本波次完整时长的主备值班与联系方式 |
| `feedback.tsv` | 唯一反馈账本;每行 id、时间、报告人、角色、分类、级别、摘要、状态、owner、证据都完整 |
| `manual-acceptance.tsv` | 角色权限、主链、发布负例、入 feed 前真玩四项人工签认 |
| `drills.json` | stop/delete/rollback 三类演练的精确 ACK、操作人、时间和目录内证据 |
| `safety-status.json` | 固定停止条件逐项评估,任一缺失或触发即拒绝 |
| `freezes/*.sha256.json` | 当日全部证据和附件的 SHA-256 清单wave0 至少 1 份、wave1 至少 3 份,按日连续并以前序清单哈希串联 |
所有时间使用带时区 ISO 8601例如 `2026-07-22T08:00:00Z`。哈希必须是小写 64 位 SHA-256并与附件内容一致。
## 4. 首屏 P75 口径
采样环境固定为 mini-desktop、Chrome 146、`390x844@1`、Tailscale staging。每次禁用缓存后独立导航从 navigation commit 计到首个可交互游戏帧;每条记录使用唯一 `navigationId`,并标记 `cache=cold``source=/gstack@mini-desktop`。热缓存另行报告,禁止写入 `performance.json`
算法使用 nearest-rank`n``durationMs` 升序排列,排名为 `ceil(0.75 × n)`,按 1 起算取值。30 个样本取第 23 个值。门槛是严格 `P75 < 3000ms`,所以 2999ms 通过3000ms 失败;少于 30 个样本直接失败。
## 5. 停止条件
出现以下任一情况,立即停止扩容;前六项同时要求全局停止:
1. 安全事件、跨账号访问、数据丢失、真实扣款、真实广告曝光、未审内容进入 feed。
2. 生成成功率低于 80%。
3. 出现任一确认假阳。
4. 同一阻断根因影响两人。
5. 埋点缺失超过 5%。
6. 待审超过 4 小时。
7. 验收成本突破生成 v3 上限。
`safety-status.json` 必须逐项使用固定 condition 名称评估。不要删除已触发项来让闸门变绿。
## 6. 回滚顺序
真实操作只由批准的部署窗口人工执行。本目录不提供环境命令,固定顺序不可调整:
1. 后端关闭新账号与提交审核。
2. 后端 feed 总闸切为 fail-closed。
3. 生成验收从 v3 退回 `v3_shadow`
4. 下架受影响游戏。
5. 回退到上一 commit-pinned RC。
6. 保留全部证据,创建或关联根因工单。
首个 RC 没有上一版 commit-pinned RC 时,第 5 步不可伪造为已演练;保持 rollback 红灯,等第二个 RC 窗口补证。
## 7. 停服、删档和回滚演练记录
先由获批人员在隔离目标完成真实演练并保存证据,再用本工具签收。精确 ACK 只证明操作者明确选择了对应波次和动作,不会触发环境操作。
```bash
deploy/dogfood-ops/record-drill.sh \
--wave-dir /受控证据根/wave0 \
--action stop \
--ack ACK-DOGFOOD-wave0-stop \
--operator 值班人ID \
--performed-at 2026-07-22T08:00:00Z \
--evidence-ref attachments/stop-drill-report.json
```
`delete``rollback` 分别使用 `ACK-DOGFOOD-wave0-delete``ACK-DOGFOOD-wave0-rollback`。同一动作、同一记录重复执行幂等返回;已有不同记录时拒绝覆盖。
删档范围只能是当前 `batchId` 关联的账号、项目、版本、feed 和遥测业务数据。审核记录、安全日志、发布证据、根因工单至少保留 180 天,不得随业务删档删除。删档报告必须列出实际删除对象和保留对象,不能只写总数。
## 8. 每日冻结
每天完成采集后运行 `freeze-evidence.sh`。清单覆盖该波次目录下除 `freezes/` 自身外的全部普通文件并记录上一日清单哈希。工具拒绝符号链接、日期空档和同日覆盖wave0 收口至少需要 1 份wave1 至少需要连续 3 份。若当天冻结后又补证,保留原清单并在下一日期重新冻结,不要修改历史清单。
## 9. 真实采集待办
本地测试 fixture 不可用于开波。真实执行仍需从批准窗口采集:
- commit-pinned RC、制品和配置快照的原始文件及哈希
- MySQL 本轮表快照、无网络隔离恢复报告、对象存储前缀审计;
- wave0/wave1 精确账号与 FREE 额度盘点;
- `/gstack` 在 mini-desktop 产生的角色权限、完整主链、八类发布负例、逐游戏入 feed 前真玩截图与网络证据;
- 30 次独立冷导航原始计时和单列的热缓存报告;
- P0、埋点完整率、告警接收回执、值班覆盖和反馈账本
- 停服、限定 batchId 删档、第二个 RC 回滚的人工演练证据;
- 每个采集日的 SHA-256 冻结清单。
## 10. 本地验证
```bash
python3 -m unittest discover -s deploy/dogfood-ops/tests -v
find deploy/dogfood-ops -name '*.sh' -exec bash -n {} +
python3 -m py_compile deploy/dogfood-ops/dogfood_ops.py deploy/dogfood-ops/tests/test_dogfood_ops.py
git diff --check -- deploy/dogfood-ops
```

View File

@ -0,0 +1,6 @@
#!/usr/bin/env bash
# 执行收口证据门,并重算最近一次每日冻结清单。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
exec python3 "$SCRIPT_DIR/dogfood_ops.py" check --phase close "$@"

View File

@ -0,0 +1,6 @@
#!/usr/bin/env bash
# 执行开波证据门;任一证据缺失、失配或越界即返回非零。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
exec python3 "$SCRIPT_DIR/dogfood_ops.py" check --phase readiness "$@"

826
deploy/dogfood-ops/dogfood_ops.py Executable file
View File

@ -0,0 +1,826 @@
#!/usr/bin/env python3
"""内部狗粮波次证据初始化与校验入口。"""
from __future__ import annotations
import argparse
import csv
import hashlib
import json
import math
import re
import sys
from datetime import date, datetime
from pathlib import Path
from typing import Any
WAVE_SCALES = {
"wave0": {"creatorCount": 3, "playerCount": 5, "durationHours": 24},
"wave1": {"creatorCount": 10, "playerCount": 30, "durationHours": 72},
}
TSV_TEMPLATES = {
"oncall.tsv": "start_at\tend_at\tprimary\tbackup\tcontact\n",
"feedback.tsv": "id\tcreated_at\treporter_id\trole\tcategory\tseverity\tsummary\tstatus\towner\tevidence_ref\n",
"p0.tsv": "id\tseverity\tstatus\tsummary\tevidence_ref\n",
"manual-acceptance.tsv": "item\tresult\toperator\tchecked_at\tevidence_ref\n",
}
JSON_TEMPLATES: dict[str, dict[str, Any]] = {
"rc.json": {"schema": "dogfood-rc/1", "commit": None, "repoClean": None, "artifactRef": None, "artifactSha256": None},
"config-identity.json": {"schema": "dogfood-config-identity/1", "capturedAt": None, "configSha256": None, "configBundleRef": None, "rcCommit": None},
"snapshot.json": {"schema": "dogfood-snapshot/1", "mysql": {}, "isolatedRestore": {}},
"storage-audit.json": {"schema": "dogfood-storage-audit/1", "result": None, "capturedAt": None, "reportRef": None, "reportSha256": None},
"accounts.json": {"schema": "dogfood-accounts/1", "batchId": None, "creatorCount": None, "playerCount": None, "freeQuotaCount": None},
"role-permissions.json": {"schema": "dogfood-role-permissions/1", "checks": []},
"main-chain.json": {"schema": "dogfood-main-chain/1", "identity": {}, "stages": []},
"release-negatives.json": {"schema": "dogfood-release-negatives/1", "checks": []},
"manual-play.json": {"schema": "dogfood-manual-play/1", "releasedGameCount": 0, "games": []},
"performance.json": {"schema": "dogfood-first-frame-performance/1", "context": {}, "samples": []},
"telemetry.json": {"schema": "dogfood-telemetry-completeness/1", "expectedCount": None, "completeCount": None, "evidenceRef": None},
"alerts.json": {"schema": "dogfood-alert-delivery/1", "deliveries": []},
"drills.json": {"schema": "dogfood-drills/1", "drills": []},
"safety-status.json": {"schema": "dogfood-safety-status/1", "assessedAt": None, "assessor": None, "conditions": []},
}
REQUIRED_PERMISSION_CASES = {
"creator-own-project": "ALLOW",
"creator-cross-account-denied": "DENY",
"reviewer-review-only": "ALLOW",
"reviewer-config-write-denied": "DENY",
"readonly-write-denied": "DENY",
}
REQUIRED_CHAIN_STAGES = {
"create", "generate", "preview", "submit-review", "approve", "feed", "play", "telemetry",
}
REQUIRED_NEGATIVE_CASES = {
"draft-publish", "reviewing-publish", "rejected-publish", "direct-publish-bypass",
"artifact-drift", "delisted-feed", "delisted-detail", "delisted-deep-link",
}
REQUIRED_CHECKLIST_ITEMS = {
"role-permissions", "main-chain", "release-negatives", "manual-play-before-feed",
}
REQUIRED_DRILLS = {"stop", "delete", "rollback"}
REQUIRED_STOP_CONDITIONS = {
"security-incident", "cross-account-access", "data-loss", "real-charge", "real-ad-impression",
"unreviewed-content-in-feed", "generation-success-below-80", "confirmed-false-positive",
"same-root-cause-blocked-two-users", "telemetry-missing-above-5", "review-backlog-over-4h",
"acceptance-cost-exceeded",
}
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$")
def write_json(path: Path, value: Any) -> None:
"""以稳定格式写入 JSON便于冻结哈希和人工审阅。"""
path.write_text(
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
def sha256_file(path: Path) -> str:
"""流式计算文件 SHA-256避免大证据附件一次性进入内存。"""
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def parse_timestamp(value: Any) -> datetime | None:
"""解析带时区 ISO 8601 时间;无时区时间不作为可信证据。"""
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo is not None else None
class EvidenceGate:
"""对同一波次的结构化证据执行默认拒绝校验。"""
def __init__(self, wave_dir: Path, phase: str) -> None:
self.wave_dir = wave_dir.resolve()
self.phase = phase
self.errors: list[tuple[str, str]] = []
self.manifest: dict[str, Any] | None = None
self.rc_artifact_sha256: str | None = None
def fail(self, code: str, message: str) -> None:
"""记录一条可追踪失败,不因首个错误隐藏其余缺口。"""
self.errors.append((code, message))
def load_json(self, name: str, schema: str) -> dict[str, Any] | None:
"""读取固定 JSON 文件并校验对象类型与 schema。"""
path = self.wave_dir / name
if not path.is_file():
self.fail(f"{name.removesuffix('.json')}.missing", f"缺少 {name}")
return None
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
self.fail(f"{name.removesuffix('.json')}.invalid-json", f"{name} 不是合法 JSON{exc}")
return None
if not isinstance(value, dict):
self.fail(f"{name.removesuffix('.json')}.invalid-type", f"{name} 顶层必须是对象")
return None
if value.get("schema") != schema:
self.fail(f"{name.removesuffix('.json')}.schema", f"{name} schema 必须为 {schema}")
if value.get("_template") is True:
self.fail(f"{name.removesuffix('.json')}.missing", f"{name} 仍是未填写模板")
return None
return value
def checked_ref(self, value: Any, context: str, require_attachment: bool = True) -> Path | None:
"""只接受波次目录内现存普通文件,拒绝绝对路径、穿越和符号链接。"""
if not isinstance(value, str) or not value or Path(value).is_absolute():
self.fail("reference.outside-wave", f"{context} 必须是波次目录内相对路径")
return None
relative = Path(value)
if require_attachment and (not relative.parts or relative.parts[0] != "attachments"):
self.fail("reference.outside-wave", f"{context} 必须位于 attachments/ 内:{value}")
return None
candidate = self.wave_dir / value
try:
resolved = candidate.resolve(strict=True)
resolved.relative_to(self.wave_dir)
except (FileNotFoundError, OSError, ValueError):
self.fail("reference.outside-wave", f"{context} 不存在或越过波次目录:{value}")
return None
if candidate.is_symlink() or not resolved.is_file():
self.fail("reference.outside-wave", f"{context} 必须是目录内普通文件:{value}")
return None
return resolved
def checked_typed_ref(self, value: Any, context: str, suffixes: set[str]) -> Path | None:
"""在可信附件边界上继续校验证据文件类型。"""
path = self.checked_ref(value, context)
if path is not None and path.suffix.lower() not in suffixes:
self.fail("main-chain.evidence-type", f"{context} 文件类型不匹配:{value}")
return None
return path
def check_hash_ref(self, ref: Any, expected_hash: Any, context: str) -> None:
"""同时验证哈希格式、附件边界和实际内容。"""
path = self.checked_ref(ref, context)
if not isinstance(expected_hash, str) or not SHA256_PATTERN.fullmatch(expected_hash):
self.fail(f"{context}.sha256", f"{context} 缺少合法 SHA-256")
return
if path is not None and sha256_file(path) != expected_hash:
self.fail(f"{context}.hash-mismatch", f"{context} 的 SHA-256 与附件不一致")
def read_tsv(self, name: str, fields: list[str]) -> list[dict[str, str]] | None:
"""用标准库 TSV 解析器校验表头,禁止文本猜字段。"""
path = self.wave_dir / name
if not path.is_file():
self.fail(f"{name.removesuffix('.tsv')}.missing", f"缺少 {name}")
return None
try:
with path.open("r", encoding="utf-8", newline="") as stream:
reader = csv.DictReader(stream, delimiter="\t")
if reader.fieldnames != fields:
self.fail(f"{name.removesuffix('.tsv')}.header", f"{name} 表头不匹配")
return None
return list(reader)
except (OSError, csv.Error) as exc:
self.fail(f"{name.removesuffix('.tsv')}.parse", f"{name} 解析失败:{exc}")
return None
def validate_manifest(self) -> None:
"""确认波次身份和规模没有被改写。"""
manifest = self.load_json("manifest.json", "dogfood-wave-manifest/1")
if manifest is None:
return
wave = manifest.get("wave")
if wave not in WAVE_SCALES:
self.fail("manifest.wave", "manifest.wave 只能是 wave0 或 wave1")
return
expected = {"schema": "dogfood-wave-manifest/1", "wave": wave, **WAVE_SCALES[wave]}
if manifest != expected or self.wave_dir.name != wave:
self.fail("manifest.identity", "波次目录名、身份或固定规模不匹配")
return
self.manifest = manifest
def validate_rc_and_config(self) -> None:
"""校验完整提交、干净构建、制品与配置身份。"""
rc = self.load_json("rc.json", "dogfood-rc/1")
config = self.load_json("config-identity.json", "dogfood-config-identity/1")
if rc is not None:
if not isinstance(rc.get("commit"), str) or not COMMIT_PATTERN.fullmatch(rc["commit"]):
self.fail("rc.commit", "RC commit 必须是完整 40 位小写哈希")
if rc.get("repoClean") is not True:
self.fail("rc.clean", "RC 必须来自 tracked 工作区干净构建")
self.check_hash_ref(rc.get("artifactRef"), rc.get("artifactSha256"), "rc.artifact")
if isinstance(rc.get("artifactSha256"), str) and SHA256_PATTERN.fullmatch(rc["artifactSha256"]):
self.rc_artifact_sha256 = rc["artifactSha256"]
if config is not None:
if parse_timestamp(config.get("capturedAt")) is None:
self.fail("config.captured-at", "配置身份缺少带时区采集时间")
self.check_hash_ref(config.get("configBundleRef"), config.get("configSha256"), "config.bundle")
if rc is not None and config.get("rcCommit") != rc.get("commit"):
self.fail("config.rc-commit", "配置身份未绑定同一个 RC commit")
def validate_snapshot_and_storage(self) -> None:
"""校验 MySQL 快照、隔离恢复和对象存储审计的原子证据。"""
snapshot = self.load_json("snapshot.json", "dogfood-snapshot/1")
if snapshot is not None:
mysql = snapshot.get("mysql")
if not isinstance(mysql, dict):
self.fail("snapshot.mysql", "缺少 MySQL 快照对象")
else:
self.check_hash_ref(mysql.get("snapshotRef"), mysql.get("sha256"), "snapshot.mysql")
if parse_timestamp(mysql.get("capturedAt")) is None:
self.fail("snapshot.mysql", "MySQL 快照缺少带时区采集时间")
restore = snapshot.get("isolatedRestore")
if not isinstance(restore, dict):
self.fail("snapshot.isolated-restore", "缺少隔离恢复对象")
else:
target = str(restore.get("target", "")).lower()
if restore.get("result") != "PASS" or not target or any(word in target for word in ("staging", "prod", "production")):
self.fail("snapshot.isolated-restore", "隔离恢复必须 PASS 且目标不得是 staging/production")
if parse_timestamp(restore.get("completedAt")) is None:
self.fail("snapshot.isolated-restore", "隔离恢复缺少带时区完成时间")
self.checked_ref(restore.get("evidenceRef"), "snapshot.isolated-restore")
storage = self.load_json("storage-audit.json", "dogfood-storage-audit/1")
if storage is not None:
if storage.get("result") != "PASS" or parse_timestamp(storage.get("capturedAt")) is None:
self.fail("storage-audit.result", "对象存储审计必须含真实 PASS 与采集时间")
self.check_hash_ref(storage.get("reportRef"), storage.get("reportSha256"), "storage-audit.report")
def validate_accounts(self) -> None:
"""校验波次账号规模、批次身份和额度余量。"""
accounts = self.load_json("accounts.json", "dogfood-accounts/1")
if accounts is None or self.manifest is None:
return
for key in ("creatorCount", "playerCount"):
if accounts.get(key) != self.manifest[key]:
self.fail(f"accounts.{key}", f"{key} 不符合批准波次规模")
required_quota = math.ceil(self.manifest["creatorCount"] * 1.2)
if not isinstance(accounts.get("freeQuotaCount"), int) or accounts["freeQuotaCount"] < required_quota:
self.fail("accounts.free-quota", f"FREE 额度必须至少为 {required_quota}")
if not isinstance(accounts.get("batchId"), str) or not accounts["batchId"]:
self.fail("accounts.batch-id", "账号必须绑定非空 batchId")
def validate_permissions(self) -> None:
"""校验角色权限用例和截图清单。"""
payload = self.load_json("role-permissions.json", "dogfood-role-permissions/1")
checks = payload.get("checks") if payload else None
if not isinstance(checks, list):
self.fail("role-permissions.checks", "角色权限 checks 必须是数组")
return
by_case = {item.get("case"): item for item in checks if isinstance(item, dict)}
for case, expected in REQUIRED_PERMISSION_CASES.items():
item = by_case.get(case)
if item is None or item.get("result") != expected:
self.fail("role-permissions.case", f"角色权限缺少或结果错误:{case}")
continue
self.checked_ref(item.get("screenshotRef"), f"role-permissions.{case}.screenshot")
def validate_main_chain(self) -> None:
"""校验同一项目、版本、产物和游戏贯穿完整主链。"""
payload = self.load_json("main-chain.json", "dogfood-main-chain/1")
if payload is None:
return
identity = payload.get("identity")
if not isinstance(identity, dict) or any(not identity.get(key) for key in ("projectId", "versionId", "artifactSha256", "gameId")):
self.fail("main-chain.identity", "主链身份缺少 project/version/artifact/game")
elif identity.get("artifactSha256") != self.rc_artifact_sha256:
self.fail("main-chain.rc-artifact", "主链产物哈希未绑定当前 RC 制品")
stages = payload.get("stages")
if not isinstance(stages, list):
self.fail("main-chain.stages", "主链 stages 必须是数组")
return
by_stage = {item.get("stage"): item for item in stages if isinstance(item, dict)}
evidence_types = {
"screenshotRef": {".png", ".jpg", ".jpeg", ".webp"},
"networkRef": {".har", ".json"},
"logRef": {".log", ".txt"},
"databaseRef": {".json", ".tsv"},
}
for stage in REQUIRED_CHAIN_STAGES:
item = by_stage.get(stage)
if item is None or item.get("result") != "PASS":
self.fail("main-chain.stage", f"主链缺少真实通过阶段:{stage}")
continue
refs = [item.get(field) for field in evidence_types]
if len(set(ref for ref in refs if isinstance(ref, str))) != len(evidence_types):
self.fail("main-chain.evidence-modalities", f"主链四类证据路径必须互异:{stage}")
for field, suffixes in evidence_types.items():
self.checked_typed_ref(item.get(field), f"main-chain.{stage}.{field}", suffixes)
def validate_release_and_manual_play(self) -> None:
"""校验发布负例全部被拦截,并确保入 feed 前完成真玩。"""
negatives = self.load_json("release-negatives.json", "dogfood-release-negatives/1")
checks = negatives.get("checks") if negatives else None
if not isinstance(checks, list):
self.fail("release-negatives.checks", "发布负例 checks 必须是数组")
else:
by_case = {item.get("case"): item for item in checks if isinstance(item, dict)}
for case in REQUIRED_NEGATIVE_CASES:
item = by_case.get(case)
if item is None or item.get("blocked") is not True:
self.fail("release-negatives.case", f"发布负例未证实被拦截:{case}")
continue
self.checked_ref(item.get("evidenceRef"), f"release-negatives.{case}")
manual = self.load_json("manual-play.json", "dogfood-manual-play/1")
games = manual.get("games") if manual else None
if not isinstance(games, list) or not games or manual.get("releasedGameCount") != len(games):
self.fail("manual-play.coverage", "每个发布游戏都必须有人工真玩记录")
return
for item in games:
if not isinstance(item, dict):
self.fail("manual-play.item", "人工真玩记录必须是对象")
continue
played_at = parse_timestamp(item.get("playedAt"))
feed_at = parse_timestamp(item.get("feedEnteredAt"))
if not item.get("gameId") or not item.get("tester") or played_at is None or feed_at is None or played_at >= feed_at:
self.fail("manual-play.before-feed", "人工真玩必须发生在进入 feed 前")
if item.get("source") != "/gstack@mini-desktop":
self.fail("manual-play.source", "浏览器真玩证据只能来自 /gstack@mini-desktop")
self.checked_ref(item.get("screenshotRef"), "manual-play.screenshot")
def validate_performance(self) -> None:
"""按 nearest-rank 算法校验 30 个独立冷导航样本的 P75。"""
payload = self.load_json("performance.json", "dogfood-first-frame-performance/1")
if payload is None:
return
expected_context = {
"host": "mini-desktop", "browser": "Chrome", "browserVersion": "146", "viewport": "390x844@1",
"network": "Tailscale staging", "timing": "navigation-commit-to-first-interactive-game-frame",
}
if payload.get("context") != expected_context:
self.fail("performance.context", "首屏采样环境或计时边界不匹配")
samples = payload.get("samples")
if not isinstance(samples, list):
self.fail("performance.samples", "performance.samples 必须是数组")
return
if len(samples) < 30:
self.fail("performance.sample-count", "冷缓存独立导航样本不得少于 30")
navigation_ids: list[str] = []
durations: list[float] = []
for sample in samples:
if not isinstance(sample, dict):
self.fail("performance.sample", "性能样本必须是对象")
continue
if sample.get("cache") != "cold":
self.fail("performance.cold-only", "热缓存样本不得混入冷样本")
if sample.get("source") != "/gstack@mini-desktop":
self.fail("performance.source", "性能证据只能来自 /gstack@mini-desktop")
navigation_id = sample.get("navigationId")
if not isinstance(navigation_id, str) or not navigation_id:
self.fail("performance.navigation-id", "每个样本必须有独立 navigationId")
else:
navigation_ids.append(navigation_id)
duration = sample.get("durationMs")
if (
not isinstance(duration, (int, float))
or isinstance(duration, bool)
or not math.isfinite(duration)
or duration < 0
):
self.fail("performance.duration", "durationMs 必须是非负数")
else:
durations.append(float(duration))
if len(set(navigation_ids)) != len(navigation_ids):
self.fail("performance.independent", "navigationId 重复,样本不是独立导航")
if len(durations) == len(samples) and len(durations) >= 30:
ordered = sorted(durations)
rank = math.ceil(0.75 * len(ordered))
p75 = ordered[rank - 1]
if p75 >= 3000:
self.fail("performance.p75", f"nearest-rank P75={p75:g}ms必须严格小于 3000ms")
def validate_p0_telemetry_alerts(self) -> None:
"""校验 P0 为零、埋点完整率和告警真送达字段。"""
p0_rows = self.read_tsv("p0.tsv", ["id", "severity", "status", "summary", "evidence_ref"])
if p0_rows is not None:
for row in p0_rows:
if any(not row.get(field) for field in row):
self.fail("p0.row", "P0 账本存在字段不完整的行")
if row.get("severity", "").upper() == "P0":
self.fail("p0.nonzero", "本波次出现 P0不能开波或收口")
telemetry = self.load_json("telemetry.json", "dogfood-telemetry-completeness/1")
if telemetry is not None:
expected = telemetry.get("expectedCount")
complete = telemetry.get("completeCount")
if not isinstance(expected, int) or isinstance(expected, bool) or expected <= 0 or not isinstance(complete, int) or isinstance(complete, bool) or complete < 0 or complete > expected:
self.fail("telemetry.counts", "埋点计数必须是有效非负整数且 complete<=expected")
elif complete * 100 < expected * 95:
self.fail("telemetry.completeness", f"埋点完整率 {complete}/{expected} 低于 95%")
self.checked_ref(telemetry.get("evidenceRef"), "telemetry.evidence")
alerts = self.load_json("alerts.json", "dogfood-alert-delivery/1")
deliveries = alerts.get("deliveries") if alerts else None
if not isinstance(deliveries, list) or not deliveries:
self.fail("alerts.deliveries", "至少需要一条告警真实送达记录")
else:
for item in deliveries:
if not isinstance(item, dict) or parse_timestamp(item.get("receivedAt")) is None or not item.get("recipient") or not item.get("externalMessageId"):
self.fail("alerts.delivery", "告警缺少接收时间、接收人或外部消息 id")
continue
self.checked_ref(item.get("evidenceRef"), "alerts.evidence")
def validate_oncall_feedback_checklist(self) -> None:
"""校验连续值班覆盖、单一反馈账本和人工验收清单。"""
oncall = self.read_tsv("oncall.tsv", ["start_at", "end_at", "primary", "backup", "contact"])
shifts: list[tuple[datetime, datetime]] = []
if oncall is not None:
for row in oncall:
start = parse_timestamp(row.get("start_at"))
end = parse_timestamp(row.get("end_at"))
if start is None or end is None or start >= end or not row.get("primary") or not row.get("backup") or not row.get("contact"):
self.fail("oncall.row", "值班记录字段不完整或时间无效")
else:
shifts.append((start, end))
if shifts and self.manifest is not None:
shifts.sort()
cursor = shifts[0][1]
for start, end in shifts[1:]:
if start > cursor:
self.fail("oncall.gap", "值班覆盖存在空档")
cursor = max(cursor, end)
covered_hours = (cursor - shifts[0][0]).total_seconds() / 3600
if covered_hours < self.manifest["durationHours"]:
self.fail("oncall.duration", "值班覆盖不足批准波次持续时间")
elif oncall is not None:
self.fail("oncall.empty", "值班表不得为空")
feedback = self.read_tsv("feedback.tsv", ["id", "created_at", "reporter_id", "role", "category", "severity", "summary", "status", "owner", "evidence_ref"])
if feedback is not None:
seen_ids: set[str] = set()
for row in feedback:
if any(not row.get(field) for field in row) or parse_timestamp(row.get("created_at")) is None:
self.fail("feedback.row", "反馈账本存在字段不完整的行")
continue
if row["id"] in seen_ids:
self.fail("feedback.duplicate", f"反馈 id 重复:{row['id']}")
seen_ids.add(row["id"])
self.checked_ref(row.get("evidence_ref"), f"feedback.{row.get('id', 'unknown')}.evidence")
checklist = self.read_tsv("manual-acceptance.tsv", ["item", "result", "operator", "checked_at", "evidence_ref"])
if checklist is not None:
by_item = {row.get("item"): row for row in checklist}
for item_name in REQUIRED_CHECKLIST_ITEMS:
row = by_item.get(item_name)
if row is None or row.get("result") != "PASS" or not row.get("operator") or parse_timestamp(row.get("checked_at")) is None:
self.fail("manual-acceptance.item", f"人工验收项缺失或未通过:{item_name}")
continue
self.checked_ref(row.get("evidence_ref"), f"manual-acceptance.{item_name}.evidence")
def validate_drills_and_safety(self) -> None:
"""校验三类演练 ACK 及固定停止条件均未触发。"""
drills = self.load_json("drills.json", "dogfood-drills/1")
records = drills.get("drills") if drills else None
if not isinstance(records, list):
self.fail("drills.records", "drills 必须是数组")
elif self.manifest is not None:
by_action = {item.get("action"): item for item in records if isinstance(item, dict)}
for action in REQUIRED_DRILLS:
item = by_action.get(action)
expected_ack = f"ACK-DOGFOOD-{self.manifest['wave']}-{action}"
if item is None or item.get("result") != "PASS" or item.get("ack") != expected_ack or not item.get("operator") or parse_timestamp(item.get("performedAt")) is None:
self.fail("drills.action", f"演练缺失、未通过或 ACK 错误:{action}")
continue
self.checked_ref(item.get("evidenceRef"), f"drills.{action}.evidence")
safety = self.load_json("safety-status.json", "dogfood-safety-status/1")
conditions = safety.get("conditions") if safety else None
if not isinstance(conditions, list) or parse_timestamp(safety.get("assessedAt") if safety else None) is None or not (safety or {}).get("assessor"):
self.fail("safety-status.assessment", "安全状态缺少评估人、时间或条件数组")
return
by_condition = {item.get("condition"): item for item in conditions if isinstance(item, dict)}
for condition in REQUIRED_STOP_CONDITIONS:
item = by_condition.get(condition)
if item is None or item.get("triggered") is not False:
self.fail("safety-status.stop-condition", f"停止条件缺失或已触发:{condition}")
def validate_freeze(self) -> None:
"""收口时校验每日冻结哈希链,并重算最新证据。"""
freeze_dir = self.wave_dir / "freezes"
freeze_files = sorted(freeze_dir.glob("*.sha256.json")) if freeze_dir.is_dir() else []
if not freeze_files:
self.fail("freeze.missing", "收口前必须生成至少一次每日 SHA-256 冻结")
return
required_count = math.ceil((self.manifest or {}).get("durationHours", 0) / 24)
if len(freeze_files) < required_count:
self.fail("freeze.daily-count", f"每日冻结不足:需要 {required_count} 份,实际 {len(freeze_files)}")
payloads: list[dict[str, Any]] = []
previous_file: Path | None = None
previous_date: date | None = None
for freeze_file in freeze_files:
expected_date = freeze_file.name.removesuffix(".sha256.json")
try:
parsed_date = date.fromisoformat(expected_date)
payload = json.loads(freeze_file.read_text(encoding="utf-8"))
except (OSError, ValueError, json.JSONDecodeError) as exc:
self.fail("freeze.invalid", f"冻结清单不可读:{freeze_file.name}{exc}")
return
if not isinstance(payload, dict):
self.fail("freeze.schema", "冻结清单 schema 或 files 非法")
return
if (
payload.get("schema") != "dogfood-evidence-freeze/1"
or payload.get("algorithm") != "SHA-256"
or payload.get("wave") != (self.manifest or {}).get("wave")
or payload.get("date") != expected_date
or not isinstance(payload.get("files"), list)
):
self.fail("freeze.schema", "冻结清单身份、日期、算法或 files 非法")
return
expected_previous = sha256_file(previous_file) if previous_file is not None else None
if payload.get("previousFreezeSha256") != expected_previous:
self.fail("freeze.chain", f"冻结哈希链断裂:{freeze_file.name}")
if previous_date is not None and (parsed_date - previous_date).days != 1:
self.fail("freeze.daily-sequence", "每日冻结日期必须连续")
payloads.append(payload)
previous_file = freeze_file
previous_date = parsed_date
latest = freeze_files[-1]
entries = payloads[-1]["files"]
try:
expected_paths = {path.relative_to(self.wave_dir).as_posix() for path in collect_evidence_files(self.wave_dir)}
except (OSError, ValueError) as exc:
self.fail("freeze.collection", f"当前证据集合不可冻结:{exc}")
return
recorded_paths: set[str] = set()
for entry in entries:
if not isinstance(entry, dict):
self.fail("freeze.entry", "冻结条目必须是对象")
continue
relative = entry.get("path")
if isinstance(relative, str) and relative in recorded_paths:
self.fail("freeze.duplicate", f"冻结路径重复:{relative}")
if isinstance(relative, str):
recorded_paths.add(relative)
path = self.checked_ref(relative, "freeze.entry", require_attachment=False)
if path is not None and entry.get("sha256") != sha256_file(path):
self.fail("freeze.hash-mismatch", f"冻结后证据发生变化:{relative}")
if recorded_paths != expected_paths:
self.fail("freeze.file-set", "当前证据文件集合与冻结清单不一致")
def run(self) -> int:
"""执行完整闸门并输出机器可读错误代码。"""
if not self.wave_dir.is_dir():
self.fail("wave-dir.missing", "波次目录不存在")
else:
self.validate_manifest()
self.validate_rc_and_config()
self.validate_snapshot_and_storage()
self.validate_accounts()
self.validate_permissions()
self.validate_main_chain()
self.validate_release_and_manual_play()
self.validate_performance()
self.validate_p0_telemetry_alerts()
self.validate_oncall_feedback_checklist()
self.validate_drills_and_safety()
if self.phase == "close":
self.validate_freeze()
if self.errors:
print(f"GATE_FAIL phase={self.phase} errors={len(self.errors)}")
for code, message in self.errors:
print(f"[{code}] {message}")
return 1
print(f"GATE_PASS phase={self.phase} wave={self.manifest['wave']}")
return 0
def collect_evidence_files(wave_dir: Path) -> list[Path]:
"""收集冻结范围,排除冻结清单自身并拒绝符号链接。"""
files: list[Path] = []
for path in sorted(wave_dir.rglob("*")):
relative = path.relative_to(wave_dir)
if relative.parts and relative.parts[0] == "freezes":
continue
if path.is_symlink():
raise ValueError(f"冻结范围不允许符号链接:{relative.as_posix()}")
if path.is_file():
files.append(path)
return files
def freeze_evidence(wave_dir: Path, freeze_date: str) -> int:
"""为指定日期原子写入不可覆盖的 SHA-256 文件清单。"""
wave_dir = wave_dir.resolve()
try:
parsed_date = date.fromisoformat(freeze_date)
except ValueError:
print("FREEZE_REFUSED--date 必须是 YYYY-MM-DD。", file=sys.stderr)
return 2
manifest_path = wave_dir / "manifest.json"
if not manifest_path.is_file():
print("FREEZE_REFUSED波次目录缺少 manifest.json。", file=sys.stderr)
return 2
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
files = collect_evidence_files(wave_dir)
except (OSError, json.JSONDecodeError, ValueError) as exc:
print(f"FREEZE_REFUSED{exc}", file=sys.stderr)
return 2
freeze_dir = wave_dir / "freezes"
freeze_dir.mkdir(mode=0o700, exist_ok=True)
target = freeze_dir / f"{freeze_date}.sha256.json"
earlier = sorted(path for path in freeze_dir.glob("*.sha256.json") if path != target)
if earlier:
latest_date_text = earlier[-1].name.removesuffix(".sha256.json")
try:
latest_date = date.fromisoformat(latest_date_text)
except ValueError:
print("FREEZE_REFUSED既有冻结文件名不是 YYYY-MM-DD。", file=sys.stderr)
return 2
if latest_date >= parsed_date:
print("FREEZE_REFUSED冻结日期必须晚于既有最近日期。", file=sys.stderr)
return 2
previous = earlier[-1] if earlier else None
payload = {
"schema": "dogfood-evidence-freeze/1",
"date": freeze_date,
"wave": manifest.get("wave"),
"algorithm": "SHA-256",
"previousFreezeSha256": sha256_file(previous) if previous is not None else None,
"files": [{"path": path.relative_to(wave_dir).as_posix(), "sha256": sha256_file(path)} for path in files],
}
serialized = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
if target.exists():
if target.read_text(encoding="utf-8") == serialized:
print(f"FREEZE_ALREADY_EXISTS path={target}")
return 0
print("FREEZE_REFUSED同日冻结已存在且当前证据不同。", file=sys.stderr)
return 2
temporary = target.with_suffix(target.suffix + ".tmp")
temporary.write_text(serialized, encoding="utf-8")
temporary.replace(target)
print(f"FREEZE_CREATED path={target} files={len(files)}")
return 0
def record_drill(
wave_dir: Path,
action: str,
ack: str,
operator: str,
performed_at: str,
evidence_ref: str,
) -> int:
"""签收人工演练证据;本函数不包含任何真实破坏性操作。"""
wave_dir = wave_dir.resolve()
manifest_path = wave_dir / "manifest.json"
if not manifest_path.is_file():
print("DRILL_REFUSED波次目录缺少 manifest.json。", file=sys.stderr)
return 2
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
print(f"DRILL_REFUSEDmanifest 不可读:{exc}", file=sys.stderr)
return 2
wave = manifest.get("wave")
if wave not in WAVE_SCALES:
print("DRILL_REFUSEDmanifest 波次身份非法。", file=sys.stderr)
return 2
expected_ack = f"ACK-DOGFOOD-{wave}-{action}"
if ack != expected_ack:
print(f"ACK_REFUSED必须显式输入 {expected_ack}", file=sys.stderr)
return 2
if not operator or parse_timestamp(performed_at) is None:
print("DRILL_REFUSEDoperator 和带时区 performed-at 必填。", file=sys.stderr)
return 2
# 复用闸门的可信路径边界,但不运行其它证据校验。
ref_gate = EvidenceGate(wave_dir, "record-drill")
if ref_gate.checked_ref(evidence_ref, f"drills.{action}.evidence") is None:
print("DRILL_REFUSEDevidence-ref 不在波次目录内或不存在。", file=sys.stderr)
return 2
record = {
"action": action,
"result": "PASS",
"ack": ack,
"performedAt": performed_at,
"operator": operator,
"evidenceRef": evidence_ref,
}
drills_path = wave_dir / "drills.json"
try:
payload = json.loads(drills_path.read_text(encoding="utf-8")) if drills_path.is_file() else {"schema": "dogfood-drills/1", "drills": []}
except (OSError, json.JSONDecodeError) as exc:
print(f"DRILL_REFUSEDdrills.json 不可读:{exc}", file=sys.stderr)
return 2
if not isinstance(payload, dict) or payload.get("schema") != "dogfood-drills/1" or not isinstance(payload.get("drills"), list):
print("DRILL_REFUSEDdrills.json 结构非法。", file=sys.stderr)
return 2
existing = next((item for item in payload["drills"] if isinstance(item, dict) and item.get("action") == action), None)
if existing == record:
print(f"DRILL_ALREADY_RECORDED wave={wave} action={action}")
return 0
if existing is not None:
print("DRILL_REFUSED同一动作已有不同记录不允许覆盖。", file=sys.stderr)
return 2
payload.pop("_template", None)
payload["drills"].append(record)
temporary = drills_path.with_suffix(".json.tmp")
write_json(temporary, payload)
temporary.replace(drills_path)
print(f"DRILL_RECORDED wave={wave} action={action}(仅记录证据,未执行环境操作)")
return 0
def ensure_wave_scaffold(wave_dir: Path) -> list[str]:
"""只补齐缺失模板和目录,绝不覆盖已有证据。"""
created: list[str] = []
for name, template in JSON_TEMPLATES.items():
path = wave_dir / name
if not path.exists():
write_json(path, {**template, "_template": True})
created.append(name)
for name, header in TSV_TEMPLATES.items():
path = wave_dir / name
if not path.exists():
path.write_text(header, encoding="utf-8")
created.append(name)
for name in ("attachments", "freezes"):
path = wave_dir / name
if not path.exists():
path.mkdir(mode=0o700)
created.append(name + "/")
return created
def initialize_wave(root: Path, wave: str) -> int:
"""初始化固定波次目录;已存在时只校验身份,不覆盖证据。"""
wave_dir = root.resolve() / wave
manifest_path = wave_dir / "manifest.json"
expected = {"schema": "dogfood-wave-manifest/1", "wave": wave, **WAVE_SCALES[wave]}
if wave_dir.exists():
if not manifest_path.is_file():
print("INIT_REFUSED既有目录缺少 manifest.json。", file=sys.stderr)
return 2
try:
current = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
print(f"INIT_REFUSED既有 manifest 不可读:{exc}", file=sys.stderr)
return 2
if current != expected:
print("INIT_REFUSED既有目录的波次身份或规模不匹配。", file=sys.stderr)
return 2
repaired = ensure_wave_scaffold(wave_dir)
if repaired:
print(f"INIT_REPAIRED wave={wave} path={wave_dir} created={','.join(repaired)}")
return 0
print(f"INIT_ALREADY_EXISTS wave={wave} path={wave_dir}")
return 0
wave_dir.mkdir(parents=True, mode=0o700)
write_json(manifest_path, expected)
ensure_wave_scaffold(wave_dir)
print(f"INIT_CREATED wave={wave} path={wave_dir}")
return 0
def build_parser() -> argparse.ArgumentParser:
"""构造严格参数解析器,波次名不接受任意路径。"""
parser = argparse.ArgumentParser(description="内部狗粮运行保障证据工具")
subparsers = parser.add_subparsers(dest="command", required=True)
init_parser = subparsers.add_parser("init", help="初始化 wave0 或 wave1 证据目录")
init_parser.add_argument("--root", required=True, type=Path, help="证据根目录")
init_parser.add_argument("--wave", required=True, choices=sorted(WAVE_SCALES), help="固定波次")
check_parser = subparsers.add_parser("check", help="执行开波或收口证据门")
check_parser.add_argument("--wave-dir", required=True, type=Path, help="单个波次证据目录")
check_parser.add_argument("--phase", required=True, choices=("readiness", "close"), help="闸门阶段")
freeze_parser = subparsers.add_parser("freeze", help="生成每日 SHA-256 冻结清单")
freeze_parser.add_argument("--wave-dir", required=True, type=Path, help="单个波次证据目录")
freeze_parser.add_argument("--date", required=True, help="冻结日期 YYYY-MM-DD")
drill_parser = subparsers.add_parser("record-drill", help="记录已完成的人工演练证据,不执行环境操作")
drill_parser.add_argument("--wave-dir", required=True, type=Path, help="单个波次证据目录")
drill_parser.add_argument("--action", required=True, choices=sorted(REQUIRED_DRILLS), help="演练动作")
drill_parser.add_argument("--ack", required=True, help="精确 ACK 文本")
drill_parser.add_argument("--operator", required=True, help="演练操作人")
drill_parser.add_argument("--performed-at", required=True, help="带时区 ISO 8601 时间")
drill_parser.add_argument("--evidence-ref", required=True, help="波次目录内证据相对路径")
return parser
def main() -> int:
"""解析命令并返回稳定退出码。"""
args = build_parser().parse_args()
if args.command == "init":
return initialize_wave(args.root, args.wave)
if args.command == "check":
return EvidenceGate(args.wave_dir, args.phase).run()
if args.command == "freeze":
return freeze_evidence(args.wave_dir, args.date)
if args.command == "record-drill":
return record_drill(args.wave_dir, args.action, args.ack, args.operator, args.performed_at, args.evidence_ref)
return 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,6 @@
#!/usr/bin/env bash
# 为单个波次生成不可覆盖的每日 SHA-256 证据清单。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
exec python3 "$SCRIPT_DIR/dogfood_ops.py" freeze "$@"

View File

@ -0,0 +1,6 @@
#!/usr/bin/env bash
# 初始化固定规模的内部狗粮证据目录,不连接任何远端环境。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
exec python3 "$SCRIPT_DIR/dogfood_ops.py" init "$@"

View File

@ -0,0 +1,6 @@
#!/usr/bin/env bash
# 仅记录已经人工完成的停服、删档或回滚演练;本脚本不执行环境操作。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
exec python3 "$SCRIPT_DIR/dogfood_ops.py" record-drill "$@"

View File

@ -0,0 +1,532 @@
#!/usr/bin/env python3
"""内部狗粮运行保障工具的行为测试。"""
from __future__ import annotations
import hashlib
import json
import subprocess
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
TOOL = Path(__file__).resolve().parents[1] / "dogfood_ops.py"
OPS_DIR = TOOL.parent
class DogfoodOpsTest(unittest.TestCase):
"""通过命令行入口验证运维人员实际使用的行为。"""
def setUp(self) -> None:
"""每个用例使用独立临时证据根,避免测试相互污染。"""
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name) / "evidence"
def tearDown(self) -> None:
"""清理测试创建的本地证据。"""
self.temp_dir.cleanup()
def run_tool(self, *args: str) -> subprocess.CompletedProcess[str]:
"""调用真实 CLI并保留标准输出供失败断言。"""
return subprocess.run(
["python3", str(TOOL), *args],
check=False,
text=True,
capture_output=True,
)
def init_wave(self, wave: str) -> Path:
"""初始化指定波次并返回证据目录。"""
result = self.run_tool("init", "--root", str(self.root), "--wave", wave)
self.assertEqual(result.returncode, 0, result.stderr)
return self.root / wave
def write_json(self, path: Path, value: Any) -> None:
"""写入稳定 JSON fixture。"""
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def add_attachment(self, wave_dir: Path, name: str, content: str = "evidence") -> tuple[str, str]:
"""创建目录内证据附件并返回相对路径与哈希。"""
attachment = wave_dir / "attachments" / name
attachment.parent.mkdir(exist_ok=True)
attachment.write_text(content, encoding="utf-8")
return attachment.relative_to(wave_dir).as_posix(), hashlib.sha256(attachment.read_bytes()).hexdigest()
def populate_valid_evidence(self, wave_dir: Path) -> None:
"""构造不依赖 staging 的完整本地通过 fixture。"""
manifest = json.loads((wave_dir / "manifest.json").read_text(encoding="utf-8"))
wave = manifest["wave"]
commit = "1" * 40
artifact_ref, artifact_hash = self.add_attachment(wave_dir, "rc-artifact.bin")
config_ref, config_hash = self.add_attachment(wave_dir, "config-bundle.txt")
dump_ref, dump_hash = self.add_attachment(wave_dir, "mysql.sql.gz")
restore_ref, _ = self.add_attachment(wave_dir, "restore-report.json")
storage_ref, storage_hash = self.add_attachment(wave_dir, "storage-audit.json")
common_ref, _ = self.add_attachment(wave_dir, "browser-proof.png")
network_ref, _ = self.add_attachment(wave_dir, "main-chain.har")
log_ref, _ = self.add_attachment(wave_dir, "application.log")
database_ref, _ = self.add_attachment(wave_dir, "database.json")
self.write_json(wave_dir / "rc.json", {
"schema": "dogfood-rc/1", "commit": commit, "repoClean": True,
"artifactRef": artifact_ref, "artifactSha256": artifact_hash,
})
self.write_json(wave_dir / "config-identity.json", {
"schema": "dogfood-config-identity/1", "capturedAt": "2026-07-22T00:00:00Z",
"rcCommit": commit, "configBundleRef": config_ref, "configSha256": config_hash,
})
self.write_json(wave_dir / "snapshot.json", {
"schema": "dogfood-snapshot/1",
"mysql": {"snapshotRef": dump_ref, "sha256": dump_hash, "capturedAt": "2026-07-22T00:00:00Z"},
"isolatedRestore": {"result": "PASS", "target": "isolated-dogfood-restore-001", "completedAt": "2026-07-22T01:00:00Z", "evidenceRef": restore_ref},
})
self.write_json(wave_dir / "storage-audit.json", {
"schema": "dogfood-storage-audit/1", "result": "PASS", "capturedAt": "2026-07-22T00:30:00Z",
"reportRef": storage_ref, "reportSha256": storage_hash,
})
self.write_json(wave_dir / "accounts.json", {
"schema": "dogfood-accounts/1", "batchId": f"dogfood-{wave}",
"creatorCount": manifest["creatorCount"], "playerCount": manifest["playerCount"],
"freeQuotaCount": 4 if wave == "wave0" else 12,
})
permission_cases = {
"creator-own-project": "ALLOW",
"creator-cross-account-denied": "DENY",
"reviewer-review-only": "ALLOW",
"reviewer-config-write-denied": "DENY",
"readonly-write-denied": "DENY",
}
self.write_json(wave_dir / "role-permissions.json", {
"schema": "dogfood-role-permissions/1",
"checks": [{"case": case, "result": result, "screenshotRef": common_ref} for case, result in permission_cases.items()],
})
evidence_bundle = {"screenshotRef": common_ref, "networkRef": network_ref, "logRef": log_ref, "databaseRef": database_ref}
stages = ["create", "generate", "preview", "submit-review", "approve", "feed", "play", "telemetry"]
self.write_json(wave_dir / "main-chain.json", {
"schema": "dogfood-main-chain/1",
"identity": {"projectId": "p-1", "versionId": "v-1", "artifactSha256": artifact_hash, "gameId": "g-1"},
"stages": [{"stage": stage, "result": "PASS", **evidence_bundle} for stage in stages],
})
negative_cases = [
"draft-publish", "reviewing-publish", "rejected-publish", "direct-publish-bypass",
"artifact-drift", "delisted-feed", "delisted-detail", "delisted-deep-link",
]
self.write_json(wave_dir / "release-negatives.json", {
"schema": "dogfood-release-negatives/1",
"checks": [{"case": case, "blocked": True, "evidenceRef": common_ref} for case in negative_cases],
})
self.write_json(wave_dir / "manual-play.json", {
"schema": "dogfood-manual-play/1", "releasedGameCount": 1,
"games": [{"gameId": "g-1", "tester": "tester-1", "playedAt": "2026-07-22T02:00:00Z", "feedEnteredAt": "2026-07-22T02:10:00Z", "screenshotRef": common_ref, "source": "/gstack@mini-desktop"}],
})
samples = [
{"navigationId": f"nav-{index:02d}", "cache": "cold", "durationMs": 2000, "source": "/gstack@mini-desktop"}
for index in range(30)
]
self.write_json(wave_dir / "performance.json", {
"schema": "dogfood-first-frame-performance/1",
"context": {"host": "mini-desktop", "browser": "Chrome", "browserVersion": "146", "viewport": "390x844@1", "network": "Tailscale staging", "timing": "navigation-commit-to-first-interactive-game-frame"},
"samples": samples,
})
self.write_json(wave_dir / "telemetry.json", {
"schema": "dogfood-telemetry-completeness/1", "expectedCount": 20, "completeCount": 19,
"evidenceRef": common_ref,
})
self.write_json(wave_dir / "alerts.json", {
"schema": "dogfood-alert-delivery/1",
"deliveries": [{"alertId": "alert-1", "receivedAt": "2026-07-22T03:00:00Z", "recipient": "oncall-1", "externalMessageId": "msg-1", "evidenceRef": common_ref}],
})
start = datetime(2026, 7, 22, tzinfo=timezone.utc)
end = start + timedelta(hours=manifest["durationHours"])
(wave_dir / "oncall.tsv").write_text(
"start_at\tend_at\tprimary\tbackup\tcontact\n"
f"{start.isoformat().replace('+00:00', 'Z')}\t{end.isoformat().replace('+00:00', 'Z')}\toncall-1\toncall-2\tinternal-roster\n",
encoding="utf-8",
)
(wave_dir / "feedback.tsv").write_text(
"id\tcreated_at\treporter_id\trole\tcategory\tseverity\tsummary\tstatus\towner\tevidence_ref\n"
f"F-1\t2026-07-22T04:00:00Z\tu-1\tplayer\tux\tP2\tfixture feedback\tCLOSED\tops\t{common_ref}\n",
encoding="utf-8",
)
checklist_items = ["role-permissions", "main-chain", "release-negatives", "manual-play-before-feed"]
(wave_dir / "manual-acceptance.tsv").write_text(
"item\tresult\toperator\tchecked_at\tevidence_ref\n" + "".join(
f"{item}\tPASS\toperator-1\t2026-07-22T05:00:00Z\t{common_ref}\n" for item in checklist_items
),
encoding="utf-8",
)
self.write_json(wave_dir / "drills.json", {
"schema": "dogfood-drills/1",
"drills": [{"action": action, "result": "PASS", "ack": f"ACK-DOGFOOD-{wave}-{action}", "performedAt": "2026-07-22T06:00:00Z", "operator": "ops-1", "evidenceRef": common_ref} for action in ["stop", "delete", "rollback"]],
})
stop_conditions = [
"security-incident", "cross-account-access", "data-loss", "real-charge", "real-ad-impression",
"unreviewed-content-in-feed", "generation-success-below-80", "confirmed-false-positive",
"same-root-cause-blocked-two-users", "telemetry-missing-above-5", "review-backlog-over-4h",
"acceptance-cost-exceeded",
]
self.write_json(wave_dir / "safety-status.json", {
"schema": "dogfood-safety-status/1", "assessedAt": "2026-07-22T07:00:00Z", "assessor": "ops-1",
"conditions": [{"condition": condition, "triggered": False} for condition in stop_conditions],
})
def test_init_wave0_creates_fixed_ledgers_and_scale(self) -> None:
"""wave0 必须固定为 3 名创作者和 5 名玩家。"""
wave_dir = self.init_wave("wave0")
manifest = json.loads((wave_dir / "manifest.json").read_text(encoding="utf-8"))
self.assertEqual(manifest["wave"], "wave0")
self.assertEqual(manifest["creatorCount"], 3)
self.assertEqual(manifest["playerCount"], 5)
self.assertTrue((wave_dir / "oncall.tsv").is_file())
self.assertTrue((wave_dir / "feedback.tsv").is_file())
self.assertTrue((wave_dir / "p0.tsv").is_file())
self.assertTrue((wave_dir / "config-identity.json").is_file())
self.assertTrue((wave_dir / "manual-acceptance.tsv").is_file())
for name in (
"rc.json", "snapshot.json", "storage-audit.json", "accounts.json", "role-permissions.json",
"main-chain.json", "release-negatives.json", "manual-play.json", "performance.json",
"telemetry.json", "alerts.json", "drills.json", "safety-status.json",
):
self.assertTrue((wave_dir / name).is_file(), name)
self.assertTrue((wave_dir / "attachments").is_dir())
self.assertTrue((wave_dir / "freezes").is_dir())
def test_init_wave1_uses_approved_scale(self) -> None:
"""wave1 必须固定为累计 10 名创作者和 30 名玩家。"""
wave_dir = self.init_wave("wave1")
manifest = json.loads((wave_dir / "manifest.json").read_text(encoding="utf-8"))
self.assertEqual((manifest["creatorCount"], manifest["playerCount"]), (10, 30))
def test_repeated_init_is_idempotent_and_does_not_overwrite(self) -> None:
"""重复初始化应成功返回,但不得覆盖已经填写的证据。"""
wave_dir = self.init_wave("wave0")
feedback = wave_dir / "feedback.tsv"
feedback.write_text(feedback.read_text(encoding="utf-8") + "F-1\t2026-07-22T00:00:00Z\tu1\tplayer\tbug\tP1\t卡住\tOPEN\tops\tattachments/f.txt\n", encoding="utf-8")
before = hashlib.sha256(feedback.read_bytes()).hexdigest()
result = self.run_tool("init", "--root", str(self.root), "--wave", "wave0")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(hashlib.sha256(feedback.read_bytes()).hexdigest(), before)
self.assertIn("INIT_ALREADY_EXISTS", result.stdout)
def test_repeated_init_repairs_missing_templates_without_overwrite(self) -> None:
"""初始化中断后重试应补回缺失模板,同时保留已经填写的账本。"""
wave_dir = self.init_wave("wave0")
feedback = wave_dir / "feedback.tsv"
feedback.write_text(feedback.read_text(encoding="utf-8") + "kept\n", encoding="utf-8")
(wave_dir / "rc.json").unlink()
(wave_dir / "attachments").rmdir()
result = self.run_tool("init", "--root", str(self.root), "--wave", "wave0")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("INIT_REPAIRED", result.stdout)
self.assertTrue((wave_dir / "rc.json").is_file())
self.assertTrue((wave_dir / "attachments").is_dir())
self.assertTrue(feedback.read_text(encoding="utf-8").endswith("kept\n"))
def test_init_rejects_unknown_wave_instead_of_creating_a_path(self) -> None:
"""波次名不得被当成任意相对路径使用。"""
result = self.run_tool("init", "--root", str(self.root), "--wave", "../wave0")
self.assertNotEqual(result.returncode, 0)
self.assertFalse((Path(self.temp_dir.name) / "wave0").exists())
self.assertIn("invalid choice", result.stderr)
def test_readiness_fails_closed_when_evidence_is_missing(self) -> None:
"""仅初始化模板不得被误判为可开波。"""
wave_dir = self.init_wave("wave0")
result = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "readiness")
self.assertNotEqual(result.returncode, 0)
self.assertIn("rc.missing", result.stdout)
self.assertNotIn("GATE_PASS", result.stdout)
def test_readiness_accepts_complete_structured_evidence(self) -> None:
"""完整且互相对账的结构化证据应通过开波门。"""
wave_dir = self.init_wave("wave0")
self.populate_valid_evidence(wave_dir)
result = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "readiness")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("GATE_PASS phase=readiness", result.stdout)
def test_performance_requires_at_least_30_cold_independent_samples(self) -> None:
"""冷样本少于 30 个时必须直接失败。"""
wave_dir = self.init_wave("wave0")
self.populate_valid_evidence(wave_dir)
path = wave_dir / "performance.json"
payload = json.loads(path.read_text(encoding="utf-8"))
payload["samples"] = payload["samples"][:29]
self.write_json(path, payload)
result = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "readiness")
self.assertNotEqual(result.returncode, 0)
self.assertIn("performance.sample-count", result.stdout)
def test_performance_p75_uses_nearest_rank_and_rejects_3000ms(self) -> None:
"""30 样本的第 23 个值是 P75恰好 3000ms 不满足严格小于。"""
wave_dir = self.init_wave("wave0")
self.populate_valid_evidence(wave_dir)
path = wave_dir / "performance.json"
payload = json.loads(path.read_text(encoding="utf-8"))
for index, sample in enumerate(payload["samples"]):
sample["durationMs"] = 1000 if index < 22 else 3000
self.write_json(path, payload)
result = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "readiness")
self.assertNotEqual(result.returncode, 0)
self.assertIn("performance.p75", result.stdout)
def test_performance_accepts_p75_of_2999ms(self) -> None:
"""严格阈值内的第 23 个值应通过。"""
wave_dir = self.init_wave("wave0")
self.populate_valid_evidence(wave_dir)
path = wave_dir / "performance.json"
payload = json.loads(path.read_text(encoding="utf-8"))
for index, sample in enumerate(payload["samples"]):
sample["durationMs"] = 1000 if index < 22 else 2999
self.write_json(path, payload)
result = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "readiness")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_performance_rejects_hot_cache_mixed_into_cold_samples(self) -> None:
"""冷样本文件中混入热缓存记录时不得计算 P75。"""
wave_dir = self.init_wave("wave0")
self.populate_valid_evidence(wave_dir)
path = wave_dir / "performance.json"
payload = json.loads(path.read_text(encoding="utf-8"))
payload["samples"][0]["cache"] = "hot"
self.write_json(path, payload)
result = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "readiness")
self.assertNotEqual(result.returncode, 0)
self.assertIn("performance.cold-only", result.stdout)
def test_performance_rejects_non_finite_duration(self) -> None:
"""JSON NaN 不得绕过非负数和 P75 阈值。"""
wave_dir = self.init_wave("wave0")
self.populate_valid_evidence(wave_dir)
path = wave_dir / "performance.json"
payload = json.loads(path.read_text(encoding="utf-8"))
for sample in payload["samples"]:
sample["durationMs"] = float("nan")
self.write_json(path, payload)
result = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "readiness")
self.assertNotEqual(result.returncode, 0)
self.assertIn("performance.duration", result.stdout)
def test_main_chain_requires_distinct_evidence_modalities(self) -> None:
"""同一个占位附件不得同时冒充截图、网络、日志和数据库证据。"""
wave_dir = self.init_wave("wave0")
self.populate_valid_evidence(wave_dir)
path = wave_dir / "main-chain.json"
payload = json.loads(path.read_text(encoding="utf-8"))
for field in ("networkRef", "logRef", "databaseRef"):
payload["stages"][0][field] = payload["stages"][0]["screenshotRef"]
self.write_json(path, payload)
result = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "readiness")
self.assertNotEqual(result.returncode, 0)
self.assertIn("main-chain.evidence-modalities", result.stdout)
def test_telemetry_accepts_exactly_95_percent_and_rejects_below(self) -> None:
"""完整率 95% 可过,低于 95% 必须失败。"""
wave_dir = self.init_wave("wave0")
self.populate_valid_evidence(wave_dir)
telemetry = wave_dir / "telemetry.json"
pass_result = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "readiness")
self.assertEqual(pass_result.returncode, 0, pass_result.stdout + pass_result.stderr)
self.write_json(telemetry, {"schema": "dogfood-telemetry-completeness/1", "expectedCount": 100, "completeCount": 94, "evidenceRef": "attachments/browser-proof.png"})
fail_result = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "readiness")
self.assertNotEqual(fail_result.returncode, 0)
self.assertIn("telemetry.completeness", fail_result.stdout)
def test_top_level_pass_cannot_forge_restore_evidence(self) -> None:
"""写一个 PASS 字符串不能替代隔离恢复目标和证据。"""
wave_dir = self.init_wave("wave0")
self.populate_valid_evidence(wave_dir)
self.write_json(wave_dir / "snapshot.json", {"schema": "dogfood-snapshot/1", "result": "PASS"})
result = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "readiness")
self.assertNotEqual(result.returncode, 0)
self.assertIn("snapshot.mysql", result.stdout)
self.assertIn("snapshot.isolated-restore", result.stdout)
def test_evidence_reference_cannot_escape_wave_directory(self) -> None:
"""即使目录外文件存在,证据引用也不得越过波次根。"""
wave_dir = self.init_wave("wave0")
self.populate_valid_evidence(wave_dir)
outside = wave_dir.parent / "outside.png"
outside.write_text("outside", encoding="utf-8")
payload = json.loads((wave_dir / "release-negatives.json").read_text(encoding="utf-8"))
payload["checks"][0]["evidenceRef"] = "../outside.png"
self.write_json(wave_dir / "release-negatives.json", payload)
result = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "readiness")
self.assertNotEqual(result.returncode, 0)
self.assertIn("reference.outside-wave", result.stdout)
def test_main_chain_artifact_must_match_the_rc_artifact(self) -> None:
"""主链不能用另一份产物哈希冒充当前 RC 的验收结果。"""
wave_dir = self.init_wave("wave0")
self.populate_valid_evidence(wave_dir)
path = wave_dir / "main-chain.json"
payload = json.loads(path.read_text(encoding="utf-8"))
payload["identity"]["artifactSha256"] = "f" * 64
self.write_json(path, payload)
result = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "readiness")
self.assertNotEqual(result.returncode, 0)
self.assertIn("main-chain.rc-artifact", result.stdout)
def test_close_requires_freeze_and_detects_post_freeze_tampering(self) -> None:
"""收口门必须验证最近一次 SHA-256 冻结,冻结后改动立即失效。"""
wave_dir = self.init_wave("wave0")
self.populate_valid_evidence(wave_dir)
missing = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "close")
self.assertNotEqual(missing.returncode, 0)
self.assertIn("freeze.missing", missing.stdout)
freeze = self.run_tool("freeze", "--wave-dir", str(wave_dir), "--date", "2026-07-22")
self.assertEqual(freeze.returncode, 0, freeze.stdout + freeze.stderr)
passed = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "close")
self.assertEqual(passed.returncode, 0, passed.stdout + passed.stderr)
with (wave_dir / "feedback.tsv").open("a", encoding="utf-8") as stream:
stream.write("F-2\t2026-07-22T08:00:00Z\tu-2\tplayer\tbug\tP1\ttampered\tOPEN\tops\tattachments/browser-proof.png\n")
tampered = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "close")
self.assertNotEqual(tampered.returncode, 0)
self.assertIn("freeze.hash-mismatch", tampered.stdout)
def test_close_rejects_malformed_freeze_without_traceback(self) -> None:
"""畸形冻结清单应形成可追踪失败,而不是让闸门异常退出。"""
wave_dir = self.init_wave("wave0")
self.populate_valid_evidence(wave_dir)
freeze_dir = wave_dir / "freezes"
(freeze_dir / "2026-07-22.sha256.json").write_text("[]\n", encoding="utf-8")
result = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "close")
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn("freeze.schema", result.stdout)
self.assertNotIn("Traceback", result.stderr)
def test_close_rejects_symlink_added_after_freeze_without_traceback(self) -> None:
"""冻结后加入符号链接时应 fail-closed并保持稳定退出码。"""
wave_dir = self.init_wave("wave0")
self.populate_valid_evidence(wave_dir)
freeze = self.run_tool("freeze", "--wave-dir", str(wave_dir), "--date", "2026-07-22")
self.assertEqual(freeze.returncode, 0, freeze.stderr)
(wave_dir / "attachments" / "late-link").symlink_to(wave_dir / "attachments" / "browser-proof.png")
result = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "close")
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn("freeze.collection", result.stdout)
self.assertNotIn("Traceback", result.stderr)
def test_wave1_close_requires_three_chained_daily_freezes(self) -> None:
"""三天 wave1 不得用单日快照代替每日冻结,且历史清单必须受哈希链保护。"""
wave_dir = self.init_wave("wave1")
self.populate_valid_evidence(wave_dir)
first = self.run_tool("freeze", "--wave-dir", str(wave_dir), "--date", "2026-07-22")
self.assertEqual(first.returncode, 0, first.stderr)
too_few = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "close")
self.assertNotEqual(too_few.returncode, 0)
self.assertIn("freeze.daily-count", too_few.stdout)
for day in ("2026-07-23", "2026-07-24"):
frozen = self.run_tool("freeze", "--wave-dir", str(wave_dir), "--date", day)
self.assertEqual(frozen.returncode, 0, frozen.stderr)
passed = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "close")
self.assertEqual(passed.returncode, 0, passed.stdout + passed.stderr)
first_path = wave_dir / "freezes" / "2026-07-22.sha256.json"
first_path.write_text(first_path.read_text(encoding="utf-8") + " ", encoding="utf-8")
tampered = self.run_tool("check", "--wave-dir", str(wave_dir), "--phase", "close")
self.assertNotEqual(tampered.returncode, 0)
self.assertIn("freeze.chain", tampered.stdout)
def test_record_drill_requires_exact_ack_and_is_idempotent(self) -> None:
"""演练记录必须使用精确 ACK重复签收同一证据不得制造重复行。"""
wave_dir = self.init_wave("wave0")
evidence_ref, _ = self.add_attachment(wave_dir, "stop-drill.txt")
common_args = (
"record-drill", "--wave-dir", str(wave_dir), "--action", "stop",
"--operator", "ops-1", "--performed-at", "2026-07-22T06:00:00Z",
"--evidence-ref", evidence_ref,
)
refused = self.run_tool(*common_args, "--ack", "yes")
self.assertNotEqual(refused.returncode, 0)
self.assertIn("ACK_REFUSED", refused.stderr)
accepted = self.run_tool(*common_args, "--ack", "ACK-DOGFOOD-wave0-stop")
self.assertEqual(accepted.returncode, 0, accepted.stdout + accepted.stderr)
repeated = self.run_tool(*common_args, "--ack", "ACK-DOGFOOD-wave0-stop")
self.assertEqual(repeated.returncode, 0, repeated.stdout + repeated.stderr)
self.assertIn("DRILL_ALREADY_RECORDED", repeated.stdout)
payload = json.loads((wave_dir / "drills.json").read_text(encoding="utf-8"))
self.assertEqual(len(payload["drills"]), 1)
def test_freeze_is_idempotent_but_refuses_same_day_rewrite(self) -> None:
"""同一证据可重复冻结,内容变化后不得覆盖同日清单。"""
wave_dir = self.init_wave("wave0")
first = self.run_tool("freeze", "--wave-dir", str(wave_dir), "--date", "2026-07-22")
self.assertEqual(first.returncode, 0, first.stderr)
repeated = self.run_tool("freeze", "--wave-dir", str(wave_dir), "--date", "2026-07-22")
self.assertEqual(repeated.returncode, 0, repeated.stderr)
self.assertIn("FREEZE_ALREADY_EXISTS", repeated.stdout)
(wave_dir / "feedback.tsv").write_text("changed\n", encoding="utf-8")
refused = self.run_tool("freeze", "--wave-dir", str(wave_dir), "--date", "2026-07-22")
self.assertNotEqual(refused.returncode, 0)
self.assertIn("FREEZE_REFUSED", refused.stderr)
def test_shell_entrypoints_delegate_to_the_single_cli(self) -> None:
"""值班入口应直接调用同一套实现,避免脚本间口径漂移。"""
scripts = [
"init-wave.sh", "check-readiness.sh", "check-close.sh",
"freeze-evidence.sh", "record-drill.sh",
]
for name in scripts:
result = subprocess.run(
["bash", str(OPS_DIR / name), "--help"],
check=False,
text=True,
capture_output=True,
)
self.assertEqual(result.returncode, 0, f"{name}: {result.stdout}{result.stderr}")
if __name__ == "__main__":
unittest.main()