273 lines
9.8 KiB
Python
273 lines
9.8 KiB
Python
"""项目检测引擎: 解压项目包, 执行稳定度/压力测试, 汇总报告。"""
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import tarfile
|
|
import threading
|
|
import time
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
from django.conf import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PROBE_PATHS = ["/", "/index.html", "/health", "/api", "/healthz", "/status"]
|
|
|
|
|
|
def _detect_kind(archive: Path) -> str:
|
|
with open(archive, "rb") as fh:
|
|
head = fh.read(512)
|
|
if head[:4] == b"PK\x03\x04":
|
|
return "zip"
|
|
if head[:2] == b"\x1f\x8b":
|
|
return "tar.gz"
|
|
if b"ustar" in head[:262]:
|
|
return "tar"
|
|
return archive.suffix.lstrip(".") or "unknown"
|
|
|
|
|
|
def _extract(archive: Path, dest: Path) -> Path:
|
|
kind = _detect_kind(archive)
|
|
if kind == "zip":
|
|
with zipfile.ZipFile(archive) as zf:
|
|
for member in zf.namelist():
|
|
target = (dest / member).resolve()
|
|
if not str(target).startswith(str(dest.resolve())):
|
|
raise ValueError("压缩包含非法路径")
|
|
zf.extractall(dest)
|
|
elif kind in ("tar", "tgz", "tar.gz"):
|
|
with tarfile.open(archive) as tf:
|
|
for member in tf.getmembers():
|
|
target = (dest / member.name).resolve()
|
|
if not str(target).startswith(str(dest.resolve())):
|
|
raise ValueError("压缩包含非法路径")
|
|
tf.extractall(dest, filter="data")
|
|
else:
|
|
raise ValueError(f"不支持的归档格式: {archive.suffix}")
|
|
if len(list(dest.iterdir())) == 1:
|
|
only = next(dest.iterdir())
|
|
if only.is_dir():
|
|
return only
|
|
return dest
|
|
|
|
|
|
_MARKERS = {
|
|
"requirements.txt": "python", "pyproject.toml": "python", "manage.py": "python",
|
|
"setup.py": "python", "main.py": "python", "app.py": "python",
|
|
"package.json": "nodejs", "pom.xml": "java", "build.gradle": "java",
|
|
"go.mod": "go", "Cargo.toml": "rust", "tsconfig.json": "typescript",
|
|
}
|
|
|
|
|
|
def _project_language(root: Path) -> str:
|
|
for name in ("requirements.txt", "pyproject.toml", "manage.py",
|
|
"package.json", "pom.xml", "go.mod", "Cargo.toml"):
|
|
if (root / name).exists():
|
|
return _MARKERS[name]
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
for name in filenames:
|
|
if name in _MARKERS:
|
|
return _MARKERS[name]
|
|
return "unknown"
|
|
|
|
|
|
def _probe_server(base: str) -> bool:
|
|
for path in PROBE_PATHS:
|
|
try:
|
|
resp = requests.get(base + path, timeout=5, verify=False)
|
|
if resp.status_code < 500:
|
|
return True
|
|
except requests.RequestException:
|
|
continue
|
|
return False
|
|
|
|
|
|
def _stress_one(base: str, duration: float, results: list, lock: threading.Lock):
|
|
ok = err = 0
|
|
errors: list[str] = []
|
|
start = time.monotonic()
|
|
while time.monotonic() - start < duration:
|
|
try:
|
|
resp = requests.get(base, timeout=5, verify=False)
|
|
if resp.status_code < 500:
|
|
ok += 1
|
|
else:
|
|
err += 1
|
|
if len(errors) < 5:
|
|
errors.append(f"HTTP {resp.status_code}")
|
|
except requests.RequestException as exc:
|
|
err += 1
|
|
if len(errors) < 5:
|
|
errors.append(type(exc).__name__)
|
|
with lock:
|
|
results.append({"ok": ok, "err": err, "errors": errors})
|
|
|
|
|
|
def _find_entry(root: Path, names: list[str]):
|
|
if (root / names[0]).exists():
|
|
return names[0]
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
for name in filenames:
|
|
if name in names:
|
|
return os.path.relpath(Path(dirpath) / name, root)
|
|
return None
|
|
|
|
|
|
def run_stability_check(root: Path, requirements_text: str) -> dict:
|
|
"""启动项目服务 → 压测基准/加压两阶段 → 返回稳定度指标"""
|
|
lang = _project_language(root)
|
|
env = {**os.environ, "PORT": "8321", "FLASK_RUN_PORT": "8321"}
|
|
interpreter = None
|
|
venv_python = root / ".venv" / "bin" / "python"
|
|
if venv_python.exists():
|
|
interpreter = str(venv_python)
|
|
elif shutil.which("python"):
|
|
interpreter = shutil.which("python")
|
|
start_cmd = None
|
|
if lang == "python" and interpreter:
|
|
entry = _find_entry(root, ["manage.py", "app.py", "main.py"])
|
|
if entry and entry == "manage.py":
|
|
start_cmd = [interpreter, "manage.py", "runserver", "0.0.0.0:8321"]
|
|
elif entry:
|
|
start_cmd = [interpreter, entry]
|
|
elif lang == "nodejs":
|
|
entry = _find_entry(root, ["server.js", "index.js", "app.js"])
|
|
if entry and shutil.which("node"):
|
|
start_cmd = ["node", entry]
|
|
|
|
if not start_cmd:
|
|
return {
|
|
"pass": None,
|
|
"reason": f"无法自动启动项目(语言: {lang}), 未执行压力测试",
|
|
"base_error_rate": None, "rampup_crash_rate": None, "total_requests": 0,
|
|
"details": {},
|
|
}
|
|
|
|
import subprocess
|
|
log_path = str(root / ".server.log")
|
|
try:
|
|
logfh = open(log_path, "ab")
|
|
server = subprocess.Popen(
|
|
start_cmd, cwd=str(root),
|
|
stdout=logfh, stderr=subprocess.STDOUT,
|
|
env=env,
|
|
start_new_session=True,
|
|
)
|
|
except OSError as exc:
|
|
return {"pass": None, "reason": f"启动失败: {exc}", "base_error_rate": None,
|
|
"rampup_crash_rate": None, "total_requests": 0, "details": {}}
|
|
|
|
base = "http://127.0.0.1:8321"
|
|
ready = False
|
|
for _ in range(30):
|
|
if _probe_server(base):
|
|
ready = True
|
|
break
|
|
time.sleep(1)
|
|
if not ready:
|
|
server.terminate()
|
|
return {
|
|
"pass": None, "reason": "服务 30s 内未就绪, 未执行压力测试",
|
|
"base_error_rate": None, "rampup_crash_rate": None, "total_requests": 0, "details": {},
|
|
}
|
|
|
|
try:
|
|
lock = threading.Lock()
|
|
n = settings.STABILITY_CONCURRENCY
|
|
base_res: list = []
|
|
threads = [threading.Thread(target=_stress_one, args=(base, settings.STABILITY_BASE_DURATION, base_res, lock)) for _ in range(n)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
ramp_res: list = []
|
|
threads = [threading.Thread(target=_stress_one, args=(base, settings.STABILITY_RAMPUP_DURATION, ramp_res, lock)) for _ in range(n + n // 2)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
base_ok = sum(r["ok"] for r in base_res)
|
|
base_err = sum(r["err"] for r in base_res)
|
|
ramp_ok = sum(r["ok"] for r in ramp_res)
|
|
ramp_err = sum(r["err"] for r in ramp_res)
|
|
base_total, ramp_total = base_ok + base_err, ramp_ok + ramp_err
|
|
base_error_rate = round(base_err / base_total, 4) if base_total else None
|
|
rampup_crash_rate = round(ramp_err / ramp_total, 4) if ramp_total else None
|
|
|
|
pass_flag = base_error_rate is not None and base_error_rate <= 0.01
|
|
if rampup_crash_rate is not None:
|
|
pass_flag = pass_flag and rampup_crash_rate <= 0.005
|
|
|
|
error_samples = []
|
|
for r in base_res + ramp_res:
|
|
error_samples.extend(r["errors"])
|
|
|
|
return {
|
|
"pass": pass_flag,
|
|
"requirements": requirements_text,
|
|
"base_error_rate": base_error_rate,
|
|
"rampup_crash_rate": rampup_crash_rate,
|
|
"total_requests": base_total + ramp_total,
|
|
"details": {
|
|
"base_total": base_total, "base_err": base_err,
|
|
"ramp_total": ramp_total, "ramp_err": ramp_err,
|
|
"error_samples": error_samples[:10],
|
|
},
|
|
}
|
|
finally:
|
|
import signal
|
|
try:
|
|
os.killpg(os.getpgid(server.pid), signal.SIGTERM)
|
|
except (ProcessLookupError, PermissionError):
|
|
server.terminate()
|
|
try:
|
|
server.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
try:
|
|
os.killpg(os.getpgid(server.pid), signal.SIGKILL)
|
|
except (ProcessLookupError, PermissionError):
|
|
server.kill()
|
|
|
|
|
|
def run_checks(archive_path: Path, spec: dict) -> tuple[dict, Path]:
|
|
"""对提交的项目包执行全部启用的检测, 返回 (report, project_root)"""
|
|
workdir = Path(settings.WORKSPACE_DIR) / f"{archive_path.stem}-{threading.get_ident()}"
|
|
workdir.mkdir(parents=True, exist_ok=True)
|
|
report = {
|
|
"language": None,
|
|
"stability": {"enabled": False},
|
|
"performance": {"enabled": False},
|
|
"security": {"enabled": False},
|
|
"functional": {"enabled": False, "points": []},
|
|
}
|
|
project_root = _extract(archive_path, workdir)
|
|
report["language"] = _project_language(project_root)
|
|
|
|
if spec.get("stability", {}).get("enabled"):
|
|
report["stability"] = {
|
|
"enabled": True,
|
|
"requirements": spec["stability"].get("requirements", ""),
|
|
**run_stability_check(project_root, spec["stability"].get("requirements", "")),
|
|
}
|
|
if spec.get("performance", {}).get("enabled"):
|
|
report["performance"] = {
|
|
"enabled": True,
|
|
"requirements": spec["performance"].get("requirements", ""),
|
|
"note": "性能检测依赖稳定度压测数据(P95 等), 当前版本在稳定度结果中体现",
|
|
}
|
|
if spec.get("security", {}).get("enabled"):
|
|
report["security"] = {
|
|
"enabled": True,
|
|
"requirements": spec["security"].get("requirements", ""),
|
|
"note": "安全检测当前未接入扫描器, 交由 AI 检测项覆盖",
|
|
}
|
|
func = spec.get("functional", {})
|
|
report["functional"] = {"enabled": bool(func.get("enabled")), "points": func.get("points", [])}
|
|
return report, project_root
|