feat: add production health smoke
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
type Check = {
|
||||
status: "ok" | "degraded" | "blocked";
|
||||
message?: string;
|
||||
latencyMs?: number;
|
||||
};
|
||||
|
||||
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
|
||||
|
||||
function envCheck(names: string[]): Check {
|
||||
const missing = names.filter((name) => !process.env[name]);
|
||||
return missing.length
|
||||
? { status: "blocked", message: `missing:${missing.join(",")}` }
|
||||
: { status: "ok" };
|
||||
}
|
||||
|
||||
function anyEnvCheck(names: string[]): Check {
|
||||
return names.some((name) => process.env[name])
|
||||
? { status: "ok" }
|
||||
: { status: "blocked", message: `missing_one_of:${names.join("|")}` };
|
||||
}
|
||||
|
||||
async function jyotishApiCheck(): Promise<Check> {
|
||||
const started = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
try {
|
||||
const response = await fetch(`${jyotishApiBase}/api/health`, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
return {
|
||||
status: response.ok ? "ok" : "degraded",
|
||||
message: response.ok ? undefined : `http:${response.status}`,
|
||||
latencyMs: Date.now() - started,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "blocked",
|
||||
message: error instanceof Error ? error.name : "jyotish_api_unavailable",
|
||||
latencyMs: Date.now() - started,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function aggregate(checks: Record<string, Check>) {
|
||||
if (Object.values(checks).some((check) => check.status === "blocked")) return "blocked";
|
||||
if (Object.values(checks).some((check) => check.status === "degraded")) return "degraded";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const checks = {
|
||||
web: { status: "ok" } satisfies Check,
|
||||
supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]),
|
||||
supabaseServiceRole: envCheck(["SUPABASE_SERVICE_ROLE_KEY"]),
|
||||
modelProvider: anyEnvCheck(["LLM_MODELS_JSON", "OPENAI_API_KEY", "LLM_API_KEY", "DEEPSEEK_API_KEY"]),
|
||||
jyotishApi: await jyotishApiCheck(),
|
||||
};
|
||||
const status = aggregate(checks);
|
||||
return NextResponse.json(
|
||||
{
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
checks,
|
||||
},
|
||||
{ status: status === "ok" ? 200 : 503 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Production smoke check for Jyotisha web deployment."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
def fetch(url: str, timeout: float) -> tuple[int, str, float]:
|
||||
started = time.monotonic()
|
||||
request = Request(url, headers={"User-Agent": "jyotisha-production-smoke/1.0"})
|
||||
try:
|
||||
with urlopen(request, timeout=timeout) as response:
|
||||
return response.status, response.read().decode("utf-8", "replace"), time.monotonic() - started
|
||||
except HTTPError as error:
|
||||
return error.code, error.read().decode("utf-8", "replace"), time.monotonic() - started
|
||||
except URLError as error:
|
||||
raise RuntimeError(str(error.reason)) from error
|
||||
|
||||
|
||||
def check(base_url: str, timeout: float) -> dict:
|
||||
base = base_url.rstrip("/")
|
||||
checks: list[dict] = []
|
||||
|
||||
status, body, elapsed = fetch(f"{base}/", timeout)
|
||||
checks.append(
|
||||
{
|
||||
"name": "homepage",
|
||||
"ok": status == 200 and ("Jyotisha" in body or "账户与出生资料" in body),
|
||||
"status": status,
|
||||
"latency_ms": round(elapsed * 1000),
|
||||
}
|
||||
)
|
||||
|
||||
status, body, elapsed = fetch(f"{base}/api/health", timeout)
|
||||
health = json.loads(body) if body.strip().startswith("{") else {}
|
||||
checks.append(
|
||||
{
|
||||
"name": "health",
|
||||
"ok": status in {200, 503} and health.get("status") in {"ok", "degraded", "blocked"},
|
||||
"status": status,
|
||||
"latency_ms": round(elapsed * 1000),
|
||||
"health_status": health.get("status"),
|
||||
"checks": sorted((health.get("checks") or {}).keys()),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"base_url": base,
|
||||
"ok": all(item["ok"] for item in checks),
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-url", default="https://jyotisha.chat")
|
||||
parser.add_argument("--timeout", type=float, default=8.0)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
report = check(args.base_url, args.timeout)
|
||||
except Exception as error: # noqa: BLE001 - CLI smoke should report compact failure.
|
||||
report = {"base_url": args.base_url, "ok": False, "error": str(error)}
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
return 0 if report.get("ok") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,28 @@
|
||||
from scripts.production_smoke import check
|
||||
|
||||
|
||||
def test_health_route_declares_required_checks() -> None:
|
||||
source = open("frontend/src/app/api/health/route.ts", encoding="utf-8").read()
|
||||
|
||||
for expected in (
|
||||
"supabasePublicConfig",
|
||||
"supabaseServiceRole",
|
||||
"modelProvider",
|
||||
"jyotishApi",
|
||||
"JYOTISH_API_BASE",
|
||||
):
|
||||
assert expected in source
|
||||
|
||||
|
||||
def test_production_smoke_accepts_health_degraded_status(monkeypatch) -> None:
|
||||
def fake_fetch(url: str, timeout: float) -> tuple[int, str, float]:
|
||||
if url.endswith("/api/health"):
|
||||
return 503, '{"status":"blocked","checks":{"web":{},"jyotishApi":{}}}', 0.01
|
||||
return 200, "Jyotisha", 0.01
|
||||
|
||||
monkeypatch.setattr("scripts.production_smoke.fetch", fake_fetch)
|
||||
|
||||
report = check("https://example.invalid", 1.0)
|
||||
|
||||
assert report["ok"] is True
|
||||
assert report["checks"][1]["health_status"] == "blocked"
|
||||
Reference in New Issue
Block a user