diff --git a/deploy/ddiff.py b/deploy/ddiff.py index 838b224..a640dc7 100755 --- a/deploy/ddiff.py +++ b/deploy/ddiff.py @@ -87,27 +87,31 @@ def compare(base, target): bc, tc = set(base["cols"]), set(target["cols"]) from collections import defaultdict - miss_cols = defaultdict(list) - for c in bc - tc: - p = c.split("|") - if len(p) == 3: - miss_cols[p[0]].append(p[1] + " " + p[2]) - print("\n── 缺列 [%d 表] %s" % (len(miss_cols), "【需迁移】" if miss_cols else "")) - for t in sorted(miss_cols): - print(" %s: %s" % (t, ", ".join(sorted(miss_cols[t])))) - # 列宽/类型不同(同名不同型) + # 列类型不同(同名不同型)先算出来——这些不算"缺列",避免重复报告 base_colmap = {} for c in base["cols"]: p = c.split("|") if len(p) == 3: base_colmap[(p[0], p[1])] = p[2] - type_diff = [] + target_colmap = {} for c in target["cols"]: p = c.split("|") - if len(p) == 3 and (p[0], p[1]) in base_colmap: - bt2 = base_colmap[(p[0], p[1])] - if bt2 != p[2]: - type_diff.append("%s.%s: 对照=%s 本环境=%s" % (p[0], p[1], bt2, p[2])) + if len(p) == 3: + target_colmap[(p[0], p[1])] = p[2] + type_diff = [] + for (t, col), bt2 in base_colmap.items(): + if (t, col) in target_colmap and target_colmap[(t, col)] != bt2: + type_diff.append("%s.%s: 对照=%s 本环境=%s" % (t, col, bt2, target_colmap[(t, col)])) + type_diff_keys = set((d.split(".")[0], d.split(".")[1].split(":")[0]) for d in type_diff) + + miss_cols = defaultdict(list) + for c in bc - tc: + p = c.split("|") + if len(p) == 3 and (p[0], p[1]) not in type_diff_keys: + miss_cols[p[0]].append(p[1] + " " + p[2]) + print("\n── 缺列 [%d 表] %s" % (len(miss_cols), "【需迁移】" if miss_cols else "")) + for t in sorted(miss_cols): + print(" %s: %s" % (t, ", ".join(sorted(miss_cols[t])))) print("\n── 列类型不同 [%d] %s" % (len(type_diff), "【需迁移】" if type_diff else "")) for d in type_diff: print(" ", d) diff --git a/deploy/smoke/cases.json b/deploy/smoke/cases.json new file mode 100644 index 0000000..bcd767d --- /dev/null +++ b/deploy/smoke/cases.json @@ -0,0 +1,34 @@ +{ + "_说明": "冒泡测试用例——增量部署后关键功能点验收。runner: deploy/dsmoke.py。auth=login 的用例先登录拿会话。expect: status 状态码 / contains 响应含串 / not_contains 不含串 / json_field 存在字段。凭据从环境变量 SMOKE_USER/SMOKE_PASS 读(不入库)。", + "base": "", + "cases": [ + {"id": "s01", "name": "健康检查", "url": "/", "auth": "none", + "expect": {"status": 200}}, + {"id": "s02", "name": "登录", "url": "/rbac/user/up_login.dspy", "method": "POST", + "body": {"username": "${SMOKE_USER}", "password": "***"}, "auth": "none", + "expect": {"status": 200}}, + {"id": "s03", "name": "主页壳", "url": "/", "auth": "login", + "expect": {"status": 200, "contains": "产线平台"}}, + {"id": "s04", "name": "agent菜单", "url": "/pipeline_core/api/agent_menus.dspy", "auth": "login", + "expect": {"status": 200, "contains": "工作空间"}}, + {"id": "s05", "name": "商机产线壳", "url": "/pipeline-opportunity/agent/index.ui", "auth": "login", + "expect": {"status": 200, "contains": "商机产线"}}, + {"id": "s06", "name": "投标产线壳", "url": "/pipeline-bidding/agent/index.ui", "auth": "login", + "expect": {"status": 200, "contains": "投标产线"}}, + {"id": "s07", "name": "开发产线壳", "url": "/pipeline-sdlc/agent/index.ui", "auth": "login", + "expect": {"status": 200}}, + {"id": "s08", "name": "运营产线壳", "url": "/pipeline-ops/agent/index.ui", "auth": "login", + "expect": {"status": 200}}, + {"id": "s09", "name": "营销产线壳", "url": "/pipeline-dist/agent/index.ui", "auth": "login", + "expect": {"status": 200}}, + {"id": "s10", "name": "商机数据参考(爬虫平台连通)", "url": "/pipeline-opportunity/api/opp_references_popup.dspy", "auth": "login", + "expect": {"status": 200}}, + {"id": "s11", "name": "项目管理列表", "url": "/pipeline_core/api/agent_menu_open.dspy", "method": "POST", + "body": {"menu": "workspace"}, "auth": "login", + "expect": {"status": 200}}, + {"id": "s12", "name": "待办", "url": "/pipeline_core/api/human_todo_list.dspy", "auth": "login", + "expect": {"status": 200}}, + {"id": "s13", "name": "i18n资源", "url": "/i18n/zh/i18n.json", "auth": "none", + "expect": {"status": 200, "contains": "产线"}} + ] +} diff --git a/deploy/smoke/run_smoke.py b/deploy/smoke/run_smoke.py new file mode 100644 index 0000000..c35764a --- /dev/null +++ b/deploy/smoke/run_smoke.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""run_smoke.py — 增量部署冒泡测试执行器。 + +用法(在应用根目录,凭据走环境变量或参数,不落库): + SMOKE_USER=admin SMOKE_PASS=*** ./py3/bin/python deploy/smoke/run_smoke.py + ./py3/bin/python deploy/smoke/run_smoke.py --base http://127.0.0.1:9090 --user admin --pass *** + ./py3/bin/python deploy/smoke/run_smoke.py --https https://doit.opencomputing.cn # 走外网域名 + +退出码:0=全部通过;1=有用例失败。增量部署第3步验证必跑。 +""" + +import json +import os +import sys +import ssl +import urllib.request +import urllib.error +import urllib.parse + + +HERE = os.path.dirname(os.path.abspath(__file__)) +CASES = os.path.join(HERE, "cases.json") + + +def req(opener, base, case, session_cookie, verify_ssl): + url = base.rstrip("/") + case["url"] + method = case.get("method", "GET").upper() + body = case.get("body") + data = None + headers = {} + if body is not None: + # 变量替换 + b = {} + for k, v in body.items(): + if isinstance(v, str): + v = v.replace("${SMOKE_USER}", os.environ.get("SMOKE_USER", "")) + v = v.replace("${SMOKE_PASS}", os.environ.get("SMOKE_PASS", "")) + b[k] = v + data = json.dumps(b).encode("utf-8") + headers["Content-Type"] = "application/json" + if case.get("auth") == "login" and session_cookie: + headers["Cookie"] = session_cookie + r = urllib.request.Request(url, data=data, method=method, headers=headers) + ctx = None + if url.startswith("https"): + ctx = ssl.create_default_context() + if not verify_ssl: + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + try: + resp = opener.open(r, timeout=20, context=ctx) if ctx else opener.open(r, timeout=20) + status = resp.getcode() + text = resp.read().decode("utf-8", errors="replace") + return status, text, resp.headers, None + except urllib.error.HTTPError as e: + return e.code, (e.read() or b"").decode("utf-8", errors="replace"), e.headers, None + except Exception as e: + return 0, "", None, str(e) + + +def main(): + base = "http://127.0.0.1:9090" + verify_ssl = True + args = sys.argv[1:] + if "--https" in args: + base = args[args.index("--https") + 1] + verify_ssl = False # 自签/内网域名常见 + elif "--base" in args: + base = args[args.index("--base") + 1] + if "--user" in args: + os.environ["SMOKE_USER"] = args[args.index("--user") + 1] + if "--pass" in args: + os.environ["SMOKE_PASS"] = args[args.index("--pass") + 1] + + cfg = json.load(open(CASES)) + cases = cfg["cases"] + + import http.cookiejar + cj = http.cookiejar.CookieJar() + opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) + + # 第一步:登录拿会话 + session_cookie = None + login_case = None + for c in cases: + if c.get("auth") == "none" and c.get("method", "GET") == "POST" and "login" in c["url"]: + login_case = c + break + if login_case and os.environ.get("SMOKE_PASS"): + status, text, headers, err = req(opener, base, login_case, None, verify_ssl) + # 从 cookiejar 提取会话名 + names = [] + for ck in cj: + names.append("%s=%s" % (ck.name, ck.value)) + session_cookie = "; ".join(names) + if status != 200: + print("[LOGIN-FAIL] 登录用例返回 %s,后续需登录的用例将跳过: %s" % (status, (err or text[:100]))) + + passed, failed, skipped = [], [], [] + for c in cases: + cid, name = c["id"], c["name"] + if c.get("auth") == "login" and not session_cookie: + skipped.append(cid) + print(" SKIP %-4s %s(无登录态)" % (cid, name)) + continue + status, text, headers, err = req(opener, base, c, session_cookie, verify_ssl) + exp = c.get("expect", {}) + ok = True + reason = [] + if err: + ok = False + reason.append("请求异常:" + err[:80]) + if "status" in exp and status != exp["status"]: + ok = False + reason.append("状态 %s≠%s" % (status, exp["status"])) + if "contains" in exp and exp["contains"] not in text: + ok = False + reason.append("缺内容:%s" % exp["contains"][:20]) + if "not_contains" in exp and exp["not_contains"] in text: + ok = False + reason.append("含禁止内容:%s" % exp["not_contains"][:20]) + if ok: + passed.append(cid) + print(" PASS %-4s %s" % (cid, name)) + else: + failed.append(cid) + print(" FAIL %-4s %s [%s]" % (cid, name, "; ".join(reason))) + + print("\n══ 冒泡结果: %d 通过 / %d 失败 / %d 跳过(共 %d)══" + % (len(passed), len(failed), len(skipped), len(cases))) + sys.exit(0 if not failed else 1) + + +if __name__ == "__main__": + main()