30c3714d35
新增:check-api-parity(契约 121 接口 ↔ 控制器,自带「必须恰好 121」自检)、 check-entity-columns(基类要求的列 vs 迁移建表列,F-35 那个坑踩了四次之后上的工具)、 check-manual(手册引用的错误码与提示文案对得上代码)、check-test-profile(test profile 真起一次)、 e2e-prodguard、load-test(四场景、按端点分别统计)、verify-all(一条命令跑完交付自检)、 dev-seed-audit(界面验收用的演示数据),以及 M1~M9 各轮 E2E。 压测抓到一个真缺陷并修掉(F-92):门禁 IP 限流原来计「所有请求」,于是一个班连同一个 WiFi 时 60 个不同学生同时核验只过了 20 个 → 改为只计失败 + 单独的成功上限,20/60 → 60/60。 度量工具的偏差会伪装成产品缺陷(F-60/F-77/F-97),所以每个脚本都断言「动作自己的结论字段」 (submitted / created / canExport),而不是 HTTP 200 + code 0。 验证:verify-all.py --quick 5/5;--with-e2e 见 docs/09 §8。
175 lines
8.0 KiB
Python
175 lines
8.0 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
用 `test` profile 真起一次 —— **CI 用的就是这个 profile**。
|
||
|
||
## 为什么单独一个检查
|
||
|
||
`application-test.yml` 是流水线里 `e2e` 阶段真正加载的配置,而它是"新写的配置"。
|
||
**新写的配置从没跑起来过**,正是流水线第一次上线时最典型的红法 ——
|
||
而且往往红在一个拼错的属性名上,排查半天。
|
||
|
||
所以这里用真实的环境变量起一次,确认三件事:
|
||
① 能起来;② 迁移跑得过;③ `/actuator/health` 可用。
|
||
|
||
## 它不验什么
|
||
|
||
不验任何业务 —— 业务由 `scripts/e2e-*.py` 打真实接口验。
|
||
这里只回答一个问题:**"CI 那套配置能不能把服务拉起来"**。
|
||
|
||
跑法:python scripts/check-test-profile.py
|
||
(前提:本地 PG/Redis 在跑、已 `mvn package`)
|
||
"""
|
||
import base64
|
||
import os
|
||
import re
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import urllib.request
|
||
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
RSA_KEY = 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCeHytLx+201AZKhds9B+3LWoVYDJisyimtysGpGip6JLT3iiB2tpK2QL4OAopI/8gaY1iyoxmxU5fUEWa+mPspszciNot4bAdDRZMiCkGOj0zEKJRrTDctnvLVyB6lSd89FX8yD/LS131Big57u9Sk5BYCMO9ZhgCrN1p1qbf/FoJ16zZ3NWSLtGdqVUx2BQjnSRp4Rzf8fq/49MWSXnBmsVm543B85mZMARp24rZ5SwpMwhtrAfVihwqkfaWAfu51oiIwjf0Q8s/wsfth60WoWipG56onwuPtSECKW/Weg5SUQiwoj95iWae+yQFGXRy7hkjk3FhmQArhoK3jiJbrAgMBAAECggEAA3AoD9jECWeGz34Dm1sdx4srVRPJChDHQTkkZtnjzI4ZxTwiopIrtHzJ5SIXAalWH4TDe/bh6yInLfxG4KQQ6gI0k7Dd7Xtj2QmdPmM8s63X/Hfpaj/Y/0vyecWFLRDjIr/h6SPBouX+mjs3P2eBIyi2dcu8mJS6VP66H87V6lz5LyPraFC3ocXTMCabJh+i/Xy4EqV26Mhv+kSrE0CLB2ngfQW98+RgPo5Ndk/A826eGVA16tKsylcTfq3SB/yMSy1xHv/p/IF+PK+Twl3GmJB2jf/dzZpteJIZrPRz+AJ9VJQa35/Chre57JFcA50Avkf5iySzzVfYZDXd0qk6GQKBgQDMD3opopECI5lo21p9+KUZ6poW8iO/Yp2FNYBldMnk+oHcCL6H92dGIZNu0mvcumjEstKH4X9OoI5qV2FFMUrFc6E7NfXWNKOj+eJtLn3WzNRJp6n4x8W8AEeAXzHuv8IbSlG8nsG/g49Tdnaxr2y/iWNj1qGnTfaSyhGRRh133wKBgQDGXla5EmtD9TpCpl7/OSWG8qzv4GfRTaVjC+qz2KnfU5cqiFKLK27uxkP9F8Kdh6EkvOltRA8024FeafpLgTjH3fEKHFywwXSVvssp94IZdoyuxrAqMccReE7akS6IImawm8fu9U0bAwi7WShZHaw2hS9ScM/9cATkDRfRm0DydQKBgHtcQ+rVlzom0UAdk2giH5U6SSrj+Q5jDH0SKeurnb3qXwTo2F4UB3/THnDZjlFi86Etxa5VeQPMdiv9ZRVSRjR1wZus9FAphF/F2cdNWs+3BgL88/kss/ICbTdC1UnS7QJYfG89vj0LyGKZdAnY1jI4WThq8VDWBeEZfWNIctobAoGBAJMoZRmCqoSNpZzQI2UfFdHYsaZ6SUoiWfDyZDjrIIoGTvxEiUeo/vCvsLxPJhcLCOZiJqDifo1t0bki0YW15VOQOEKR68lMmpqEId+ON7uAELmPuBx1NQKmx4ipb2Wab6fQyS9iVsMVKyMg7ye9NYZ0ozh7bDDNKzOq6uKaVEIJAoGAbulymgadkLNVksxPW0O0qIlLVfaOzOeDLGi6ZO4ZBt9wyKgbyMcfwjvuylerkLF/zZqLQ79sOd4JO15ssqHK/YVKiON/A7pDDgxAJpOXxGxy9RXpZWhHL1KBjI9Gcq0gyMSnQbiwpB1Wx3qmjNGDynclW1kNFCQKQpW9xTs4NI0='
|
||
JAR = os.path.join(ROOT, "backend", "xt-server", "target", "xt-server-1.0.0-SNAPSHOT.jar")
|
||
|
||
env = dict(os.environ)
|
||
env.update({
|
||
# 指到本地已有的库(与 dev 用的是同一个库,只验"能不能起来")
|
||
"PG_HOST": "localhost", "PG_PORT": "5432", "PG_DB": "filldb",
|
||
"PG_USER": "fill", "PG_PASSWORD": "fill_dev_pwd",
|
||
"REDIS_HOST": "localhost", "REDIS_PORT": "16379", "REDIS_PASSWORD": "redis_dev_pwd",
|
||
# ⚠️ 密钥一律用 base64(os.urandom(n)) 生成 —— 手写字面量连错两次:
|
||
# 一次是 AES 少了 2 字节,一次是 HMAC 那串根本不是合法 base64
|
||
"XT_JWT_SECRET": base64.b64encode(os.urandom(64)).decode(),
|
||
"XT_AES_KEY": base64.b64encode(os.urandom(32)).decode(),
|
||
"XT_HMAC_KEY": base64.b64encode(os.urandom(48)).decode(),
|
||
"XT_RSA_PRIVATE_KEY": RSA_KEY,
|
||
})
|
||
|
||
|
||
def kill_tree(proc):
|
||
"""
|
||
杀掉整个进程树(Windows 用 taskkill /T)。
|
||
|
||
⚠️ 不能用 `proc.kill()`:它只杀直接子进程,而 JVM 可能带着子进程;
|
||
更重要的是——**外层命令被超时掐掉时 finally 不一定会跑**,
|
||
所以残留会积累。用 `/T` 至少把"能跑到 finally"的那些清干净,
|
||
再靠下面的 preclean_port 兜住剩下的。
|
||
"""
|
||
if proc is None or proc.poll() is not None:
|
||
return
|
||
try:
|
||
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
||
capture_output=True, timeout=20)
|
||
except Exception:
|
||
try:
|
||
proc.kill()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def preclean_port(port):
|
||
"""
|
||
开跑前清掉这个端口上的残留 java。
|
||
|
||
⚠️ 为什么必须有它:脚本用的是**随机空闲端口**,所以下一次跑**不会撞端口** ——
|
||
残留的 JVM 于是静静地占着内存与 CPU,只在某次"起 jar 超时"时才隐约暴露。
|
||
不清端口 = 残留永远不会被发现。
|
||
"""
|
||
try:
|
||
r = subprocess.run(
|
||
["powershell", "-NoProfile", "-Command",
|
||
"(Get-NetTCPConnection -State Listen -LocalPort %d -ErrorAction SilentlyContinue)"
|
||
".OwningProcess" % port],
|
||
capture_output=True, text=True, timeout=30)
|
||
for line in (r.stdout or "").split():
|
||
pid = line.strip()
|
||
if pid.isdigit():
|
||
subprocess.run(["taskkill", "/F", "/T", "/PID", pid],
|
||
capture_output=True, timeout=20)
|
||
print(" ⚠️ 端口 %d 上有残留进程 %s,已清掉" % (port, pid))
|
||
except Exception:
|
||
pass
|
||
|
||
def free_port() -> int:
|
||
"""
|
||
要一个空闲端口。
|
||
|
||
⚠️ 不要写死端口:写死的话**第二次跑就撞端口**,而报的错
|
||
(`Port xxx was already in use`)跟"配置对不对"毫无关系 ——
|
||
一个只能跑一次的检查等于没有检查。
|
||
"""
|
||
with socket.socket() as s:
|
||
s.bind(("127.0.0.1", 0))
|
||
return s.getsockname()[1]
|
||
|
||
|
||
|
||
PORT = free_port()
|
||
print("用端口 %d(每次随机取,避免重复跑时撞端口)" % PORT)
|
||
preclean_port(PORT)
|
||
|
||
p = subprocess.Popen(
|
||
["java", "-jar", JAR, "--spring.profiles.active=test",
|
||
"--server.port=%d" % PORT],
|
||
cwd=os.path.join(ROOT, "backend"), env=env,
|
||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||
text=True, encoding="utf-8", errors="replace")
|
||
|
||
ok = False
|
||
reason = ""
|
||
body = ""
|
||
try:
|
||
# ⚠️ 就绪判据是 **HTTP 探活**,不是"日志里出现了某一行":
|
||
# 子进程 stdout 在管道里是**块缓冲**的,启动日志常常不足一个缓冲区,
|
||
# 于是一行都读不到;而且 readline() 一旦阻塞,超时的判断就轮不到。
|
||
# 实测:单独跑能过、紧跟在几个 JVM 之后跑就失败 —— 时好时坏正是它的特征。
|
||
deadline = time.time() + 180
|
||
while time.time() < deadline:
|
||
if p.poll() is not None:
|
||
reason = "进程自己退了(退出码 %s)" % p.returncode
|
||
break
|
||
try:
|
||
with urllib.request.urlopen(
|
||
"http://localhost:%d/actuator/health" % PORT, timeout=3) as r:
|
||
body = r.read().decode()
|
||
ok = True
|
||
break
|
||
except Exception as e: # noqa: BLE001
|
||
reason = str(e)
|
||
time.sleep(1)
|
||
if not ok and not reason:
|
||
reason = "180 秒内没探活成功"
|
||
finally:
|
||
# ⚠️ 必须杀**进程树**(Windows 用 taskkill /T):JVM 可能带子进程,
|
||
# 而 p.kill() 只杀直接子进程。**失败的运行恰恰是最需要清理的那些。**
|
||
kill_tree(p)
|
||
try:
|
||
p.wait(timeout=15)
|
||
except Exception:
|
||
pass
|
||
|
||
if ok:
|
||
print("健康检查:", body[:200])
|
||
print("\n结果:test profile **能起来**")
|
||
else:
|
||
print("\n结果:test profile **起不来**")
|
||
print(" 原因:" + reason)
|
||
try:
|
||
text = (p.stdout.read() or "") if p.stdout else ""
|
||
except Exception:
|
||
text = ""
|
||
if "already in use" in text or ("Port" in text and "in use" in text):
|
||
print(" ⚠️ 端口被占 —— 这是**环境问题**,不是配置问题")
|
||
for kw in ("ERROR", "Caused by", "Failed", "migration", "Flyway", "Cannot"):
|
||
for ln in text.split("\n"):
|
||
if kw in ln:
|
||
print(" " + ln.strip()[:160])
|
||
break
|
||
print("---- 尾部 ----")
|
||
print(text[-1500:])
|
||
|
||
sys.stdout.flush()
|
||
raise SystemExit(0 if ok else 1)
|