# -*- coding: utf-8 -*- """ 用 `test` profile 真起一次 —— **CI 用的就是这个 profile**。 ## 为什么单独一个检查 `application-test.yml` 是流水线里 `e2e` 阶段真正加载的配置,而它是"新写的配置"。 **新写的配置从没跑起来过**,正是流水线第一次上线时最典型的红法 —— 而且往往红在一个拼错的属性名上,排查半天。 所以这里用真实的环境变量起一次,确认三件事: ① 能起来;② 迁移跑得过;③ `/actuator/health` 可用。 ## 它不验什么 不验任何业务 —— 业务由 `scripts/e2e-*.py` 打真实接口验。 这里只回答一个问题:**"CI 那套配置能不能把服务拉起来"**。 ## 两个"时好时坏"的坑(都踩过) 1. **就绪判据不能用日志行**:日志行会随版本变(改个措辞就失效), 而且子进程输出走管道时是块缓冲的,`readline()` 一阻塞就轮不到超时判断。 所以判据是 **HTTP 探活**,并且子进程输出**落盘**(`backend/_test-profile.txt`)—— 失败时能看见它停在哪一行,而不是只剩一句"180 秒没起来"。 2. **随机端口带来一个反直觉的副作用**:残留 JVM 永远不会撞端口,于是会静静攒着, 直到某一次"怎么都起不来"。所以开跑前按 jar + profile 清残留(**保留 dev 后端**)。 跑法:python scripts/check-test-profile.py (前提:本地 PG/Redis 在跑、已 `mvn package`;可用 `XT_TEST_PROFILE_WAIT` 调超时) """ 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 preclean_stale_jvms(): """ 开跑前清掉**本项目的**残留 JVM(只留 dev 那个)。 ⚠️ 为什么 preclean_port 不够:本脚本用的是随机端口,所以残留 JVM **永远撞不到** 这个端口,而它们会一直占着内存与 CPU —— 表现是"这次怎么 180 秒都没起来", 下一次又好了(**时好时坏正是资源被占的特征**)。实测就是这样: 连着跑几轮之后本机攒了 5~7 个 java,之后 JVM 启动从 10 秒变成起不来。 判据要窄,否则会误杀: · 命令行里必须是**本项目的 jar**(别的项目的 java 一律不碰); · **必须留 dev**(`--spring.profiles.active=dev`)—— E2E 正打着它, 顺手杀掉等于把后面的检查全废掉。 """ try: ps = ("Get-CimInstance Win32_Process -Filter \"Name='java.exe'\" | " "ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }") r = subprocess.run(["powershell", "-NoProfile", "-Command", ps], capture_output=True, text=True, timeout=60) except Exception: # noqa: BLE001 return for line in (r.stdout or "").split("\n"): if "\t" not in line: continue pid, cmd = line.split("\t", 1) pid = pid.strip() if not pid.isdigit() or "xt-server" not in cmd: continue if "--spring.profiles.active=dev" in cmd: print(" (保留 dev 后端 pid %s —— E2E 要用它)" % pid) continue subprocess.run(["taskkill", "/F", "/T", "/PID", pid], capture_output=True, timeout=20) print(" ⚠️ 清掉残留 JVM pid %s(上一次跑 test/prodguard 留下的)" % pid) 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_stale_jvms() preclean_port(PORT) # ⚠️ 子进程输出**必须落盘**,不能用 PIPE: # ① 没人读 PIPE 时,管道缓冲区(Windows 约 64KB)写满就会**把 JVM 阻塞在写日志上** —— # 表现是"启动到一半不动了、进程还活着",与配置对不对毫无关系; # ② 失败时想读那段输出,而它要么还在缓冲区里、要么已被 kill 截断, # 于是只剩下一句"180 秒内没探活成功"这种没法排查的结论。 # 落盘之后才能回答"它到底停在哪一行"。文件名用 .txt 是有意的: # .gitignore 里 `backend/_*.txt` 已经忽略它。 # 实测基线:正常启动 ~10 秒(72 行 / 9.5KB 日志)—— # 哪天变成几十秒,先怀疑本机负载与残留 JVM,而不是配置。 LOG = os.path.join(ROOT, "backend", "_test-profile.txt") logf = open(LOG, "w", encoding="utf-8", errors="replace") started = time.time() p = subprocess.Popen( ["java", "-jar", JAR, "--spring.profiles.active=test", "--server.port=%d" % PORT], cwd=os.path.join(ROOT, "backend"), env=env, stdout=logf, stderr=subprocess.STDOUT) def log_lines(): """读日志文件当前的内容(进程还开着也能读)。""" try: logf.flush() with open(LOG, encoding="utf-8", errors="replace") as f: return [x.rstrip() for x in f if x.strip()] except Exception: # noqa: BLE001 return [] ok = False reason = "" body = "" try: # 就绪判据是 **HTTP 探活**,不是"日志里出现了某一行": # 日志行会随版本变(改个措辞就失效),而"接口能回答"才是这件事本身。 WAIT = float(os.environ.get("XT_TEST_PROFILE_WAIT", "180")) deadline = time.time() + WAIT next_report = time.time() + 15 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) # 每 15 秒报一次"等到哪一步了" —— 卡住时最需要的是进度,不是沉默 if time.time() >= next_report: next_report = time.time() + 15 tail = log_lines() print(" …… 已等 %ds,日志停在:%s" % ( int(time.time() - started), tail[-1][:110] if tail else "(还没有输出)")) time.sleep(1) if not ok and not reason: reason = "%d 秒内没探活成功" % int(WAIT) finally: # ⚠️ 必须杀**进程树**(Windows 用 taskkill /T):JVM 可能带子进程, # 而 p.kill() 只杀直接子进程。**失败的运行恰恰是最需要清理的那些。** kill_tree(p) try: p.wait(timeout=15) except Exception: # noqa: BLE001 pass logf.close() text = "\n".join(log_lines()) if ok: took = time.time() - started print("健康检查:", body[:200]) print("启动用时:%.1f 秒(基线约 10 秒)" % took) if took > 60: print(" ⚠️ 比基线慢很多 —— 大概率是**本机负载或残留 JVM**,不是配置问题") print(" 查残留:Get-CimInstance Win32_Process -Filter \"Name='java.exe'\"") print("\n结果:test profile **能起来**") else: print("\n结果:test profile **起不来**") print(" 原因:" + reason) print(" 完整日志:%s(%d 行)" % (os.path.relpath(LOG, ROOT), len(log_lines()))) if "already in use" in text or ("Port" in text and "in use" in text): print(" ⚠️ 端口被占 —— 这是**环境问题**,不是配置问题") # 关键字先扫一遍:起不来时真正的原因往往只有一行,而尾部 30 行可能全是无关的重试 for kw in ("APPLICATION FAILED TO START", "Caused by", "ERROR", "Waiting", "lock", "Flyway", "migration", "Cannot", "Exception"): for ln in text.split("\n"): if kw in ln: print(" [%s] %s" % (kw, ln.strip()[:150])) break print("---- 日志尾部 20 行 ----") for ln in log_lines()[-20:]: print(" " + ln[:150]) sys.stdout.flush() raise SystemExit(0 if ok else 1)