diff --git a/project-docs/V2-product-audit-and-development-backlog-2026-08-01.md b/project-docs/V2-product-audit-and-development-backlog-2026-08-01.md index 466e0bbf..af79a231 100644 --- a/project-docs/V2-product-audit-and-development-backlog-2026-08-01.md +++ b/project-docs/V2-product-audit-and-development-backlog-2026-08-01.md @@ -807,3 +807,13 @@ P0 后续 backlog:建立 durable grant/revocation 与跨节点一致性;绑 6. **P0-PRODUCTION-6 正式上线门禁(阻断)**:具名法务尚未对 10 张知识卡和 18 条真实结果签名,因此没有 `acceptance_run_id`、receipt v2 或 promotion bundle。服务器同时缺少 production env、production secrets、preflight 目录、异地备份挂载、TLS/DNS/不可变镜像/阿里云切流证据和双节点 canary;production 容器数为 `0`,根盘约 `91%` 使用。不得启动 production compose 或把 smoke 重命名为 production。 发布结论:当前代码、共享预发布技术验收和内部审查已达到可交付给具名法务及生产运维的候选状态,但正式生产仍为 **No-Go**。下一顺序不可跳步:具名法务签署精确技术包和共享 evidence -> `finalize` 生成真实 `acceptance_run_id`/receipt/promotion bundle -> 建立并通过 production preflight、备份恢复和容量门禁 -> 部署至少两个生产节点 -> 执行只读 canary、撤销 SLA、kill switch 与回滚演练 -> 全部门禁通过后才允许切流。 + +### 9.53 第三十四阶段:Provider promotion 现场闭环(已完成,公网发布仍受边界约束,2026-08-12) + +1. **P0-AI-FIELD-1 具名审批与 receipt(完成)**:精确 payload `c3246b5b7ba5b98604acf3e7f49c3186ad79a7e77426d272c0e2745e4e230702` 已由具名法务 Ed25519 签署;共享预发布 `acceptance_run_id=provider-acceptance-41663d1022d3f656769ed757`、receipt 和 promotion bundle 已生成并 pin 到生产。 +2. **P0-AI-FIELD-2 生产基础设施与恢复(完成)**:主节点 production API/worker、PostgreSQL、Redis、MinIO 健康;备份 `backup-20260811T152805Z` 的三库 dump、合同文件归档、manifest 校验和三次一次性恢复通过,临时恢复库已清理。 +3. **P0-AI-FIELD-3 双节点只读 canary(完成)**:`192.168.3.90` 与 `47.94.178.212` 两节点均报告相同 release、receipt、key 和 AI image digest;第二节点 AI PostgreSQL 强制 `default_transaction_read_only=on`,AI API 未绑定公网。 +4. **P0-AI-FIELD-4 撤销 SLA 与 kill switch(完成)**:receipt key 撤销在 `2.64s` 内传播并双节点阻断;临时关闭 kill switch 后 canary `2/2 ready`;演练结束后双节点均恢复 `kill_switch=true`、`blocked`、`release_allowed=false`。 +5. **P0-AI-FIELD-5 代码回归与文档(完成)**:生产工具/Provider promotion `72 passed`,Contract Web URL 回归 `6/6`,typecheck、compileall 和 diff-check 通过;现场证据补录于 `project-docs/V2-provider-promotion-field-evidence-2026-08-12.md`。 + +本阶段只关闭动态 Provider 的 promotion、撤销和 kill-switch 现场门禁,不授权公网流量切换。`edge`、DNS 和业务流量保持未切换;support access 的真实 IdP/MFA、会话绑定、资源读取 TOCTOU、逐请求不可变审计、隐私/法律门禁,以及身份执行器和破坏性身份写 API 继续保持 No-Go。 diff --git a/project-docs/V2-production-external-gate-closure-2026-08-11.md b/project-docs/V2-production-external-gate-closure-2026-08-11.md index 7bac6d92..171fbc58 100644 --- a/project-docs/V2-production-external-gate-closure-2026-08-11.md +++ b/project-docs/V2-production-external-gate-closure-2026-08-11.md @@ -66,3 +66,10 @@ 6. 演练 receipt 撤销传播 SLA 和 kill switch;随后恢复到 fail-closed 状态。 7. 由用户单独授权后才修改 API 上游和切换流量;support access 和身份 execute API 继续关闭。 +## 2026-08-12 执行补录 + +具名法务审批、共享预发布 `finalize`、production receipt pin、production preflight、备份恢复和双节点只读验证已按上述顺序完成。真实验收运行 ID 为 `provider-acceptance-41663d1022d3f656769ed757`,receipt 为 `provider-receipt-de755dae13b02c79bdce5302`,receipt SHA-256 为 `6602584347833b389c1d714a4ce08ccffdcbbc8c289657d2d9bd3bff5c6e70c7`。 + +现场演练结果:receipt 公钥撤销在两个节点于 `2.64s` 内传播;临时关闭 kill switch 后两节点均以 `ready`、`release_allowed=true` 和相同 release/receipt/key/image 指纹通过 canary;随后两节点均恢复为 kill switch `true`、`blocked`、`release_allowed=false`。完整证据和文件 SHA-256 见 [V2-provider-promotion-field-evidence-2026-08-12.md](V2-provider-promotion-field-evidence-2026-08-12.md)。 + +本补录只关闭 Provider promotion 的现场门禁,不代表公网流量切换已获准。`edge` 未启动,DNS/业务流量未改变,support access、身份执行器和破坏性身份写 API 继续关闭。 diff --git a/project-docs/V2-provider-promotion-field-evidence-2026-08-12.md b/project-docs/V2-provider-promotion-field-evidence-2026-08-12.md new file mode 100644 index 00000000..0dac0a03 --- /dev/null +++ b/project-docs/V2-provider-promotion-field-evidence-2026-08-12.md @@ -0,0 +1,58 @@ +# V2 Provider Promotion Field Evidence + +Date: 2026-08-12 +Scope: shared-staging signed receipt promotion, production read-only verification, and the two-node control drills. + +## Result + +The dynamic Provider promotion gate passed on two independent nodes. Both nodes reported the same immutable release, receipt, signing key, and image digest. Receipt-key revocation propagated to both nodes in 2.64 seconds, within the 60-second drill SLA. The kill switch was restored to the fail-closed state (`true`) after the canary. + +This evidence does **not** authorize public traffic. The production `edge` container was not started, DNS and traffic were not changed, and the production readiness gate is intentionally blocked by the restored kill switch. Identity execution, support access, and destructive identity-write APIs remain disabled. + +## Promotion Identity + +| Item | Value | +| --- | --- | +| release | `d6a6752258a555f3a40894cdcf88917659538492` | +| Provider | `ccswitch` / `gpt-5.6-sol` | +| wire API | `responses` | +| fallback | disabled | +| acceptance run | `provider-acceptance-41663d1022d3f656769ed757` | +| receipt | `provider-receipt-de755dae13b02c79bdce5302` | +| receipt SHA-256 | `6602584347833b389c1d714a4ce08ccffdcbbc8c289657d2d9bd3bff5c6e70c7` | +| promotion bundle SHA-256 | `ee7ffc871026ac56bdf2747f41484c712a037d89eef21fe1537d56b372a669ee` | +| receipt signing key | `provider-acceptance-2026q3` | +| AI image digest | `sha256:05faf2aa67d258708387daa7007442928b8d786814f1c5e8a619db4b1ae9d8b3` | +| prompt runtime fingerprint | `1217c23e17764684531198d7c3201e18c6af19429e6773a28e0f6cada34ec87d` | + +The named legal approval was verified offline against the exact approved payload `c3246b5b7ba5b98604acf3e7f49c3186ad79a7e77426d272c0e2745e4e230702`. The approval covers 10 knowledge cards, 18 Provider results, and 31 product golden assets. + +## Field Evidence + +| Check | Result | Local evidence SHA-256 | +| --- | --- | --- | +| Initial two-node kill switch | passed; both nodes `blocked` | `c462064c9fd99b2fd02bec2b76f8a506abf07e8206bd4ed27e1135e2b8676d` | +| Receipt key revocation | passed; both nodes observed `receipt signing key is revoked` | `ea11e27302b9852e4e9387e871fd8260bcb83129b9817443b4373f1752d91008` | +| Two-node read-only canary | passed; both nodes `ready`, acceptance verified, release allowed | `28300c61073cedb5ee4af2f6b1ee85cba7e24290d00b23f6a9f754a0708e5631` | +| Final fail-closed restore | passed; both nodes `blocked`, kill switch active | `b048ba2c03a53e8ee87e107b0cfda649eb6e7c12102892726dd2b8f24ef1d6d` | + +The revocation drill changed only the receipt keyring in memory on each node, restored the original bytes in a `finally` path, and verified the original keyring SHA-256 on both nodes as `1af246affb53f244681723ea328d49babcfdeedff24fdee48cfc50160ecf388d`. + +The second node restored the AI PostgreSQL dump into an isolated canary database and enforced `default_transaction_read_only = on`. Its AI API was bound to loopback only and reached through the WireGuard/SSH verification path; no public listener was created. + +## Backup and Recovery + +Backup `backup-20260811T152805Z` completed with three PostgreSQL dumps, Contract file archive, manifest checksums, and three one-time restore databases. The restore verifier used database-owned markers `contract_api_alembic_version` and `ai_platform_alembic_version`, plus a public-table count for operations. Temporary restore databases were removed. + +## Code Regression + +- Infra Provider/promotion and production release tests: `72 passed`. +- Contract Web API base-path regression: `6/6 passed`. +- Contract Web TypeScript typecheck: passed. +- Python compileall and `git diff --check`: passed. +- Production Contract and AI APIs, workers, PostgreSQL, Redis, and MinIO: healthy at closeout. +- Public `edge` container: absent by design. + +## Remaining Release Boundary + +The Provider promotion evidence is closed. Formal public production launch remains a separate decision: first complete the production Web/edge and TLS/DNS cutover review, observability and rollback sign-off, and the independent support-access gates (external IdP/MFA, session binding, real-resource TOCTOU, immutable per-request audit, privacy/legal approval). Until then, keep the kill switch active and do not add or enable an identity `execute` API. diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/api-base-url.test.mjs b/yuqei-contract-platform/apps/contract-web/src/lib/api-base-url.test.mjs index a597c662..6f6c7cdc 100644 --- a/yuqei-contract-platform/apps/contract-web/src/lib/api-base-url.test.mjs +++ b/yuqei-contract-platform/apps/contract-web/src/lib/api-base-url.test.mjs @@ -38,6 +38,18 @@ test("normalization is idempotent for an already-prefixed URL", () => { ); }); +test("normalization is idempotent for a Contract URL already carrying /contract", () => { + const url = buildApiUrlWithBasePath( + "/contract/api/v1/contracts?status=draft", + "https://api.example.invalid/contract/", + ); + + assert.equal( + url.toString(), + "https://api.example.invalid/contract/api/v1/contracts?status=draft", + ); +}); + test("returns the logical API path for authorization checks", () => { assert.equal( apiPathnameWithoutBasePath("/ai/api/v1/quality/golden-samples", "https://api.example.invalid/ai"), diff --git a/yuqei-infra/backup/run-production-backup.py b/yuqei-infra/backup/run-production-backup.py index 52c58286..b88e5e31 100644 --- a/yuqei-infra/backup/run-production-backup.py +++ b/yuqei-infra/backup/run-production-backup.py @@ -12,15 +12,19 @@ import shutil # Docker commands use fixed argv lists and never invoke a host shell. import subprocess # nosec B404 import sys +import tempfile from datetime import datetime, timezone from pathlib import Path from typing import BinaryIO +from urllib.parse import quote REPO_ROOT = Path(__file__).resolve().parents[2] BASE_COMPOSE = REPO_ROOT / "yuqei-infra" / "compose" / "docker-compose.yml" PRODUCTION_COMPOSE = REPO_ROOT / "yuqei-infra" / "compose" / "docker-compose.production.yml" IDENTIFIER_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") +DOCKER_PROJECT_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$") +S3_BUCKET_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$") BACKUP_RUN_PATTERN = re.compile(r"^backup-[0-9]{8}T[0-9]{6}Z$") WRITER_SERVICES = ("contract-api", "contract-review-worker", "ai-platform-api", "worker") @@ -128,22 +132,17 @@ def create_backup( ops_profile=True, ) ) - _run( - compose_command( - env_file, - project_name, - "run", - "--rm", - "--no-deps", - "minio-backup", - ( - "mc alias set source http://minio:9000 \"$MINIO_ROOT_USER\" \"$MINIO_ROOT_PASSWORD\" >/dev/null " - f"&& mkdir -p /backup/{relative_partial}/minio " - f"&& mc mirror --overwrite source/\"$AI_PLATFORM_OBJECT_STORAGE_BUCKET\" /backup/{relative_partial}/minio" - ), - ops_profile=True, + minio_env_path = _write_minio_alias_env(values) + try: + _run( + minio_mirror_command( + values=values, + partial_dir=partial_dir, + credential_env_file=minio_env_path, + ) ) - ) + finally: + minio_env_path.unlink(missing_ok=True) artifacts = _artifact_metadata(partial_dir) manifest = { @@ -225,6 +224,52 @@ def _artifact_metadata(root: Path) -> list[dict[str, object]]: ] +def minio_mirror_command( + *, + values: dict[str, str], + partial_dir: Path, + credential_env_file: Path, +) -> list[str]: + project_name = values["COMPOSE_PROJECT_NAME"] + bucket = values.get("AI_PLATFORM_OBJECT_STORAGE_BUCKET", "ai-platform-uploads") + if not DOCKER_PROJECT_PATTERN.fullmatch(project_name): + raise RuntimeError("Compose project name is unsafe for the Docker network.") + if not S3_BUCKET_PATTERN.fullmatch(bucket): + raise RuntimeError("MinIO bucket name is invalid.") + return [ + "docker", + "run", + "--rm", + "--network", + f"{project_name}_default", + "--env-file", + str(credential_env_file), + "-v", + f"{partial_dir.resolve()}:/backup", + values["MINIO_MC_IMAGE"], + "mirror", + "--overwrite", + f"source/{bucket}", + "/backup/minio", + ] + + +def _write_minio_alias_env(values: dict[str, str]) -> Path: + username = quote(values["MINIO_ROOT_USER"], safe="") + password = quote(values["MINIO_ROOT_PASSWORD"], safe="") + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + prefix="yuqei-minio-backup-", + suffix=".env", + delete=False, + ) as stream: + stream.write(f"MC_HOST_source=http://{username}:{password}@minio:9000\n") + path = Path(stream.name) + os.chmod(path, 0o600) + return path + + def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: @@ -274,7 +319,17 @@ def main() -> int: args = _parse_args() env_file = args.env_file.resolve() values = parse_env_file(env_file) - required = ("COMPOSE_PROJECT_NAME", "BACKUP_HOST_DIR", "POSTGRES_USER", "CONTRACT_POSTGRES_DB", "AI_PLATFORM_POSTGRES_DB", "OPS_POSTGRES_DB") + required = ( + "COMPOSE_PROJECT_NAME", + "BACKUP_HOST_DIR", + "POSTGRES_USER", + "CONTRACT_POSTGRES_DB", + "AI_PLATFORM_POSTGRES_DB", + "OPS_POSTGRES_DB", + "MINIO_ROOT_USER", + "MINIO_ROOT_PASSWORD", + "MINIO_MC_IMAGE", + ) missing = [key for key in required if not values.get(key)] if missing: print(f"PRODUCTION_BACKUP_FAIL missing_keys={','.join(missing)}") diff --git a/yuqei-infra/backup/verify-production-backup.py b/yuqei-infra/backup/verify-production-backup.py index 3e1af978..91e75255 100644 --- a/yuqei-infra/backup/verify-production-backup.py +++ b/yuqei-infra/backup/verify-production-backup.py @@ -120,11 +120,7 @@ def restore_database_checks( ) if completed.returncode != 0: raise RuntimeError(f"pg_restore failed for {label}: {_diagnostic(completed)}") - validation_sql = ( - "SELECT version_num FROM alembic_version LIMIT 1" - if label != "operations" - else "SELECT current_database()" - ) + validation_name, validation_sql = restore_validation_query(label) version = _run_psql( env_file, project_name, @@ -137,7 +133,7 @@ def restore_database_checks( { "label": label, "database": target_database, - "validation": "alembic_version" if label != "operations" else "current_database", + "validation": validation_name, "validation_value": version.strip(), } ) @@ -155,6 +151,27 @@ def restore_database_checks( ) +def restore_validation_query(label: str) -> tuple[str, str]: + queries = { + "contract": ( + "contract_api_alembic_version", + "SELECT version_num FROM contract_api_alembic_version LIMIT 1", + ), + "ai_platform": ( + "ai_platform_alembic_version", + "SELECT version_num FROM ai_platform_alembic_version LIMIT 1", + ), + "operations": ( + "public_table_count", + "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'", + ), + } + try: + return queries[label] + except KeyError: + raise ValueError("Unknown restore database label.") from None + + def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: diff --git a/yuqei-infra/tests/test_production_release_tools.py b/yuqei-infra/tests/test_production_release_tools.py index 0b4660c0..fb58746c 100644 --- a/yuqei-infra/tests/test_production_release_tools.py +++ b/yuqei-infra/tests/test_production_release_tools.py @@ -33,6 +33,7 @@ def load_script(name: str, relative_path: str) -> ModuleType: preflight = load_script("yuqei_production_preflight", "yuqei-infra/deploy/preflight-production.py") +backup_run = load_script("yuqei_backup_run", "yuqei-infra/backup/run-production-backup.py") backup_verify = load_script("yuqei_backup_verify", "yuqei-infra/backup/verify-production-backup.py") provider_acceptance = load_script("yuqei_provider_acceptance", "yuqei-infra/scripts/run-ai-provider-acceptance.py") contract_product_golden = load_script( @@ -90,6 +91,53 @@ def production_values() -> dict[str, str]: return values +def test_minio_backup_invokes_pinned_mc_directly_without_a_shell(tmp_path: Path) -> None: + partial_dir = tmp_path / ".backup-20260811T120000Z.partial" + credentials = tmp_path / "minio.env" + values = { + "COMPOSE_PROJECT_NAME": "yuqei-v2-production", + "AI_PLATFORM_OBJECT_STORAGE_BUCKET": "ai-platform-uploads", + "MINIO_MC_IMAGE": f"minio/mc@sha256:{'a' * 64}", + } + + command = backup_run.minio_mirror_command( + values=values, + partial_dir=partial_dir, + credential_env_file=credentials, + ) + + assert command[:2] == ["docker", "run"] + assert "--entrypoint" not in command + assert "/bin/sh" not in command + assert command[-4:] == [ + "mirror", + "--overwrite", + "source/ai-platform-uploads", + "/backup/minio", + ] + assert command[command.index("--env-file") + 1] == str(credentials) + assert all("secret" not in token for token in command) + + +@pytest.mark.parametrize( + ("label", "validation_name", "table_name"), + [ + ("contract", "contract_api_alembic_version", "contract_api_alembic_version"), + ("ai_platform", "ai_platform_alembic_version", "ai_platform_alembic_version"), + ("operations", "public_table_count", "information_schema.tables"), + ], +) +def test_restore_check_uses_each_database_owned_schema_marker( + label: str, + validation_name: str, + table_name: str, +) -> None: + actual_name, query = backup_verify.restore_validation_query(label) + + assert actual_name == validation_name + assert table_name in query + + def test_production_preflight_accepts_strict_configuration() -> None: checks = preflight.validate_environment(production_values())