From effbefc83ddb60b848fca3cb108cfcfc9c22911c Mon Sep 17 00:00:00 2001 From: yuqei Date: Tue, 11 Aug 2026 17:07:35 +0800 Subject: [PATCH] fix: wire contract provider timeout budgets --- ...-platform-production-closure-2026-06-24.md | 1 + ...l-production-readiness-audit-2026-08-11.md | 76 +++++++++++++++++++ .../apps/contract-web/package.json | 1 + .../contract-web/src/lib/ai-platform-api.ts | 12 ++- .../src/lib/api-base-url.test.mjs | 50 ++++++++++++ .../apps/contract-web/src/lib/api-base-url.ts | 23 ++++++ .../apps/contract-web/src/lib/contract-api.ts | 13 +++- .../contract-web/src/services/contracts.ts | 9 +-- .../services/contract-api/.env.example | 1 + .../services/contract-api/README.md | 3 + .../yuqei_contract_api/api/contracts_core.py | 7 +- .../src/yuqei_contract_api/api/review.py | 1 + .../src/yuqei_contract_api/config.py | 7 +- .../src/yuqei_contract_api/review_worker.py | 12 ++- .../tests/test_contract_workflow.py | 27 +++++-- .../contract-api/tests/test_health.py | 8 ++ .../contract-api/tests/test_legal_qa_proxy.py | 14 +++- yuqei-infra/README.md | 3 + yuqei-infra/compose/.env.example | 1 + yuqei-infra/compose/.env.production.example | 1 + yuqei-infra/compose/docker-compose.yml | 9 +++ yuqei-infra/deploy/preflight-production.py | 25 ++++++ .../nginx/templates/default.conf.template | 14 ++++ .../scripts/deploy-v2-smoke-docker.ps1 | 8 +- .../tests/test_production_release_tools.py | 66 ++++++++++++++++ 25 files changed, 364 insertions(+), 28 deletions(-) create mode 100644 project-docs/V2-final-production-readiness-audit-2026-08-11.md create mode 100644 yuqei-contract-platform/apps/contract-web/src/lib/api-base-url.test.mjs create mode 100644 yuqei-contract-platform/apps/contract-web/src/lib/api-base-url.ts diff --git a/project-docs/V2-contract-flow-ai-platform-production-closure-2026-06-24.md b/project-docs/V2-contract-flow-ai-platform-production-closure-2026-06-24.md index 786545ea..b3514be1 100644 --- a/project-docs/V2-contract-flow-ai-platform-production-closure-2026-06-24.md +++ b/project-docs/V2-contract-flow-ai-platform-production-closure-2026-06-24.md @@ -72,6 +72,7 @@ yuqei-contract-platform/services/contract-api/migrations/versions/20260624_0007_ 1. `contract-review-worker` 的关键运行变量已从 compose 硬编码调整为 `.env` 可覆盖: - `CONTRACT_API_DATABASE_URL` - `CONTRACT_API_AI_PLATFORM_BASE_URL` + - `CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS` (must exceed the AI Provider request timeout; staging uses 240 seconds for a 180-second Provider budget) - `CONTRACT_API_REVIEW_WORKER_NAME` - `CONTRACT_API_REVIEW_WORKER_POLL_INTERVAL_SECONDS` - `CONTRACT_API_REVIEW_WORKER_BATCH_LIMIT` diff --git a/project-docs/V2-final-production-readiness-audit-2026-08-11.md b/project-docs/V2-final-production-readiness-audit-2026-08-11.md new file mode 100644 index 00000000..21bd8e01 --- /dev/null +++ b/project-docs/V2-final-production-readiness-audit-2026-08-11.md @@ -0,0 +1,76 @@ +# AI 合同平台 V2 最终生产就绪审计 + +日期:2026-08-11 + +## 结论 + +当前候选版本中可由代码和自动化完成的功能、回归、安全扫描与共享预发布技术验证已通过,最终差异审查未留下已知代码级 P0/P1。本轮修复了 Contract 调用 AI Platform 时固定使用 SDK 30 秒超时的问题,并在真实动态 Provider 上完成了合同审查闭环;同时关闭了生产 Web API 前缀丢失问题。 + +正式生产仍为 **No-Go**。未启动 production compose,未修改 DNS,未切换生产流量。阻断原因是具名法务、生产基础设施、双节点现场演练和 support access 外部安全集成尚无真实验收证据;这些证据不得由 AI、mock、模板或共享预发布结果代替。 + +## 本轮技术闭环 + +1. Contract API 新增 `CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS`,范围为 `1..600` 秒,默认 30 秒;review worker、手工 run-once 和 Legal QA 代理均显式传递到 SDK。 +2. 共享预发布 Provider 请求预算为 180 秒,Contract 调用预算为 240 秒,避免同期限竞态。 +3. production preflight 强制 Contract timeout 至少比 Provider timeout 多 5 秒,并拒绝缺失、非数字、非有限值、越界和倒置预算。 +4. Nginx 为同步 `/contract/api/v1/legal/qa` 设置独立 660 秒上限,覆盖 Contract 允许的 600 秒最大值;其他 Contract 路由仍保持 120 秒。 +5. Compose 的 migrate、Contract API 和 review worker 三个服务均映射 timeout;共享预发布部署脚本显式写入 Contract 240 秒和 Provider 180 秒,避免重部署恢复默认 30 秒。 +6. 生产 Web 使用 `/contract`、`/ai` 边缘前缀时,Contract 与 AI 请求均保留 base path。统一出口还会规范化 33 个旧 AI 代理路由生成的无前缀 URL;角色判断和 Contract 公共健康检查使用移除边缘前缀后的逻辑 API 路径,避免修复路由时引入权限回归。 +7. 身份执行器、support access API 和破坏性身份写接口继续关闭。共享预发布 OpenAPI 中包含 `execute`、`support-access` 或 `support_access` 的路由数为 0。 + +## 自动化验证 + +| 范围 | 结果 | +| --- | --- | +| Contract API 全量 | `286 passed, 3 skipped`;跳过项仅为需显式 PostgreSQL URL 的并发专项 | +| AI Platform API、AI worker、Python SDK | `162 passed` | +| Infra production release tools | `62 passed` | +| 本轮 timeout 定向回归 | `28 passed` | +| Contract Web 单元测试 | `17 passed`,其中既有入口/会话/工作空间 12 项,生产 API base path、幂等规范化和逻辑权限路径 5 项 | +| Contract Web typecheck | 通过 | +| Next.js production build | 通过,生成 101 个页面/路由 | +| npm audit | 0 vulnerabilities | +| pip-audit | 0 known vulnerabilities | +| Bandit | 0 findings,0 errors | +| tracked source secret scan | 45 个已复核假阳性,0 个新发现 | +| `git diff --check` | 通过 | + +另有 3 个真实 PostgreSQL 并发专项已在本轮之前使用 `192.168.3.90:5432/yuqei_contract` 的随机隔离 schema 完成 `3/3`,并已清理测试 schema。 + +## 共享预发布证据 + +- 宿主机:`192.168.3.90` +- AI Provider:`ccswitch / gpt-5.6-sol` +- Provider 调用:enabled +- Provider fallback:false +- Provider timeout:180 秒 +- Contract timeout:240 秒;API 与 review worker 容器均实际读取为 `240.0` +- Contract API 镜像:`sha256:5072971b7bdda62036791cc5836faece78b5a8615a98fd98ad5e9b4999e3f51a` +- Contract review worker 镜像:`sha256:35a76a9f6dd5dd44f6545d029cd0e621ea909251c7aabccd6e4cd860a37f11be` +- 真实审查:合同 `contract-096914efc203`、任务 `review-e8100aa210d4`,约 93 秒后状态为 `succeeded`,并持久化 AI audit run id +- post-deploy release gate:`pass`;Contract 与 AI schema-check 均为 `ok` +- 8 个共享预发布服务全部 healthy +- 根盘:92% 使用,约 17 GiB 可用;未自动清理镜像、卷或业务数据 + +远端变更前备份位于 `/opt/apps/ai-contract-platform-v2-smoke/.runtime/pre-contract-timeout-20260811-1650/`。本地 release gate 证据位于忽略目录 `.runtime/postdeploy-release-gate-timeout-fix-20260811/`,不包含服务密钥。 + +## 正式生产阻断项 + +1. 具名法务尚未对 10 张知识卡和 18 条真实 Provider 结果签署精确技术包,因此没有真实 `acceptance_run_id`、receipt v2 或 promotion bundle。 +2. 四份公开政策及合同生成、条款推荐黄金资产仍缺具名法务正式版本、生效时间和审批记录。 +3. support access 尚未接入真实 IdP/MFA、会话绑定、跨节点撤销、真实资源读取、告警消费和具名隐私/法务批准;当前必须保持 deny-only。 +4. 尚无正式 `/etc/yuqei/production.env`、production secrets、不可变镜像清单和非模板 production preflight 证据。 +5. 正式 TLS 证书、DNS、异地备份挂载、恢复演练和阿里云前端切流证据不存在。 +6. 尚未部署至少两个独立生产节点,无法执行真实 canary、撤销 SLA、kill switch 和完整回滚演练。 +7. 生产容量与回滚空间仍需运维确认;共享服务器根盘处于 92% 高水位。 + +## 固定上线顺序 + +1. 具名法务签署精确技术包和共享 evidence。 +2. 在共享预发布执行 `finalize`,生成并验证真实 `acceptance_run_id`、receipt v2 和 promotion bundle。 +3. 建立 production env/secrets、TLS、异地备份和不可变镜像,运行非模板 preflight、备份恢复及容量门禁。 +4. 部署至少两个生产节点,不切流。 +5. 执行只读 canary、跨节点撤销 SLA、kill switch 和回滚演练。 +6. 由法务、安全、隐私和运维具名批准后,另行授权 DNS/流量切换。 + +上述阻断项全部关闭前,禁止启动 production compose,也禁止把共享预发布重命名或宣称为生产。 diff --git a/yuqei-contract-platform/apps/contract-web/package.json b/yuqei-contract-platform/apps/contract-web/package.json index 4ba65a0c..d8f9f6f8 100644 --- a/yuqei-contract-platform/apps/contract-web/package.json +++ b/yuqei-contract-platform/apps/contract-web/package.json @@ -9,6 +9,7 @@ "test:auth-entry-routing": "node --no-warnings --experimental-strip-types --test src/lib/auth-entry-routing.test.mjs", "test:identity-session": "node --no-warnings --experimental-strip-types --test src/lib/identity-session-mapping.test.mjs", "test:workspace-access": "node --no-warnings --experimental-strip-types --test src/lib/workspace-access-state.test.mjs", + "test:api-base-url": "node --no-warnings --experimental-strip-types --test src/lib/api-base-url.test.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/ai-platform-api.ts b/yuqei-contract-platform/apps/contract-web/src/lib/ai-platform-api.ts index 8c5680a5..ce965312 100644 --- a/yuqei-contract-platform/apps/contract-web/src/lib/ai-platform-api.ts +++ b/yuqei-contract-platform/apps/contract-web/src/lib/ai-platform-api.ts @@ -1,4 +1,5 @@ import { getContractWebSession } from "@/lib/contract-session"; +import { apiPathnameWithoutBasePath, buildApiUrlWithBasePath } from "@/lib/api-base-url"; type AiPlatformAdminRole = "admin" | "legal"; @@ -8,7 +9,7 @@ const aiPlatformBaseUrl = "http://localhost:8101"; export function buildAiPlatformApiUrl(pathname: string) { - return new URL(pathname, aiPlatformBaseUrl); + return buildApiUrlWithBasePath(pathname, aiPlatformBaseUrl); } export async function fetchAiPlatform(targetUrl: URL, init?: RequestInit) { @@ -17,7 +18,8 @@ export async function fetchAiPlatform(targetUrl: URL, init?: RequestInit) { return Response.json({ message: "请先登录。" }, { status: 401 }); } - const allowedRoles = aiPlatformRolesForPath(targetUrl.pathname); + const apiPathname = apiPathnameWithoutBasePath(targetUrl.pathname, aiPlatformBaseUrl); + const allowedRoles = aiPlatformRolesForPath(apiPathname); if (!allowedRoles.includes(session.role.toLowerCase() as AiPlatformAdminRole)) { return Response.json({ message: "当前账号无权访问该 AI 管理接口。" }, { status: 403 }); } @@ -31,7 +33,11 @@ export async function fetchAiPlatform(targetUrl: URL, init?: RequestInit) { if (serviceSecret) { headers.set("X-AI-Platform-Service-Key", serviceSecret); } - return fetch(targetUrl, { cache: "no-store", ...init, headers }); + const normalizedTargetUrl = buildApiUrlWithBasePath( + `${apiPathname}${targetUrl.search}${targetUrl.hash}`, + aiPlatformBaseUrl + ); + return fetch(normalizedTargetUrl, { cache: "no-store", ...init, headers }); } export function aiPlatformRolesForPath(pathname: string): readonly AiPlatformAdminRole[] { 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 new file mode 100644 index 00000000..a597c662 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/api-base-url.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { apiPathnameWithoutBasePath, buildApiUrlWithBasePath } from "./api-base-url.ts"; + +test("preserves a Contract edge prefix and query parameters", () => { + const url = buildApiUrlWithBasePath( + "/api/v1/legal/qa?trace_id=smoke", + "https://api.example.invalid/contract", + ); + + assert.equal(url.toString(), "https://api.example.invalid/contract/api/v1/legal/qa?trace_id=smoke"); +}); + +test("preserves an AI edge prefix", () => { + const url = buildApiUrlWithBasePath( + "/api/v1/runtime/config-check", + "https://api.example.invalid/ai/", + ); + + assert.equal(url.toString(), "https://api.example.invalid/ai/api/v1/runtime/config-check"); +}); + +test("keeps direct service URLs unchanged", () => { + const url = buildApiUrlWithBasePath("/api/v1/health", "http://contract-api:8100"); + + assert.equal(url.toString(), "http://contract-api:8100/api/v1/health"); +}); + +test("normalization is idempotent for an already-prefixed URL", () => { + const url = buildApiUrlWithBasePath( + "/ai/api/v1/audit/ai-runs?status=succeeded", + "https://api.example.invalid/ai", + ); + + assert.equal( + url.toString(), + "https://api.example.invalid/ai/api/v1/audit/ai-runs?status=succeeded", + ); +}); + +test("returns the logical API path for authorization checks", () => { + assert.equal( + apiPathnameWithoutBasePath("/ai/api/v1/quality/golden-samples", "https://api.example.invalid/ai"), + "/api/v1/quality/golden-samples", + ); + assert.equal( + apiPathnameWithoutBasePath("/contract/api/v1/health", "https://api.example.invalid/contract"), + "/api/v1/health", + ); +}); diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/api-base-url.ts b/yuqei-contract-platform/apps/contract-web/src/lib/api-base-url.ts new file mode 100644 index 00000000..ed340fb5 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/api-base-url.ts @@ -0,0 +1,23 @@ +export function buildApiUrlWithBasePath(pathname: string, baseUrl: string): URL { + const base = new URL(baseUrl); + const requested = new URL(pathname, "http://yuqei-internal.invalid"); + const basePath = base.pathname.replace(/\/+$/, ""); + const target = new URL(base.origin); + const apiPathname = apiPathnameWithoutBasePath(requested.pathname, baseUrl); + + target.pathname = `${basePath}${apiPathname}` || "/"; + target.search = requested.search; + target.hash = requested.hash; + return target; +} + +export function apiPathnameWithoutBasePath(pathname: string, baseUrl: string): string { + const basePath = new URL(baseUrl).pathname.replace(/\/+$/, ""); + if (!basePath || pathname === basePath) { + return pathname === basePath ? "/" : pathname; + } + if (pathname.startsWith(`${basePath}/`)) { + return pathname.slice(basePath.length); + } + return pathname; +} diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/contract-api.ts b/yuqei-contract-platform/apps/contract-web/src/lib/contract-api.ts index 73f27ff3..59710bf6 100644 --- a/yuqei-contract-platform/apps/contract-web/src/lib/contract-api.ts +++ b/yuqei-contract-platform/apps/contract-web/src/lib/contract-api.ts @@ -1,4 +1,5 @@ import { getContractWebSession } from "@/lib/contract-session"; +import { apiPathnameWithoutBasePath, buildApiUrlWithBasePath } from "@/lib/api-base-url"; const contractApiBaseUrl = process.env.CONTRACT_API_BASE_URL ?? @@ -6,7 +7,7 @@ const contractApiBaseUrl = "http://localhost:8100"; export function buildContractApiUrl(pathname: string) { - return new URL(pathname, contractApiBaseUrl); + return buildApiUrlWithBasePath(pathname, contractApiBaseUrl); } export async function fetchContractApi(targetUrl: URL, init?: RequestInit) { @@ -26,7 +27,12 @@ export async function fetchContractApi(targetUrl: URL, init?: RequestInit) { headers.set("X-Contract-Proxy-Key", proxySecret); } - return fetch(targetUrl, { cache: "no-store", ...init, headers }); + const apiPathname = apiPathnameWithoutBasePath(targetUrl.pathname, contractApiBaseUrl); + const normalizedTargetUrl = buildApiUrlWithBasePath( + `${apiPathname}${targetUrl.search}${targetUrl.hash}`, + contractApiBaseUrl + ); + return fetch(normalizedTargetUrl, { cache: "no-store", ...init, headers }); } export function buildIdentityProxyHeaders(request: Request, initial?: HeadersInit) { @@ -47,7 +53,8 @@ export function buildIdentityProxyHeaders(request: Request, initial?: HeadersIni export async function proxyContractApiResponse(targetUrl: URL, init?: RequestInit) { try { const session = await getContractWebSession(); - if (!session && !isPublicContractApiPath(targetUrl.pathname)) { + const apiPathname = apiPathnameWithoutBasePath(targetUrl.pathname, contractApiBaseUrl); + if (!session && !isPublicContractApiPath(apiPathname)) { return Response.json({ message: "请先登录。" }, { status: 401 }); } const response = await fetchContractApi(targetUrl, init); diff --git a/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts b/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts index 3232bc4a..c8d8b8cc 100644 --- a/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts +++ b/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts @@ -1,4 +1,4 @@ -import { fetchContractApi } from "@/lib/contract-api"; +import { buildContractApiUrl, fetchContractApi } from "@/lib/contract-api"; import { buildAiPlatformApiUrl, fetchAiPlatform } from "@/lib/ai-platform-api"; import { getContractWebSession } from "@/lib/contract-session"; import type { UserRole } from "@/lib/access-control"; @@ -1263,11 +1263,6 @@ export type UnifiedSearchQuery = { page_size?: number; }; -const contractApiBaseUrl = - process.env.CONTRACT_API_BASE_URL ?? - process.env.NEXT_PUBLIC_CONTRACT_API_BASE_URL ?? - "http://localhost:8100"; - const serverFetchCache = new Map(); export async function getContracts(query: ContractQuery = {}): Promise { @@ -2026,7 +2021,7 @@ async function fetchAiJson( function resolveUrl(apiPathname: string, proxyPathname: string) { if (typeof window === "undefined") { - return new URL(apiPathname, contractApiBaseUrl).toString(); + return buildContractApiUrl(apiPathname).toString(); } return proxyPathname; } diff --git a/yuqei-contract-platform/services/contract-api/.env.example b/yuqei-contract-platform/services/contract-api/.env.example index f6740c7f..9cc97ffd 100644 --- a/yuqei-contract-platform/services/contract-api/.env.example +++ b/yuqei-contract-platform/services/contract-api/.env.example @@ -6,6 +6,7 @@ CONTRACT_API_SEED_DEMO_DATA=true CONTRACT_API_FILE_STORAGE_DIR=.runtime/contract-files CONTRACT_API_REDIS_URL=redis://localhost:6379/0 CONTRACT_API_AI_PLATFORM_BASE_URL=http://localhost:8101 +CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=30 CONTRACT_API_UVICORN_WORKERS=1 CONTRACT_API_READ_CACHE_TTL_SECONDS=2 CONTRACT_API_REVIEW_WORKER_NAME=contract-review-worker-1 diff --git a/yuqei-contract-platform/services/contract-api/README.md b/yuqei-contract-platform/services/contract-api/README.md index 65e34b01..20a207e7 100644 --- a/yuqei-contract-platform/services/contract-api/README.md +++ b/yuqei-contract-platform/services/contract-api/README.md @@ -65,6 +65,7 @@ CONTRACT_API_DATABASE_AUTO_CREATE=false CONTRACT_API_SEED_DEMO_DATA=false CONTRACT_API_FILE_STORAGE_DIR=/data/contract-files CONTRACT_API_AI_PLATFORM_BASE_URL=http://ai-platform-api:8101 +CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=60 CONTRACT_API_UVICORN_WORKERS=1 CONTRACT_API_READ_CACHE_TTL_SECONDS=2 CONTRACT_API_REVIEW_WORKER_NAME=contract-review-worker-1 @@ -76,6 +77,8 @@ CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED=true CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET=LOAD_A_STABLE_RANDOM_SECRET_OF_AT_LEAST_32_CHARACTERS ``` +`CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS` covers the complete Contract-to-AI request and must exceed `AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS` by at least five seconds. For example, use `240` when staging a Provider timeout of `180` seconds. + The disabled mode keeps login and administrator-managed one-time invitation links available while public company registration, password reset, and email verification remain off. When enterprise email becomes available, switch to `smtp`, set manual invitations to `false`, and configure the SMTP variables from `.env.example`. The production web proxy must overwrite public `X-Real-IP` / `X-Forwarded-For` headers before Contract Web forwards the validated client IP to Contract API for rate limiting. After migrations, initialize the first production administrator from a protected password file. The command is idempotent and will not replace an existing credential unless `--force-rotate` is supplied: diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/contracts_core.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/contracts_core.py index c33183f9..b294c22a 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/contracts_core.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/contracts_core.py @@ -302,8 +302,11 @@ def register_contract_core_routes( ) def proxy_legal_qa(request: LegalQaRequest) -> LegalQaResponse: authorization.require("start_review") - client_options = {"service_key": settings.ai_platform_service_key} if settings.ai_platform_service_key else {} - with AiPlatformClient(settings.ai_platform_base_url, **client_options) as client: + with AiPlatformClient( + settings.ai_platform_base_url, + timeout=settings.ai_platform_timeout_seconds, + service_key=settings.ai_platform_service_key or None, + ) as client: return client.run_legal_qa( request, context=RequestContext(tenant_id=get_request_tenant_id() or "default", user_id="contract-api"), diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/review.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/review.py index ed58d5e2..0a539a84 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/review.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/review.py @@ -218,6 +218,7 @@ def register_review_routes( repository=repository, ai_platform_base_url=settings.ai_platform_base_url, ai_platform_service_key=settings.ai_platform_service_key, + ai_platform_timeout_seconds=settings.ai_platform_timeout_seconds, options=ReviewWorkerOptions( worker_name=payload.worker_name or settings.review_worker_name, limit=payload.limit, diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py index 819b2ad7..f326113b 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py @@ -22,6 +22,7 @@ class ContractApiSettings(BaseSettings): redis_url: str = "redis://localhost:6379/0" ai_platform_base_url: str = "http://localhost:8101" ai_platform_service_key: str = "" + ai_platform_timeout_seconds: float = Field(default=30.0, ge=1.0, le=600.0) web_proxy_shared_secret: str = "" require_web_proxy_auth: bool = False review_worker_name: str = "contract-review-worker-1" @@ -163,7 +164,11 @@ def _check_ai_platform_config(settings: ContractApiSettings) -> RuntimeConfigChe name="ai_platform_base_url", status="ok", message="AI platform base URL is configured.", - details={"scheme": parsed.scheme, "host": parsed.netloc}, + details={ + "scheme": parsed.scheme, + "host": parsed.netloc, + "timeout_seconds": settings.ai_platform_timeout_seconds, + }, ) diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/review_worker.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/review_worker.py index cbc7fbbc..7845726e 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/review_worker.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/review_worker.py @@ -27,6 +27,7 @@ def run_review_worker_once( repository: ContractRepository, ai_platform_base_url: str, ai_platform_service_key: str | None = None, + ai_platform_timeout_seconds: float = 30.0, options: ReviewWorkerOptions, ) -> ReviewWorkerRun: max_tasks = max(options.limit, 1) @@ -46,6 +47,7 @@ def run_review_worker_once( task=task, ai_platform_base_url=ai_platform_base_url, ai_platform_service_key=ai_platform_service_key, + ai_platform_timeout_seconds=ai_platform_timeout_seconds, options=options, ) if result.status == "succeeded": @@ -67,6 +69,7 @@ def execute_review_task( task: ReviewTask, ai_platform_base_url: str, ai_platform_service_key: str | None = None, + ai_platform_timeout_seconds: float = 30.0, options: ReviewWorkerOptions, ) -> ReviewTask: contract = repository.get_contract(task.contract_id) @@ -88,8 +91,11 @@ def execute_review_task( return failed or task try: - client_options = {"service_key": ai_platform_service_key} if ai_platform_service_key else {} - with AiPlatformClient(ai_platform_base_url, **client_options) as client: + with AiPlatformClient( + ai_platform_base_url, + timeout=ai_platform_timeout_seconds, + service_key=ai_platform_service_key or None, + ) as client: review = client.run_contract_review( ContractReviewRequest( contract_title=contract.title, @@ -220,6 +226,7 @@ def main() -> None: repository=repository, ai_platform_base_url=settings.ai_platform_base_url, ai_platform_service_key=settings.ai_platform_service_key, + ai_platform_timeout_seconds=settings.ai_platform_timeout_seconds, options=ReviewWorkerOptions( worker_name=settings.review_worker_name, limit=settings.review_worker_batch_limit, @@ -235,6 +242,7 @@ def run_once(settings: ContractApiSettings | None = None, *, limit: int | None = repository=repository, ai_platform_base_url=resolved_settings.ai_platform_base_url, ai_platform_service_key=resolved_settings.ai_platform_service_key, + ai_platform_timeout_seconds=resolved_settings.ai_platform_timeout_seconds, options=ReviewWorkerOptions( worker_name=resolved_settings.review_worker_name, limit=limit or resolved_settings.review_worker_batch_limit, diff --git a/yuqei-contract-platform/services/contract-api/tests/test_contract_workflow.py b/yuqei-contract-platform/services/contract-api/tests/test_contract_workflow.py index 72319c9a..a119f721 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_contract_workflow.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_contract_workflow.py @@ -2275,8 +2275,16 @@ def test_postgresql_unified_search_keeps_full_text_as_bounded_fallback(monkeypat def test_review_worker_calls_ai_platform_and_persists_result(tmp_path: Path, monkeypatch) -> None: class FakeAiPlatformClient: - def __init__(self, base_url: str) -> None: + def __init__( + self, + base_url: str, + *, + timeout: float, + service_key: str | None = None, + ) -> None: self.base_url = base_url + assert timeout == 180.0 + assert service_key is None def __enter__(self): return self @@ -2317,7 +2325,8 @@ def test_review_worker_calls_ai_platform_and_persists_result(tmp_path: Path, mon ) monkeypatch.setattr(contract_review_worker, "AiPlatformClient", FakeAiPlatformClient) - app = create_app(make_settings(tmp_path)) + settings = make_settings(tmp_path).model_copy(update={"ai_platform_timeout_seconds": 180.0}) + app = create_app(settings) client = TestClient(app) create_response = client.post( @@ -2387,7 +2396,12 @@ def test_review_worker_route_forwards_ai_service_key(tmp_path: Path, monkeypatch } monkeypatch.setattr("yuqei_contract_api.api.review.run_review_worker_once", fake_run_review_worker_once) - settings = make_settings(tmp_path).model_copy(update={"ai_platform_service_key": "test-service-key"}) + settings = make_settings(tmp_path).model_copy( + update={ + "ai_platform_service_key": "test-service-key", + "ai_platform_timeout_seconds": 180.0, + } + ) app = create_app(settings) client = TestClient(app) @@ -2395,6 +2409,7 @@ def test_review_worker_route_forwards_ai_service_key(tmp_path: Path, monkeypatch assert response.status_code == 200 assert captured["ai_platform_service_key"] == "test-service-key" + assert captured["ai_platform_timeout_seconds"] == 180.0 def test_review_clause_repair_can_append_recommended_clause_to_contract_body(tmp_path: Path) -> None: @@ -3454,7 +3469,7 @@ def test_legal_change_impact_scan_uses_published_clause_snapshot(tmp_path: Path) def test_review_worker_uses_uploaded_source_file_text_and_persists_ai_sources(tmp_path: Path, monkeypatch) -> None: class FakeAiPlatformClient: - def __init__(self, base_url: str) -> None: + def __init__(self, base_url: str, **_kwargs) -> None: self.base_url = base_url def __enter__(self): @@ -3532,7 +3547,7 @@ def test_review_worker_does_not_fallback_to_contract_body_when_source_file_is_un calls: list[str] = [] class FakeAiPlatformClient: - def __init__(self, base_url: str) -> None: + def __init__(self, base_url: str, **_kwargs) -> None: self.base_url = base_url def __enter__(self): @@ -3584,7 +3599,7 @@ def test_review_worker_does_not_fallback_to_contract_body_when_source_file_is_un def test_failed_review_task_can_be_requeued(tmp_path: Path, monkeypatch) -> None: class FailingAiPlatformClient: - def __init__(self, base_url: str) -> None: + def __init__(self, base_url: str, **_kwargs) -> None: self.base_url = base_url def __enter__(self): diff --git a/yuqei-contract-platform/services/contract-api/tests/test_health.py b/yuqei-contract-platform/services/contract-api/tests/test_health.py index 1e3d3308..19d4361c 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_health.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_health.py @@ -1,4 +1,6 @@ from fastapi.testclient import TestClient +import pytest +from pydantic import ValidationError from sqlalchemy import text from yuqei_contract_api.config import ContractApiSettings @@ -90,6 +92,12 @@ def test_runtime_config_check_flags_production_localhost_ai_platform() -> None: ) +@pytest.mark.parametrize("timeout", [0, 0.5, 600.1, 601]) +def test_ai_platform_timeout_must_stay_within_supported_range(timeout: float) -> None: + with pytest.raises(ValidationError): + ContractApiSettings(ai_platform_timeout_seconds=timeout) + + def test_runtime_config_check_flags_empty_review_worker_name() -> None: app = create_app( ContractApiSettings( diff --git a/yuqei-contract-platform/services/contract-api/tests/test_legal_qa_proxy.py b/yuqei-contract-platform/services/contract-api/tests/test_legal_qa_proxy.py index 0c4845fe..8fdc2626 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_legal_qa_proxy.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_legal_qa_proxy.py @@ -17,6 +17,9 @@ def make_test_settings() -> ContractApiSettings: def test_contract_api_exposes_legal_qa_proxy(monkeypatch) -> None: from yuqei_sdk import ai_platform + captured_options: dict[str, object] = {} + fake_service_key = "legal-qa-service-key" # pragma: allowlist secret + def handler(request: httpx.Request) -> httpx.Response: assert request.url.path == "/api/v1/legal/qa" assert request.headers["x-tenant-id"] == "tenant-qa" @@ -45,6 +48,7 @@ def test_contract_api_exposes_legal_qa_proxy(monkeypatch) -> None: original_init = ai_platform.AiPlatformClient.__init__ def init_with_mock_transport(self, base_url: str, **kwargs) -> None: + captured_options.update(kwargs) original_init( self, base_url, @@ -54,7 +58,13 @@ def test_contract_api_exposes_legal_qa_proxy(monkeypatch) -> None: monkeypatch.setattr(ai_platform.AiPlatformClient, "__init__", init_with_mock_transport) - app = create_app(make_test_settings()) + settings = make_test_settings().model_copy( + update={ + "ai_platform_service_key": fake_service_key, + "ai_platform_timeout_seconds": 45.0, + } + ) + app = create_app(settings) client = TestClient(app) response = client.post( "/api/v1/legal/qa", @@ -65,3 +75,5 @@ def test_contract_api_exposes_legal_qa_proxy(monkeypatch) -> None: assert response.status_code == 200 payload = response.json() assert payload["citations"][0]["reference"] == "\u7b2c\u4e94\u767e\u96f6\u4e5d\u6761" + assert captured_options["timeout"] == 45.0 + assert captured_options["service_key"] == fake_service_key diff --git a/yuqei-infra/README.md b/yuqei-infra/README.md index be77e435..a6b3aeea 100644 --- a/yuqei-infra/README.md +++ b/yuqei-infra/README.md @@ -251,6 +251,7 @@ CONTRACT_API_DATABASE_URL=postgresql+psycopg://.../yuqei_contract CONTRACT_API_DATABASE_AUTO_CREATE=false CONTRACT_API_SEED_DEMO_DATA=false CONTRACT_API_AI_PLATFORM_BASE_URL=http://ai-platform-api:8101 +CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=60 CONTRACT_API_UVICORN_WORKERS=1 CONTRACT_API_READ_CACHE_TTL_SECONDS=2 CONTRACT_API_REVIEW_WORKER_NAME=contract-review-worker-1 @@ -264,6 +265,8 @@ YUQEI_WORKER_DATABASE_URL=postgresql+psycopg://.../yuqei_ai YUQEI_WORKER_DATABASE_AUTO_CREATE=false ``` +Keep `CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS` at least five seconds above `AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS`; production preflight rejects missing, non-finite, out-of-range, or inverted timeout budgets. + Start from `yuqei-infra\compose\.env.production.example` and replace every `CHANGE_ME` value before deployment. Run migrations explicitly before replacing application containers. diff --git a/yuqei-infra/compose/.env.example b/yuqei-infra/compose/.env.example index 0e018d9f..503360bf 100644 --- a/yuqei-infra/compose/.env.example +++ b/yuqei-infra/compose/.env.example @@ -27,6 +27,7 @@ CONTRACT_API_SEED_DEMO_DATA=true CONTRACT_API_FILE_STORAGE_DIR=/data/contract-files CONTRACT_API_REDIS_URL=redis://redis:6379/0 CONTRACT_API_AI_PLATFORM_BASE_URL=http://ai-platform-api:8101 +CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=30 CONTRACT_API_WEB_PROXY_SHARED_SECRET=local-only-proxy-secret-change-me-32 CONTRACT_API_REQUIRE_WEB_PROXY_AUTH=false CONTRACT_API_UVICORN_WORKERS=1 diff --git a/yuqei-infra/compose/.env.production.example b/yuqei-infra/compose/.env.production.example index 1ea16634..dc9c0bdc 100644 --- a/yuqei-infra/compose/.env.production.example +++ b/yuqei-infra/compose/.env.production.example @@ -67,6 +67,7 @@ CONTRACT_API_SEED_DEMO_DATA=false CONTRACT_API_FILE_STORAGE_DIR=/data/contract-files CONTRACT_API_REDIS_URL=redis://redis:6379/0 CONTRACT_API_AI_PLATFORM_BASE_URL=http://ai-platform-api:8101 +CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=60 CONTRACT_API_WEB_PROXY_SHARED_SECRET=CHANGE_ME_USE_A_RANDOM_32_BYTE_SECRET CONTRACT_API_REQUIRE_WEB_PROXY_AUTH=true CONTRACT_API_UVICORN_WORKERS=1 diff --git a/yuqei-infra/compose/docker-compose.yml b/yuqei-infra/compose/docker-compose.yml index bd01b7e8..3e778dec 100644 --- a/yuqei-infra/compose/docker-compose.yml +++ b/yuqei-infra/compose/docker-compose.yml @@ -86,6 +86,7 @@ services: CONTRACT_API_FILE_STORAGE_DIR: ${CONTRACT_API_FILE_STORAGE_DIR:-/data/contract-files} CONTRACT_API_REDIS_URL: ${CONTRACT_API_REDIS_URL:-redis://redis:6379/0} CONTRACT_API_AI_PLATFORM_BASE_URL: ${CONTRACT_API_AI_PLATFORM_BASE_URL:-http://ai-platform-api:8101} + CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS: ${CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS:-30} CONTRACT_API_DB_POOL_SIZE: ${CONTRACT_API_DB_POOL_SIZE:-10} CONTRACT_API_DB_MAX_OVERFLOW: ${CONTRACT_API_DB_MAX_OVERFLOW:-20} CONTRACT_API_DB_POOL_TIMEOUT_SECONDS: ${CONTRACT_API_DB_POOL_TIMEOUT_SECONDS:-30} @@ -113,6 +114,7 @@ services: CONTRACT_API_REDIS_URL: ${CONTRACT_API_REDIS_URL:-redis://redis:6379/0} CONTRACT_API_AI_PLATFORM_BASE_URL: ${CONTRACT_API_AI_PLATFORM_BASE_URL:-http://ai-platform-api:8101} CONTRACT_API_AI_PLATFORM_SERVICE_KEY: ${AI_PLATFORM_SERVICE_SHARED_SECRET:-local-only-ai-service-secret-change-me-32} + CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS: ${CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS:-30} CONTRACT_API_WEB_PROXY_SHARED_SECRET: ${CONTRACT_API_WEB_PROXY_SHARED_SECRET:-local-only-proxy-secret-change-me-32} CONTRACT_API_REQUIRE_WEB_PROXY_AUTH: ${CONTRACT_API_REQUIRE_WEB_PROXY_AUTH:-false} CONTRACT_API_UVICORN_WORKERS: ${CONTRACT_API_UVICORN_WORKERS:-1} @@ -121,6 +123,12 @@ services: CONTRACT_API_DB_POOL_TIMEOUT_SECONDS: ${CONTRACT_API_DB_POOL_TIMEOUT_SECONDS:-30} CONTRACT_API_DB_POOL_RECYCLE_SECONDS: ${CONTRACT_API_DB_POOL_RECYCLE_SECONDS:-1800} CONTRACT_API_READ_CACHE_TTL_SECONDS: ${CONTRACT_API_READ_CACHE_TTL_SECONDS:-2} + CONTRACT_API_ANTI_ABUSE_MODE: ${CONTRACT_API_ANTI_ABUSE_MODE:-disabled} + CONTRACT_API_ANTI_ABUSE_HASH_SECRET: ${CONTRACT_API_ANTI_ABUSE_HASH_SECRET:-} + CONTRACT_API_ANTI_ABUSE_S3_RECORDS_DAILY_LIMIT: ${CONTRACT_API_ANTI_ABUSE_S3_RECORDS_DAILY_LIMIT:-1000} + CONTRACT_API_ANTI_ABUSE_S4_RECORDS_DAILY_LIMIT: ${CONTRACT_API_ANTI_ABUSE_S4_RECORDS_DAILY_LIMIT:-1000} + CONTRACT_API_ANTI_ABUSE_FILES_DAILY_LIMIT: ${CONTRACT_API_ANTI_ABUSE_FILES_DAILY_LIMIT:-50} + CONTRACT_API_ANTI_ABUSE_BYTES_DAILY_LIMIT: ${CONTRACT_API_ANTI_ABUSE_BYTES_DAILY_LIMIT:-524288000} CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE: ${CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE:-development} CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED: ${CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED:-true} CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED: ${CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED:-false} @@ -184,6 +192,7 @@ services: CONTRACT_API_REDIS_URL: ${CONTRACT_API_REDIS_URL:-redis://redis:6379/0} CONTRACT_API_AI_PLATFORM_BASE_URL: ${CONTRACT_API_AI_PLATFORM_BASE_URL:-http://ai-platform-api:8101} CONTRACT_API_AI_PLATFORM_SERVICE_KEY: ${AI_PLATFORM_SERVICE_SHARED_SECRET:-local-only-ai-service-secret-change-me-32} + CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS: ${CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS:-30} CONTRACT_API_REVIEW_WORKER_NAME: ${CONTRACT_API_REVIEW_WORKER_NAME:-contract-review-worker-1} CONTRACT_API_REVIEW_WORKER_POLL_INTERVAL_SECONDS: ${CONTRACT_API_REVIEW_WORKER_POLL_INTERVAL_SECONDS:-5} CONTRACT_API_REVIEW_WORKER_BATCH_LIMIT: ${CONTRACT_API_REVIEW_WORKER_BATCH_LIMIT:-5} diff --git a/yuqei-infra/deploy/preflight-production.py b/yuqei-infra/deploy/preflight-production.py index a576d5be..4820f450 100644 --- a/yuqei-infra/deploy/preflight-production.py +++ b/yuqei-infra/deploy/preflight-production.py @@ -6,6 +6,7 @@ from __future__ import annotations import argparse import hashlib import json +import math import os import re import stat @@ -78,7 +79,9 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False) "CONTRACT_POSTGRES_DB", "AI_PLATFORM_POSTGRES_DB", "CONTRACT_API_DATABASE_URL", + "CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS", "AI_PLATFORM_DATABASE_URL", + "AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS", "YUQEI_WORKER_DATABASE_URL", "MINIO_ROOT_USER", "MINIO_ROOT_PASSWORD", @@ -116,6 +119,7 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False) ] checks.append(_result("production_modes", not invalid_exact, "Production modes are strict." if not invalid_exact else f"Invalid production values: {', '.join(invalid_exact)}.")) checks.append(_validate_identity_email_delivery(values)) + checks.append(_validate_ai_timeout_budget(values)) placeholders = [ key @@ -421,6 +425,27 @@ def _parse_int(value: str) -> int | None: return None +def _validate_ai_timeout_budget(values: dict[str, str]) -> Check: + contract_timeout = _parse_float(values.get("CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS", "")) + provider_timeout = _parse_float(values.get("AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS", "")) + valid = ( + contract_timeout is not None + and provider_timeout is not None + and math.isfinite(contract_timeout) + and math.isfinite(provider_timeout) + and 1 <= contract_timeout <= 600 + and provider_timeout > 0 + and contract_timeout >= provider_timeout + 5 + ) + return _result( + "ai_timeout_budget", + valid, + "Contract-to-AI timeout leaves at least five seconds beyond the Provider request budget." + if valid + else "Timeouts must be finite and positive, Contract must be within 1..600 seconds, and Contract must exceed Provider by at least five seconds.", + ) + + def _parse_float(value: str) -> float | None: try: return float(value) diff --git a/yuqei-infra/nginx/templates/default.conf.template b/yuqei-infra/nginx/templates/default.conf.template index e18bd67f..05dbefea 100644 --- a/yuqei-infra/nginx/templates/default.conf.template +++ b/yuqei-infra/nginx/templates/default.conf.template @@ -44,6 +44,20 @@ server { return 200 '{"status":"ok","service":"yuqei-v2-edge"}'; } + location = /contract/api/v1/legal/qa { + limit_req zone=yuqei_api_per_ip burst=20 nodelay; + proxy_pass http://contract_api_upstream/api/v1/legal/qa; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Connection ""; + proxy_connect_timeout 5s; + proxy_read_timeout 660s; + proxy_send_timeout 660s; + } + location /contract/ { limit_req zone=yuqei_api_per_ip burst=40 nodelay; proxy_pass http://contract_api_upstream/; diff --git a/yuqei-infra/scripts/deploy-v2-smoke-docker.ps1 b/yuqei-infra/scripts/deploy-v2-smoke-docker.ps1 index b3a0e4a2..1208aa14 100644 --- a/yuqei-infra/scripts/deploy-v2-smoke-docker.ps1 +++ b/yuqei-infra/scripts/deploy-v2-smoke-docker.ps1 @@ -357,6 +357,8 @@ CONTRACT_API_SEED_DEMO_DATA=true CONTRACT_API_FILE_STORAGE_DIR=/data/contract-files CONTRACT_API_REDIS_URL=redis://redis:6379/0 CONTRACT_API_AI_PLATFORM_BASE_URL=http://ai-platform-api:8101 +CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=240 +AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS=180 AI_PLATFORM_SERVICE_SHARED_SECRET=$aiPlatformServiceSecret AI_PLATFORM_REQUIRE_SERVICE_AUTH=true CONTRACT_API_WEB_PROXY_SHARED_SECRET=$webProxySharedSecret @@ -466,15 +468,15 @@ docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $ set -euo pipefail cd $(ConvertTo-ShellSingleQuote $RemoteProjectPath) docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T postgres psql -U yuqei -d yuqei_contract -v ON_ERROR_STOP=1 -c "UPDATE review_tasks SET status = 'failed', status_label = 'smoke cleanup', error_message = 'Skipped before deterministic smoke verification.', completed_at = now(), updated_at = now() WHERE status IN ('queued', 'running');" < /dev/null -CREATE_RESPONSE=`$(curl -fsS -X POST http://127.0.0.1:$ContractApiHostPort/api/v1/contracts -H 'Content-Type: application/json' -H 'X-Contract-Proxy-Key: $webProxySharedSecret' -d '{"title":"Smoke Review Contract","counterparty":"Smoke Counterparty","source_text":"Smoke contract body. The parties agree on payment, delivery, acceptance, confidentiality, breach liability, dispute resolution, and evidence archive obligations.","owner":"smoke"}') +CREATE_RESPONSE=`$(curl -fsS -X POST http://127.0.0.1:$ContractApiHostPort/api/v1/contracts -H 'Content-Type: application/json' -H 'X-Contract-Proxy-Key: $webProxySharedSecret' -H 'X-Tenant-Id: default' -H 'X-User-Id: user-current-admin' -d '{"title":"Smoke Review Contract","counterparty":"Smoke Counterparty","source_text":"Smoke contract body. The parties agree on payment, delivery, acceptance, confidentiality, breach liability, dispute resolution, and evidence archive obligations.","owner":"smoke"}') SMOKE_CONTRACT_ID=`$(printf '%s' "`$CREATE_RESPONSE" | docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T contract-api python -c 'import json, sys; print(json.load(sys.stdin)["id"])') -TASK_RESPONSE=`$(curl -fsS -X POST http://127.0.0.1:$ContractApiHostPort/api/v1/contracts/`$SMOKE_CONTRACT_ID/review -H 'Content-Type: application/json' -H 'X-Contract-Proxy-Key: $webProxySharedSecret' -d '{"actor_name":"smoke","comment":"deterministic review worker smoke"}') +TASK_RESPONSE=`$(curl -fsS -X POST http://127.0.0.1:$ContractApiHostPort/api/v1/contracts/`$SMOKE_CONTRACT_ID/review -H 'Content-Type: application/json' -H 'X-Contract-Proxy-Key: $webProxySharedSecret' -H 'X-Tenant-Id: default' -H 'X-User-Id: user-current-admin' -d '{"actor_name":"smoke","comment":"deterministic review worker smoke"}') SMOKE_TASK_ID=`$(printf '%s' "`$TASK_RESPONSE" | docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T contract-api python -c 'import json, sys; print(json.load(sys.stdin)["id"])') SMOKE_OUTPUT=`$(docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T contract-review-worker python -m yuqei_contract_api.review_worker --run-once --limit 1 < /dev/null) echo "`$SMOKE_OUTPUT" printf '%s' "`$SMOKE_OUTPUT" | docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T contract-api python -c 'import json, sys; payload = json.load(sys.stdin); raise SystemExit(1 if payload.get("failed", 0) > 0 else 0)' for i in `$(seq 1 30); do - TASKS_RESPONSE=`$(curl -fsS http://127.0.0.1:$ContractApiHostPort/api/v1/contracts/`$SMOKE_CONTRACT_ID/review-tasks -H 'X-Contract-Proxy-Key: $webProxySharedSecret') + TASKS_RESPONSE=`$(curl -fsS http://127.0.0.1:$ContractApiHostPort/api/v1/contracts/`$SMOKE_CONTRACT_ID/review-tasks -H 'X-Contract-Proxy-Key: $webProxySharedSecret' -H 'X-Tenant-Id: default' -H 'X-User-Id: user-current-admin') TASK_STATUS=`$(printf '%s' "`$TASKS_RESPONSE" | docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $(ConvertTo-ShellSingleQuote $remoteEnvPath) -f $(ConvertTo-ShellSingleQuote $composeFile) exec -T -e SMOKE_TASK_ID="`$SMOKE_TASK_ID" contract-api python -c 'import json, os, sys; task_id = os.environ["SMOKE_TASK_ID"]; rows = json.load(sys.stdin); print(next((item.get("status", "missing") for item in rows if item.get("id") == task_id), "missing"))') echo "smoke_review_task_status=`$TASK_STATUS" if [ "`$TASK_STATUS" = "succeeded" ]; then diff --git a/yuqei-infra/tests/test_production_release_tools.py b/yuqei-infra/tests/test_production_release_tools.py index 417400de..9f2c737e 100644 --- a/yuqei-infra/tests/test_production_release_tools.py +++ b/yuqei-infra/tests/test_production_release_tools.py @@ -92,6 +92,23 @@ def test_production_preflight_accepts_strict_configuration() -> None: assert [check.key for check in checks if check.status == "fail"] == [] +@pytest.mark.parametrize( + ("contract_timeout", "provider_timeout"), + [("30", "30"), ("0", "30"), ("601", "30"), ("NaN", "30"), ("60", "inf")], +) +def test_production_preflight_rejects_invalid_ai_timeout_budgets( + contract_timeout: str, + provider_timeout: str, +) -> None: + values = production_values() + values["CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS"] = contract_timeout + values["AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS"] = provider_timeout + + checks = {check.key: check for check in preflight.validate_environment(values)} + + assert checks["ai_timeout_budget"].status == "fail" + + def test_production_preflight_rejects_promotion_image_digest_drift() -> None: values = production_values() values["AI_PLATFORM_RELEASE_IMAGE_DIGEST"] = f"sha256:{'f' * 64}" @@ -197,6 +214,55 @@ def test_production_compose_mounts_only_promotion_verification_material_read_onl assert "STAGING_DATABASE_URL" not in override +def test_shared_compose_wires_contract_anti_abuse_controls() -> None: + compose = (REPO_ROOT / "yuqei-infra/compose/docker-compose.yml").read_text( + encoding="utf-8" + ) + + for variable in ( + "CONTRACT_API_ANTI_ABUSE_MODE", + "CONTRACT_API_ANTI_ABUSE_HASH_SECRET", + "CONTRACT_API_ANTI_ABUSE_S3_RECORDS_DAILY_LIMIT", + "CONTRACT_API_ANTI_ABUSE_S4_RECORDS_DAILY_LIMIT", + "CONTRACT_API_ANTI_ABUSE_FILES_DAILY_LIMIT", + "CONTRACT_API_ANTI_ABUSE_BYTES_DAILY_LIMIT", + ): + assert f"{variable}: ${{{variable}:-" in compose + + +def test_shared_compose_and_smoke_deploy_wire_contract_ai_timeout() -> None: + compose = (REPO_ROOT / "yuqei-infra/compose/docker-compose.yml").read_text( + encoding="utf-8" + ) + deploy_script = ( + REPO_ROOT / "yuqei-infra/scripts/deploy-v2-smoke-docker.ps1" + ).read_text(encoding="utf-8") + + assert compose.count("CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS: ${") == 3 + assert "CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS=240" in deploy_script + assert "AI_PLATFORM_PROVIDER_REQUEST_TIMEOUT_SECONDS=180" in deploy_script + + +def test_legal_qa_edge_timeout_covers_contract_client_maximum() -> None: + nginx = ( + REPO_ROOT / "yuqei-infra/nginx/templates/default.conf.template" + ).read_text(encoding="utf-8") + + location = nginx.split("location = /contract/api/v1/legal/qa", 1)[1].split("}", 1)[0] + assert "proxy_read_timeout 660s;" in location + assert "proxy_send_timeout 660s;" in location + assert "proxy_pass http://contract_api_upstream/api/v1/legal/qa;" in location + + +def test_smoke_review_requests_include_server_authorized_identity_context() -> None: + deploy_script = ( + REPO_ROOT / "yuqei-infra/scripts/deploy-v2-smoke-docker.ps1" + ).read_text(encoding="utf-8") + + assert deploy_script.count("-H 'X-Tenant-Id: default'") >= 3 + assert deploy_script.count("-H 'X-User-Id: user-current-admin'") >= 3 + + def test_disabled_identity_email_does_not_require_smtp_secret_file() -> None: values = production_values() values.pop("CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE_HOST", None)