Files
ai-contract-platform/yuqei-infra/scripts/run-release-quality-gate.py
T
yuqei c24f7a4f1e
Build and Test Quality Gate / Contract Web typecheck and build (pull_request) Successful in 2m58s
Build and Test Quality Gate / Python tests and Contract API migration (pull_request) Failing after 15m25s
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (pull_request) Successful in 5m14s
fix: align release benchmark with abuse limits
2026-08-11 17:36:33 +08:00

645 lines
23 KiB
Python

#!/usr/bin/env python3
"""Verify the V2 release quality gate from runtime checks and QA summaries."""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_ARTIFACT_ROOT = REPO_ROOT / "project-docs" / "qa-artifacts" / "v2-release-quality-gate"
SMOKE_ARTIFACT_ROOT = REPO_ROOT / "project-docs" / "qa-artifacts" / "v2-smoke-docker-deploy"
BENCHMARK_ARTIFACT_ROOT = REPO_ROOT / "project-docs" / "qa-artifacts" / "v2-interface-benchmark"
BENCHMARK_SCRIPT = REPO_ROOT / "yuqei-infra" / "scripts" / "run-interface-benchmark.py"
REQUIRED_SMOKE_CHECKS = (
"remote_health",
"ai_golden_sample_gate",
"remote_db_check",
"review_worker_smoke",
)
DEFAULT_RELEASE_BENCHMARK_REQUESTS_PER_SCENARIO = 15
DEFAULT_RELEASE_BENCHMARK_CONCURRENCY = 3
DEFAULT_RELEASE_BENCHMARK_WARMUP = 2
@dataclass
class GateCheck:
key: str
label: str
status: str
detail: str
required: bool = True
meta: dict[str, Any] | None = None
def as_dict(self) -> dict[str, Any]:
return {
"key": self.key,
"label": self.label,
"status": self.status,
"required": self.required,
"detail": self.detail,
"meta": self.meta or {},
}
def now_stamp() -> str:
return datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
def checked_at() -> str:
return datetime.now(timezone.utc).astimezone().isoformat()
def normalize_base_url(value: str) -> str:
return value.rstrip("/")
def resolve_path(value: str) -> Path:
path = Path(value)
return path if path.is_absolute() else REPO_ROOT / path
def latest_summary(root: Path, summary_name: str) -> Path | None:
if not root.exists():
return None
candidates = [path for path in root.glob(f"*/{summary_name}") if path.is_file()]
if not candidates:
return None
return max(candidates, key=lambda path: path.stat().st_mtime)
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8-sig"))
def parse_timestamp(value: Any) -> datetime | None:
if not isinstance(value, str) or not value.strip():
return None
normalized = value.strip().replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def age_hours(value: Any) -> float | None:
parsed = parse_timestamp(value)
if parsed is None:
return None
return (datetime.now(timezone.utc) - parsed).total_seconds() / 3600
def age_status(
label: str,
timestamp: Any,
max_age_hours: float,
*,
missing_is_failure: bool = True,
) -> tuple[str, str, dict[str, Any]]:
if max_age_hours <= 0:
return "pass", "", {}
actual_age = age_hours(timestamp)
meta = {"checked_at": timestamp, "max_age_hours": max_age_hours, "age_hours": actual_age}
if actual_age is None:
status = "fail" if missing_is_failure else "pass"
return status, f"{label} timestamp is missing or invalid.", meta
if actual_age > max_age_hours:
return "fail", f"{label} is stale: age_hours={actual_age:.2f} exceeds max_age_hours={max_age_hours}.", meta
return "pass", f"{label} age_hours={actual_age:.2f} is within max_age_hours={max_age_hours}.", meta
def fetch_json(
url: str,
timeout_seconds: float,
headers: dict[str, str] | None = None,
) -> tuple[Any | None, str, int]:
started = time.perf_counter()
request = Request(url, headers={"Accept": "application/json", **(headers or {})}, method="GET")
try:
with urlopen(request, timeout=timeout_seconds) as response:
payload = json.loads(response.read().decode("utf-8"))
elapsed_ms = int((time.perf_counter() - started) * 1000)
return payload, "", elapsed_ms
except HTTPError as exc:
elapsed_ms = int((time.perf_counter() - started) * 1000)
return None, f"HTTPError {exc.code}: {exc.reason}", elapsed_ms
except URLError as exc:
elapsed_ms = int((time.perf_counter() - started) * 1000)
return None, f"URLError: {exc.reason}", elapsed_ms
except TimeoutError as exc:
elapsed_ms = int((time.perf_counter() - started) * 1000)
return None, f"TimeoutError: {exc}", elapsed_ms
except Exception as exc: # pragma: no cover - defensive release logging.
elapsed_ms = int((time.perf_counter() - started) * 1000)
return None, f"{type(exc).__name__}: {exc}", elapsed_ms
def validate_schema_check(
service: str,
base_url: str,
timeout_seconds: float,
output_dir: Path,
*,
headers: dict[str, str] | None = None,
allowed_warning_checks: set[str] | None = None,
) -> GateCheck:
url = f"{normalize_base_url(base_url)}/api/v1/runtime/schema-check"
payload, error, elapsed_ms = fetch_json(url, timeout_seconds, headers)
artifact = output_dir / f"{service}-schema-check.json"
artifact.write_text(json.dumps(payload or {"error": error}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if payload is None:
return GateCheck(
key=f"{service}_schema_check",
label=f"{service} schema-check",
status="fail",
detail=error,
meta={"url": url, "elapsed_ms": elapsed_ms, "artifact": str(artifact)},
)
failed_checks = [
item.get("name", "unknown")
for item in payload.get("checks", [])
if item.get("status") == "error"
]
warning_checks = {
item.get("name", "unknown")
for item in payload.get("checks", [])
if item.get("status") == "warning"
}
allowed_warnings = allowed_warning_checks or set()
if payload.get("status") == "ok" and not failed_checks:
return GateCheck(
key=f"{service}_schema_check",
label=f"{service} schema-check",
status="pass",
detail="Runtime schema-check returned status=ok.",
meta={"url": url, "elapsed_ms": elapsed_ms, "artifact": str(artifact)},
)
if (
payload.get("status") == "warning"
and not failed_checks
and warning_checks
and warning_checks <= allowed_warnings
):
return GateCheck(
key=f"{service}_schema_check",
label=f"{service} schema-check",
status="pass",
detail=f"Runtime schema-check returned only allowed warnings: {','.join(sorted(warning_checks))}.",
meta={
"url": url,
"elapsed_ms": elapsed_ms,
"artifact": str(artifact),
"allowed_warning_checks": sorted(allowed_warnings),
},
)
detail = f"Runtime schema-check status={payload.get('status')}; failed checks={','.join(failed_checks) or '-'}."
return GateCheck(
key=f"{service}_schema_check",
label=f"{service} schema-check",
status="fail",
detail=detail,
meta={"url": url, "elapsed_ms": elapsed_ms, "artifact": str(artifact)},
)
def validate_smoke_summary(path: Path | None, *, skip: bool, max_age_hours: float) -> GateCheck:
if skip:
return GateCheck(
key="docker_smoke_summary",
label="Docker smoke summary",
status="skip",
detail="Docker smoke summary validation was skipped.",
required=False,
)
if path is None:
return GateCheck(
key="docker_smoke_summary",
label="Docker smoke summary",
status="fail",
detail="No Docker smoke summary path was provided and no latest summary was found.",
)
try:
payload = read_json(path)
except Exception as exc:
return GateCheck(
key="docker_smoke_summary",
label="Docker smoke summary",
status="fail",
detail=f"Unable to read Docker smoke summary: {exc}",
meta={"path": str(path)},
)
failed_required = [
item.get("key") or item.get("label", "unknown")
for item in payload.get("checks", [])
if item.get("required", True) and item.get("status") != "pass"
]
checks_by_key = {item.get("key"): item for item in payload.get("checks", [])}
missing_required = [key for key in REQUIRED_SMOKE_CHECKS if key not in checks_by_key]
failed_named = [
key
for key in REQUIRED_SMOKE_CHECKS
if key in checks_by_key and checks_by_key[key].get("status") != "pass"
]
age_check_status, age_detail, age_meta = age_status(
"Docker smoke summary",
payload.get("checked_at"),
max_age_hours,
)
if (
payload.get("status") == "pass"
and not failed_required
and not missing_required
and not failed_named
and age_check_status == "pass"
):
return GateCheck(
key="docker_smoke_summary",
label="Docker smoke summary",
status="pass",
detail="Docker smoke deploy summary passed required checks."
+ (f" {age_detail}" if age_detail else ""),
meta={
"path": str(path),
"checked_at": payload.get("checked_at"),
"server_host": payload.get("server_host"),
"compose_project_name": payload.get("compose_project_name"),
**age_meta,
},
)
detail = (
f"Smoke status={payload.get('status')}; "
f"failed_required={','.join(failed_required + failed_named) or '-'}; "
f"missing_required={','.join(missing_required) or '-'}."
)
if age_check_status != "pass":
detail = f"{detail} {age_detail}"
return GateCheck(
key="docker_smoke_summary",
label="Docker smoke summary",
status="fail",
detail=detail,
meta={"path": str(path), **age_meta},
)
def validate_benchmark_summary(path: Path | None, *, skip: bool, max_age_hours: float) -> GateCheck:
if skip:
return GateCheck(
key="interface_benchmark_summary",
label="Interface benchmark summary",
status="skip",
detail="Interface benchmark summary validation was skipped.",
required=False,
)
if path is None:
return GateCheck(
key="interface_benchmark_summary",
label="Interface benchmark summary",
status="fail",
detail="No interface benchmark summary path was provided and no latest summary was found.",
)
try:
payload = read_json(path)
except Exception as exc:
return GateCheck(
key="interface_benchmark_summary",
label="Interface benchmark summary",
status="fail",
detail=f"Unable to read interface benchmark summary: {exc}",
meta={"path": str(path)},
)
scenarios = payload.get("scenarios", [])
failed = [
item.get("key") or item.get("label", "unknown")
for item in scenarios
if not item.get("passed") or item.get("errors", 0) != 0
]
benchmark_checked_at = payload.get("run", {}).get("checked_at")
age_check_status, age_detail, age_meta = age_status(
"Interface benchmark summary",
benchmark_checked_at,
max_age_hours,
)
if payload.get("status") == "pass" and scenarios and not failed and age_check_status == "pass":
return GateCheck(
key="interface_benchmark_summary",
label="Interface benchmark summary",
status="pass",
detail=f"Interface benchmark passed {len(scenarios)} scenarios."
+ (f" {age_detail}" if age_detail else ""),
meta={
"path": str(path),
"checked_at": benchmark_checked_at,
"requests_per_scenario": payload.get("run", {}).get("requests_per_scenario"),
"concurrency": payload.get("run", {}).get("concurrency"),
**age_meta,
},
)
detail = (
f"Benchmark status={payload.get('status')}; "
f"failed scenarios={','.join(failed) or '-'}; scenarios={len(scenarios)}."
)
if age_check_status != "pass":
detail = f"{detail} {age_detail}"
return GateCheck(
key="interface_benchmark_summary",
label="Interface benchmark summary",
status="fail",
detail=detail,
meta={"path": str(path), **age_meta},
)
def run_benchmark(args: argparse.Namespace, artifact_dir: Path) -> tuple[Path | None, GateCheck]:
benchmark_dir = artifact_dir / "interface-benchmark"
command = [
sys.executable,
str(BENCHMARK_SCRIPT),
"--contract-api-base",
args.contract_api_base,
"--ai-api-base",
args.ai_api_base,
"--requests-per-scenario",
str(args.benchmark_requests_per_scenario),
"--concurrency",
str(args.benchmark_concurrency),
"--warmup",
str(args.benchmark_warmup),
"--timeout-seconds",
str(args.timeout_seconds),
"--artifact-dir",
str(benchmark_dir),
"--tenant-id",
args.tenant_id,
"--user-id",
args.user_id,
"--contract-proxy-key-env",
args.contract_proxy_key_env,
"--ai-service-key-env",
args.ai_service_key_env,
]
if args.include_heavy_explain:
command.append("--include-heavy-explain")
log_path = artifact_dir / "run-interface-benchmark.log"
try:
completed = subprocess.run(
command,
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
except Exception as exc:
log_path.write_text(f"Failed to start benchmark: {exc}\n", encoding="utf-8")
return None, GateCheck(
key="run_interface_benchmark",
label="Run interface benchmark",
status="fail",
detail=f"Failed to start benchmark: {exc}",
meta={"log": str(log_path)},
)
log_path.write_text(
"\n".join(
[
"$ " + " ".join(command),
"",
"## stdout",
completed.stdout,
"",
"## stderr",
completed.stderr,
]
),
encoding="utf-8",
)
summary_path = benchmark_dir / "interface-benchmark-summary.json"
if completed.returncode == 0 and summary_path.exists():
return summary_path, GateCheck(
key="run_interface_benchmark",
label="Run interface benchmark",
status="pass",
detail="Interface benchmark command completed.",
meta={"log": str(log_path), "summary": str(summary_path)},
)
return (summary_path if summary_path.exists() else None), GateCheck(
key="run_interface_benchmark",
label="Run interface benchmark",
status="fail",
detail=f"Interface benchmark command failed with exit code {completed.returncode}.",
meta={"log": str(log_path), "summary": str(summary_path) if summary_path.exists() else None},
)
def write_markdown(path: Path, summary: dict[str, Any]) -> None:
lines: list[str] = []
lines.append("# V2 Release Quality Gate Summary")
lines.append("")
lines.append(f"- Status: {summary['status']}")
lines.append(f"- Checked at: {summary['checked_at']}")
lines.append(f"- Artifact dir: {summary['artifact_dir']}")
lines.append(f"- Web base: {summary['inputs']['web_base_url']}")
lines.append(f"- Contract API base: {summary['inputs']['contract_api_base']}")
lines.append(f"- AI Platform API base: {summary['inputs']['ai_api_base']}")
lines.append("")
lines.append("## Checks")
lines.append("| Check | Status | Required | Detail |")
lines.append("| --- | --- | --- | --- |")
for check in summary["checks"]:
lines.append(
f"| {check['label']} | {check['status']} | {str(check['required']).lower()} | {check['detail']} |"
)
lines.append("")
lines.append("## Sources")
for key, value in summary["sources"].items():
lines.append(f"- {key}: {value or '-'}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run the V2 release quality gate.")
parser.add_argument("--web-base-url", default="http://localhost:3000")
parser.add_argument("--contract-api-base", default="http://localhost:8100")
parser.add_argument("--ai-api-base", default="http://localhost:8101")
parser.add_argument("--timeout-seconds", type=float, default=20.0)
parser.add_argument("--artifact-dir", default="")
parser.add_argument("--smoke-summary", default="")
parser.add_argument("--benchmark-summary", default="")
parser.add_argument("--skip-smoke-summary", action="store_true")
parser.add_argument("--skip-benchmark-summary", action="store_true")
parser.add_argument("--skip-schema-check", action="store_true")
parser.add_argument("--max-smoke-summary-age-hours", type=float, default=0)
parser.add_argument("--max-benchmark-summary-age-hours", type=float, default=0)
parser.add_argument("--run-benchmark", action="store_true")
parser.add_argument(
"--benchmark-requests-per-scenario",
type=int,
default=DEFAULT_RELEASE_BENCHMARK_REQUESTS_PER_SCENARIO,
)
parser.add_argument(
"--benchmark-concurrency",
type=int,
default=DEFAULT_RELEASE_BENCHMARK_CONCURRENCY,
)
parser.add_argument(
"--benchmark-warmup",
type=int,
default=DEFAULT_RELEASE_BENCHMARK_WARMUP,
)
parser.add_argument("--include-heavy-explain", action="store_true")
parser.add_argument("--tenant-id", default="default")
parser.add_argument("--user-id", default="user-current-admin")
parser.add_argument("--contract-proxy-key-env", default="CONTRACT_API_WEB_PROXY_SHARED_SECRET")
parser.add_argument("--ai-service-key-env", default="AI_PLATFORM_SERVICE_SHARED_SECRET")
parser.add_argument(
"--allow-ai-provider-warning",
action="store_true",
help="Allow only the AI provider_runtime warning for an intentional smoke fallback environment.",
)
return parser.parse_args()
def request_headers(tenant_id: str, user_id: str, auth_header: str, secret: str) -> dict[str, str]:
headers: dict[str, str] = {}
if tenant_id.strip():
headers["X-Tenant-Id"] = tenant_id.strip()
if user_id.strip():
headers["X-User-Id"] = user_id.strip()
if secret:
headers[auth_header] = secret
return headers
def main() -> int:
args = parse_args()
contract_proxy_key = os.getenv(args.contract_proxy_key_env, "").strip()
ai_service_key = os.getenv(args.ai_service_key_env, "").strip()
contract_headers = request_headers(
args.tenant_id,
args.user_id,
"X-Contract-Proxy-Key",
contract_proxy_key,
)
ai_headers = request_headers(
args.tenant_id,
args.user_id,
"X-AI-Platform-Service-Key",
ai_service_key,
)
artifact_dir = resolve_path(args.artifact_dir) if args.artifact_dir else DEFAULT_ARTIFACT_ROOT / now_stamp()
artifact_dir.mkdir(parents=True, exist_ok=True)
checks: list[GateCheck] = []
smoke_summary = resolve_path(args.smoke_summary) if args.smoke_summary else latest_summary(
SMOKE_ARTIFACT_ROOT,
"v2-smoke-docker-deploy-summary.json",
)
benchmark_summary = resolve_path(args.benchmark_summary) if args.benchmark_summary else latest_summary(
BENCHMARK_ARTIFACT_ROOT,
"interface-benchmark-summary.json",
)
if not args.skip_schema_check:
checks.append(
validate_schema_check(
"contract_api",
args.contract_api_base,
args.timeout_seconds,
artifact_dir,
headers=contract_headers,
)
)
checks.append(
validate_schema_check(
"ai_platform_api",
args.ai_api_base,
args.timeout_seconds,
artifact_dir,
headers=ai_headers,
allowed_warning_checks={"provider_runtime"} if args.allow_ai_provider_warning else set(),
)
)
else:
checks.append(
GateCheck(
key="schema_check",
label="Runtime schema-check",
status="skip",
detail="Runtime schema-check validation was skipped.",
required=False,
)
)
checks.append(
validate_smoke_summary(
smoke_summary,
skip=args.skip_smoke_summary,
max_age_hours=args.max_smoke_summary_age_hours,
)
)
if args.run_benchmark and not args.skip_benchmark_summary:
benchmark_summary, benchmark_run_check = run_benchmark(args, artifact_dir)
checks.append(benchmark_run_check)
checks.append(
validate_benchmark_summary(
benchmark_summary,
skip=args.skip_benchmark_summary,
max_age_hours=args.max_benchmark_summary_age_hours,
)
)
failed_required = [check for check in checks if check.required and check.status != "pass"]
status = "pass" if not failed_required else "fail"
summary = {
"status": status,
"checked_at": checked_at(),
"artifact_dir": str(artifact_dir),
"inputs": {
"web_base_url": args.web_base_url,
"contract_api_base": args.contract_api_base,
"ai_api_base": args.ai_api_base,
"run_benchmark": args.run_benchmark,
"benchmark_requests_per_scenario": args.benchmark_requests_per_scenario,
"benchmark_concurrency": args.benchmark_concurrency,
"benchmark_warmup": args.benchmark_warmup,
"max_smoke_summary_age_hours": args.max_smoke_summary_age_hours,
"max_benchmark_summary_age_hours": args.max_benchmark_summary_age_hours,
"tenant_id": args.tenant_id,
"user_id": args.user_id,
"contract_proxy_auth_configured": bool(contract_proxy_key),
"ai_service_auth_configured": bool(ai_service_key),
"allow_ai_provider_warning": args.allow_ai_provider_warning,
},
"sources": {
"smoke_summary": str(smoke_summary) if smoke_summary else None,
"benchmark_summary": str(benchmark_summary) if benchmark_summary else None,
},
"checks": [check.as_dict() for check in checks],
}
summary_json = artifact_dir / "release-quality-gate-summary.json"
summary_md = artifact_dir / "release-quality-gate-summary.md"
summary_json.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
write_markdown(summary_md, summary)
print(f"V2_RELEASE_QUALITY_GATE_{status.upper()} {artifact_dir}")
return 0 if status == "pass" else 1
if __name__ == "__main__":
raise SystemExit(main())