2026-07-28 10:37:41 +08:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""启动两个本地服务,等待 HTTP 就绪后执行检查并清理进程。"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
2026-07-28 15:58:05 +08:00
|
|
|
|
import concurrent.futures
|
2026-07-28 10:37:41 +08:00
|
|
|
|
import os
|
2026-07-28 15:58:05 +08:00
|
|
|
|
import re
|
2026-07-28 10:37:41 +08:00
|
|
|
|
import shutil
|
|
|
|
|
|
import signal
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import tempfile
|
|
|
|
|
|
import time
|
|
|
|
|
|
import urllib.error
|
|
|
|
|
|
import urllib.request
|
2026-07-28 15:58:05 +08:00
|
|
|
|
import uuid
|
2026-07-28 10:37:41 +08:00
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Service:
|
|
|
|
|
|
name: str
|
|
|
|
|
|
command: str
|
|
|
|
|
|
cwd: Path
|
|
|
|
|
|
url: str
|
|
|
|
|
|
process: subprocess.Popen[bytes] | None = None
|
|
|
|
|
|
log_path: Path | None = None
|
|
|
|
|
|
log_file: object | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 15:58:05 +08:00
|
|
|
|
SENSITIVE_VALUE_PATTERN = re.compile(
|
|
|
|
|
|
r"(?i)(password|token|secret|authorization|api[_-]?key)"
|
|
|
|
|
|
r"(\s*[\"']?\s*[:=]\s*[\"']?)([^\"'\s,;}\]]+)"
|
|
|
|
|
|
)
|
|
|
|
|
|
BEARER_PATTERN = re.compile(r"(?i)(Bearer\s+)[^\s,;]+")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 10:37:41 +08:00
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
|
description="启动前后端命令,检查 HTTP 地址并清理进程。"
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument("--backend-cmd", required=True, help="后端启动命令")
|
|
|
|
|
|
parser.add_argument("--backend-dir", default=".", help="后端工作目录")
|
|
|
|
|
|
parser.add_argument("--backend-url", required=True, help="后端健康检查地址")
|
|
|
|
|
|
parser.add_argument("--frontend-cmd", required=True, help="前端启动命令")
|
|
|
|
|
|
parser.add_argument("--frontend-dir", default=".", help="前端工作目录")
|
|
|
|
|
|
parser.add_argument("--frontend-url", required=True, help="前端就绪检查地址")
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--check-url",
|
|
|
|
|
|
action="append",
|
|
|
|
|
|
default=[],
|
|
|
|
|
|
metavar="LABEL=URL",
|
|
|
|
|
|
help="额外的 GET 检查,可重复传入",
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--timeout",
|
|
|
|
|
|
type=float,
|
|
|
|
|
|
default=60.0,
|
|
|
|
|
|
help="每个服务的就绪超时时间,单位为秒,默认 60",
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--interval",
|
|
|
|
|
|
type=float,
|
2026-07-28 15:58:05 +08:00
|
|
|
|
default=0.25,
|
|
|
|
|
|
help="每次就绪检查的间隔秒数,默认 0.25",
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--check-timeout",
|
|
|
|
|
|
type=float,
|
|
|
|
|
|
default=5.0,
|
|
|
|
|
|
help="额外接口检查的单请求超时时间,默认 5 秒",
|
2026-07-28 10:37:41 +08:00
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--log-dir",
|
|
|
|
|
|
type=Path,
|
|
|
|
|
|
help="日志目录,运行结束后保留",
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--keep-logs",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="运行结束后保留临时日志",
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--keep-running",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="验证结束后保持已启动的服务运行",
|
|
|
|
|
|
)
|
|
|
|
|
|
return parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_dir(value: str) -> Path:
|
|
|
|
|
|
path = Path(value).resolve()
|
|
|
|
|
|
if not path.is_dir():
|
|
|
|
|
|
raise RuntimeError(f"工作目录不存在:{path}")
|
|
|
|
|
|
return path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def start_service(service: Service, log_dir: Path) -> None:
|
|
|
|
|
|
service.log_path = log_dir / f"{service.name}.log"
|
|
|
|
|
|
service.log_file = service.log_path.open("w", encoding="utf-8", buffering=1)
|
|
|
|
|
|
creationflags = 0
|
|
|
|
|
|
start_new_session = False
|
|
|
|
|
|
if os.name == "nt":
|
|
|
|
|
|
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
|
|
|
|
else:
|
|
|
|
|
|
start_new_session = True
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
service.process = subprocess.Popen(
|
|
|
|
|
|
service.command,
|
|
|
|
|
|
cwd=service.cwd,
|
|
|
|
|
|
shell=True,
|
|
|
|
|
|
stdout=service.log_file,
|
|
|
|
|
|
stderr=subprocess.STDOUT,
|
|
|
|
|
|
creationflags=creationflags,
|
|
|
|
|
|
start_new_session=start_new_session,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
service.log_file.close()
|
|
|
|
|
|
service.log_file = None
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
print(f"启动 {service.name}:进程={service.process.pid},工作目录={service.cwd}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 15:58:05 +08:00
|
|
|
|
def request(url: str, timeout: float, request_id: str) -> tuple[bool, str]:
|
|
|
|
|
|
request_obj = urllib.request.Request(
|
|
|
|
|
|
url,
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"User-Agent": "fullstack-start-verify/1.1",
|
|
|
|
|
|
"X-Request-Id": request_id,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
2026-07-28 10:37:41 +08:00
|
|
|
|
try:
|
|
|
|
|
|
with urllib.request.urlopen(request_obj, timeout=timeout) as response:
|
|
|
|
|
|
status = response.status
|
|
|
|
|
|
return 200 <= status < 400, str(status)
|
|
|
|
|
|
except urllib.error.HTTPError as error:
|
|
|
|
|
|
return 200 <= error.code < 400, str(error.code)
|
|
|
|
|
|
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
|
|
|
|
|
reason = getattr(error, "reason", error)
|
|
|
|
|
|
return False, type(reason).__name__
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 15:58:05 +08:00
|
|
|
|
def wait_for_service(
|
|
|
|
|
|
service: Service, timeout: float, interval: float, run_id: str
|
|
|
|
|
|
) -> None:
|
2026-07-28 10:37:41 +08:00
|
|
|
|
if service.process is None:
|
|
|
|
|
|
raise RuntimeError(f"{service.name} 未启动")
|
|
|
|
|
|
|
|
|
|
|
|
deadline = time.monotonic() + timeout
|
|
|
|
|
|
last_reason = "not checked"
|
|
|
|
|
|
while time.monotonic() < deadline:
|
|
|
|
|
|
exit_code = service.process.poll()
|
|
|
|
|
|
if exit_code is not None:
|
|
|
|
|
|
raise RuntimeError(f"{service.name} 在就绪检查前退出,退出码为 {exit_code}")
|
|
|
|
|
|
|
2026-07-28 15:58:05 +08:00
|
|
|
|
request_timeout = min(max(interval * 2, 0.5), 5.0)
|
|
|
|
|
|
ok, reason = request(service.url, request_timeout, f"{run_id}-{service.name}")
|
2026-07-28 10:37:41 +08:00
|
|
|
|
last_reason = reason
|
|
|
|
|
|
if ok:
|
|
|
|
|
|
print(f"通过 {service.name}:{service.url} [{reason}]")
|
|
|
|
|
|
return
|
2026-07-28 15:58:05 +08:00
|
|
|
|
time.sleep(min(max(interval, 0.05), max(deadline - time.monotonic(), 0)))
|
2026-07-28 10:37:41 +08:00
|
|
|
|
|
|
|
|
|
|
raise RuntimeError(f"{service.name} 在 {timeout:g} 秒内未就绪 [{last_reason}]")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_extra_checks(values: list[str]) -> list[tuple[str, str]]:
|
|
|
|
|
|
checks: list[tuple[str, str]] = []
|
|
|
|
|
|
for value in values:
|
|
|
|
|
|
if "=" in value:
|
|
|
|
|
|
label, url = value.split("=", 1)
|
|
|
|
|
|
else:
|
|
|
|
|
|
label, url = value, value
|
|
|
|
|
|
label = label.strip() or url.strip()
|
|
|
|
|
|
url = url.strip()
|
|
|
|
|
|
if not url.startswith(("http://", "https://")):
|
|
|
|
|
|
raise RuntimeError(f"检查地址无效:{url}")
|
|
|
|
|
|
checks.append((label, url))
|
|
|
|
|
|
return checks
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 15:58:05 +08:00
|
|
|
|
def wait_for_services(
|
|
|
|
|
|
services: list[Service], timeout: float, interval: float, run_id: str
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""并行等待服务,整体耗时取最慢服务的就绪时间。"""
|
|
|
|
|
|
executor = concurrent.futures.ThreadPoolExecutor(max_workers=len(services))
|
|
|
|
|
|
failed = True
|
|
|
|
|
|
try:
|
|
|
|
|
|
futures = [
|
|
|
|
|
|
executor.submit(wait_for_service, service, timeout, interval, run_id)
|
|
|
|
|
|
for service in services
|
|
|
|
|
|
]
|
|
|
|
|
|
done, _ = concurrent.futures.wait(
|
|
|
|
|
|
futures, return_when=concurrent.futures.FIRST_EXCEPTION
|
|
|
|
|
|
)
|
|
|
|
|
|
for future in done:
|
|
|
|
|
|
future.result()
|
|
|
|
|
|
failed = False
|
|
|
|
|
|
finally:
|
|
|
|
|
|
executor.shutdown(wait=not failed, cancel_futures=failed)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_extra_checks(
|
|
|
|
|
|
checks: list[tuple[str, str]], timeout: float, run_id: str
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""并行执行只读接口检查,避免多个接口检查串行等待。"""
|
|
|
|
|
|
if not checks:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
def execute_check(
|
|
|
|
|
|
item: tuple[int, tuple[str, str]]
|
|
|
|
|
|
) -> tuple[str, str, tuple[bool, str]]:
|
|
|
|
|
|
index, (label, url) = item
|
|
|
|
|
|
return label, url, request(url, timeout, f"{run_id}-check-{index}")
|
|
|
|
|
|
|
|
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=len(checks)) as executor:
|
|
|
|
|
|
results = list(executor.map(execute_check, enumerate(checks, start=1)))
|
|
|
|
|
|
for label, url, (ok, reason) in results:
|
2026-07-28 10:37:41 +08:00
|
|
|
|
if not ok:
|
|
|
|
|
|
raise RuntimeError(f"检查失败:{label} {url} [{reason}]")
|
|
|
|
|
|
print(f"通过 {label}:{url} [{reason}]")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def stop_service(service: Service) -> None:
|
|
|
|
|
|
process = service.process
|
|
|
|
|
|
if process is None or process.poll() is not None:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
if os.name == "nt":
|
|
|
|
|
|
try:
|
2026-07-28 15:58:05 +08:00
|
|
|
|
subprocess.run(
|
|
|
|
|
|
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
|
|
|
|
stdout=subprocess.DEVNULL,
|
|
|
|
|
|
stderr=subprocess.DEVNULL,
|
|
|
|
|
|
check=False,
|
|
|
|
|
|
timeout=2,
|
|
|
|
|
|
)
|
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
|
pass
|
|
|
|
|
|
try:
|
|
|
|
|
|
process.wait(timeout=1)
|
2026-07-28 10:37:41 +08:00
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
|
process.kill()
|
2026-07-28 15:58:05 +08:00
|
|
|
|
try:
|
|
|
|
|
|
process.wait(timeout=1)
|
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
|
process.returncode = -1
|
2026-07-28 10:37:41 +08:00
|
|
|
|
except OSError:
|
|
|
|
|
|
# taskkill 已经结束进程,但 Windows 可能同时使句柄失效。
|
|
|
|
|
|
process.returncode = -1
|
|
|
|
|
|
else:
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.killpg(process.pid, signal.SIGTERM)
|
|
|
|
|
|
except ProcessLookupError:
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
process.wait(timeout=5)
|
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def tail(path: Path | None, lines: int = 20) -> list[str]:
|
|
|
|
|
|
if path is None or not path.exists():
|
|
|
|
|
|
return []
|
|
|
|
|
|
return path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 15:58:05 +08:00
|
|
|
|
def redact(line: str) -> str:
|
|
|
|
|
|
"""仅在终端输出日志时脱敏,避免错误摘要泄露凭据。"""
|
|
|
|
|
|
line = SENSITIVE_VALUE_PATTERN.sub(r"\1\2[REDACTED]", line)
|
|
|
|
|
|
return BEARER_PATTERN.sub(r"\1[REDACTED]", line)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 10:37:41 +08:00
|
|
|
|
def main() -> int:
|
|
|
|
|
|
args = parse_args()
|
2026-07-28 15:58:05 +08:00
|
|
|
|
if args.timeout <= 0 or args.interval <= 0 or args.check_timeout <= 0:
|
|
|
|
|
|
raise SystemExit("timeout、interval 和 check-timeout 必须大于 0")
|
|
|
|
|
|
run_id = uuid.uuid4().hex[:12]
|
|
|
|
|
|
print(f"验证任务 ID:{run_id}")
|
|
|
|
|
|
checks = parse_extra_checks(args.check_url)
|
2026-07-28 10:37:41 +08:00
|
|
|
|
temp_log_dir: Path | None = None
|
|
|
|
|
|
log_dir = args.log_dir
|
|
|
|
|
|
if log_dir is None:
|
|
|
|
|
|
temp_log_dir = Path(tempfile.mkdtemp(prefix="fullstack-start-verify-"))
|
|
|
|
|
|
log_dir = temp_log_dir
|
|
|
|
|
|
log_dir = log_dir.resolve()
|
|
|
|
|
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
services = [
|
|
|
|
|
|
Service("backend", args.backend_cmd, resolve_dir(args.backend_dir), args.backend_url),
|
|
|
|
|
|
Service("frontend", args.frontend_cmd, resolve_dir(args.frontend_dir), args.frontend_url),
|
|
|
|
|
|
]
|
|
|
|
|
|
failure: Exception | None = None
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
for service in services:
|
|
|
|
|
|
start_service(service, log_dir)
|
2026-07-28 15:58:05 +08:00
|
|
|
|
wait_for_services(services, args.timeout, args.interval, run_id)
|
|
|
|
|
|
run_extra_checks(checks, args.check_timeout, run_id)
|
|
|
|
|
|
print(f"全栈验证通过(FULLSTACK_VERIFY=PASS,任务 ID:{run_id})")
|
2026-07-28 10:37:41 +08:00
|
|
|
|
return 0
|
|
|
|
|
|
except (OSError, RuntimeError, ValueError) as error:
|
|
|
|
|
|
failure = error
|
|
|
|
|
|
print(f"全栈验证失败:{error}", file=sys.stderr)
|
|
|
|
|
|
for service in services:
|
|
|
|
|
|
for line in tail(service.log_path):
|
2026-07-28 15:58:05 +08:00
|
|
|
|
print(f"日志 {service.name}:{redact(line)}", file=sys.stderr)
|
2026-07-28 10:37:41 +08:00
|
|
|
|
return 1
|
|
|
|
|
|
finally:
|
|
|
|
|
|
if not args.keep_running:
|
|
|
|
|
|
for service in reversed(services):
|
|
|
|
|
|
stop_service(service)
|
|
|
|
|
|
for service in services:
|
|
|
|
|
|
if service.log_file is not None:
|
|
|
|
|
|
service.log_file.close()
|
|
|
|
|
|
if args.keep_running:
|
|
|
|
|
|
print(f"服务仍在运行,日志目录:{log_dir}")
|
|
|
|
|
|
elif temp_log_dir is not None and not args.keep_logs:
|
|
|
|
|
|
shutil.rmtree(temp_log_dir, ignore_errors=True)
|
|
|
|
|
|
elif temp_log_dir is not None:
|
|
|
|
|
|
print(f"日志已保留:{temp_log_dir}")
|
|
|
|
|
|
elif failure is not None:
|
|
|
|
|
|
print(f"日志已保留:{log_dir}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
raise SystemExit(main())
|