doc-inspector / scripts /check_live_space.py
steven0226's picture
deploy: 建立乾淨的已驗收版本
9d60e8e verified
Raw
History Blame Contribute Delete
7.07 kB
"""Read-only health check for the published web interface.
This script never uploads a document, calls a model endpoint, or reads dotenv
secrets. It only verifies that the public HTML shell is reachable.
"""
from __future__ import annotations
import argparse
from collections.abc import Callable
import json
from time import perf_counter, sleep
from typing import Any
from urllib.parse import urlsplit, urlunsplit
from urllib.request import Request, urlopen
DEFAULT_EXPECTED_TEXT = "文件預檢所"
LOCAL_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
def validate_target_url(url: str, *, allow_http_localhost: bool = False) -> str:
"""Validate and normalize a secret-free health-check target."""
candidate = url.strip()
parsed = urlsplit(candidate)
if parsed.username or parsed.password:
raise ValueError("健康檢查網址不可包含帳號或密碼。")
if parsed.query or parsed.fragment:
raise ValueError("健康檢查網址不可包含 query 或 fragment。")
if not parsed.hostname:
raise ValueError("健康檢查網址缺少主機名稱。")
is_local_http = (
parsed.scheme == "http"
and allow_http_localhost
and parsed.hostname.lower() in LOCAL_HOSTS
)
if parsed.scheme != "https" and not is_local_http:
raise ValueError("遠端健康檢查只接受 HTTPS;HTTP 僅限明確允許的 localhost。")
path = parsed.path or "/"
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
def check_once(
url: str,
*,
expected_text: str = DEFAULT_EXPECTED_TEXT,
timeout_seconds: float = 20,
max_response_bytes: int = 2 * 1024 * 1024,
opener: Callable[..., Any] = urlopen,
) -> dict[str, object]:
"""Fetch one HTML shell and return non-sensitive health metadata."""
if timeout_seconds <= 0:
raise ValueError("timeout_seconds 必須大於 0。")
if max_response_bytes <= 0:
raise ValueError("max_response_bytes 必須大於 0。")
if not expected_text:
raise ValueError("expected_text 不可為空。")
request = Request(
url,
headers={
"Accept": "text/html",
"Accept-Encoding": "identity",
"User-Agent": "doc-inspector-release-check/1.0",
},
method="GET",
)
started = perf_counter()
with opener(request, timeout=timeout_seconds) as response:
status = int(response.getcode())
content_type = response.headers.get_content_type()
charset = response.headers.get_content_charset() or "utf-8"
payload = response.read(max_response_bytes + 1)
final_url = response.geturl()
elapsed_ms = max(0, round((perf_counter() - started) * 1000))
if status != 200:
raise RuntimeError(f"介面回傳 HTTP {status}。")
if content_type != "text/html":
raise RuntimeError(f"介面回傳的 Content-Type 不是 text/html:{content_type}。")
if len(payload) > max_response_bytes:
raise RuntimeError("介面 HTML 超過健康檢查讀取上限。")
try:
html = payload.decode(charset)
except (LookupError, UnicodeDecodeError) as exc:
raise RuntimeError("介面 HTML 無法依宣告編碼解碼。") from exc
if expected_text not in html:
raise RuntimeError("介面可連線,但找不到預期的應用程式標題。")
return {
"healthy": True,
"http_status": status,
"content_type": content_type,
"expected_text_found": True,
"response_bytes": len(payload),
"elapsed_ms": elapsed_ms,
"final_url": final_url,
"uploads_document": False,
"calls_model": False,
"reads_env_truth": False,
}
def run_health_check(
url: str,
*,
expected_text: str = DEFAULT_EXPECTED_TEXT,
retries: int = 6,
delay_seconds: float = 10,
timeout_seconds: float = 20,
allow_http_localhost: bool = False,
opener: Callable[..., Any] = urlopen,
sleep_fn: Callable[[float], None] = sleep,
) -> dict[str, object]:
"""Retry a sleeping deployment and return a stable JSON-ready report."""
if retries < 1:
raise ValueError("retries 必須至少為 1。")
if delay_seconds < 0:
raise ValueError("delay_seconds 不可小於 0。")
target = validate_target_url(
url,
allow_http_localhost=allow_http_localhost,
)
failures: list[dict[str, object]] = []
for attempt in range(1, retries + 1):
try:
result = check_once(
target,
expected_text=expected_text,
timeout_seconds=timeout_seconds,
opener=opener,
)
return {
**result,
"target_url": target,
"attempts": attempt,
"previous_failures": failures,
}
except Exception as exc: # Network libraries expose several exception types.
failures.append(
{
"attempt": attempt,
"error_type": type(exc).__name__,
"message": str(exc),
}
)
if attempt < retries:
sleep_fn(delay_seconds)
return {
"healthy": False,
"target_url": target,
"attempts": retries,
"failures": failures,
"uploads_document": False,
"calls_model": False,
"reads_env_truth": False,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="檢查公開介面 HTML 是否可用;不會上傳文件或呼叫模型。",
)
parser.add_argument("--url", required=True, help="公開 HTTPS URL")
parser.add_argument(
"--expected-text",
default=DEFAULT_EXPECTED_TEXT,
help="HTML 中必須出現的文字",
)
parser.add_argument("--retries", type=int, default=6)
parser.add_argument("--delay-seconds", type=float, default=10)
parser.add_argument("--timeout-seconds", type=float, default=20)
parser.add_argument(
"--allow-http-localhost",
action="store_true",
help="只供本機容器預檢使用",
)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
report = run_health_check(
args.url,
expected_text=args.expected_text,
retries=args.retries,
delay_seconds=args.delay_seconds,
timeout_seconds=args.timeout_seconds,
allow_http_localhost=args.allow_http_localhost,
)
except ValueError as exc:
report = {
"healthy": False,
"configuration_error": str(exc),
"uploads_document": False,
"calls_model": False,
"reads_env_truth": False,
}
print(json.dumps(report, ensure_ascii=False))
return 0 if report["healthy"] else 1
if __name__ == "__main__":
raise SystemExit(main())