guanghu-ice-heart/modules/guanghu-panel-kit/deploy/verify-and-deploy.py

104 lines
6.2 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# verify-and-deploy.py · 光湖子系统部署校验Agent · 可复用模块 v1.0
# 2026-08-06 · 铸渊 ICE-GL-ZY001 建 · 配置见同目录 deploy-config.json
# 流程: 取PR → 服务器地图 → 因果链 → 范围白名单 → 运行完整性 → 合并部署 → 同步/重启 → 回执
# ⊢ 任一步失败 = REJECTED 不部署 ⊢ 进度实时落盘,面板可见
import json, os, sys, subprocess, datetime, urllib.request, shutil, tempfile
CFG = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "deploy-config.json") if os.path.exists(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "deploy-config.json")) else os.path.join(os.path.dirname(os.path.abspath(__file__)), "deploy-config.json")))
PR = sys.argv[1] if len(sys.argv) > 1 else "?"
RUN_DIR = CFG["runs_dir"]; os.makedirs(RUN_DIR, exist_ok=True)
RUN = os.path.join(RUN_DIR, "RUN-PR%s-%s.json" % (PR, datetime.datetime.now().strftime("%Y%m%dT%H%M%S")))
TOKEN = open(CFG["token_file"]).read().strip()
API = CFG["api"]; REPO = CFG["repo"]; BARE = CFG["bare"]
def api(path, method="GET", body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(API + path, data=data, method=method)
req.add_header("Authorization", "token " + TOKEN)
req.add_header("X-Forwarded-Proto", "https")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, timeout=25) as r:
return json.loads(r.read().decode() or "{}")
def gitshow(rev, path):
r = subprocess.run(["git", "-C", BARE, "show", rev + ":" + path], capture_output=True, text=True)
return r.stdout if r.returncode == 0 else ""
state = {"pr": PR, "startedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), "status": "RUNNING", "steps": []}
def save():
tmp = RUN + ".tmp"; open(tmp, "w").write(json.dumps(state, ensure_ascii=False, indent=1)); os.replace(tmp, RUN)
def step(name, status, detail=""):
state["steps"].append({"name": name, "status": status, "detail": detail, "ts": datetime.datetime.now(datetime.timezone.utc).isoformat()}); save()
def finish(status, detail=""):
state["status"] = status; state["finishedAt"] = datetime.datetime.now(datetime.timezone.utc).isoformat(); state["detail"] = detail
save(); print(status, detail); sys.exit(0 if status == "DEPLOYED" else 1)
save()
try:
pr = api("/repos/%s/pulls/%s" % (REPO, PR))
head = pr["head"]["sha"]
files = api("/repos/%s/pulls/%s/files?limit=100" % (REPO, PR))
names = [f["filename"] for f in files]
step("FETCH_PR", "PASS", "PR#%s · head %s · %d 文件" % (PR, head[:8], len(names)))
ok = True; why = []
for m in CFG.get("map_checks", []):
p = m["path"]
if not os.path.isfile(p): ok = False; why.append("缺失 " + p); continue
if "contains" in m and m["contains"] not in open(p, errors="ignore").read(): ok = False; why.append(p + " 编号不符")
if ok: step("CHECK_MAP", "PASS", "服务器地图与策略在位且编号吻合")
else: step("CHECK_MAP", "FAIL", "; ".join(why)); finish("REJECTED", "地图校验失败")
causal_ok = False; causal_file = ""
for fn in names:
if any(fn.startswith(cp) for cp in CFG.get("causal_paths", [])):
txt = gitshow(head, fn)
if ("trigger" in txt and "why" in txt) or ("因果" in txt):
causal_ok = True; causal_file = fn; break
if causal_ok: step("CHECK_CAUSAL", "PASS", "因果链齐备 · " + causal_file)
else: step("CHECK_CAUSAL", "FAIL", "缺少因果链文档contributions/ 或 docs/ 下需含 trigger+why 或「因果」)"); finish("REJECTED", "因果链审核失败")
bad = [n for n in names if not n.startswith(tuple(CFG.get("allowed_prefix", [])))]
if bad: step("CHECK_SCOPE", "FAIL", "越界文件: " + ", ".join(bad[:5])); finish("REJECTED", "部署通道范围校验失败")
step("CHECK_SCOPE", "PASS", "全部文件在部署白名单内")
alive = True; dead = []
for u in CFG.get("services", []):
r = subprocess.run(["systemctl", "is-active", u], capture_output=True, text=True)
if r.stdout.strip() != "active": alive = False; dead.append(u)
if alive: step("CHECK_INTEGRITY", "PASS", "现有服务全部在线,部署不破坏运行")
else: step("CHECK_INTEGRITY", "FAIL", "服务异常: " + ", ".join(dead)); finish("REJECTED", "完整性校验失败")
m = api("/repos/%s/pulls/%s/merge" % (REPO, PR), method="POST", body={"Do": "merge", "merge_title_message": "deploy: PR#%s 审批部署校验Agent全过" % PR})
step("DEPLOY_MERGE", "PASS", "已合并入 deploy 分支")
synced = []
for st in CFG.get("sync_trees", []):
sp, tgt = st["source_prefix"], st["target"]
hit = [n for n in names if n.startswith(sp)]
if not hit: continue
tmpd = tempfile.mkdtemp()
subprocess.run(["git", "-C", BARE, "archive", head], stdout=open(tmpd + "/a.tar", "wb"))
subprocess.run(["tar", "-xf", tmpd + "/a.tar", "-C", tmpd])
for n in hit:
src = os.path.join(tmpd, n); rel = n[len(sp):]
dst = os.path.join(tgt, rel)
if os.path.isfile(src):
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(src, dst)
synced.append(n)
shutil.rmtree(tmpd, ignore_errors=True)
if synced:
step("SYNC_CUSTOM", "PASS", "同步 %d 文件到运行目录" % len(synced))
for u in CFG.get("restart_services", []):
subprocess.run(["systemctl", "restart", u])
step("RESTART", "PASS", "已重启: " + ", ".join(CFG.get("restart_services", [])))
else:
step("SYNC_CUSTOM", "PASS", "本次无需同步运行目录")
rcpt = {"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(), "type": "DEPLOY_RECEIPT", "pr": PR, "head": head, "run": os.path.basename(RUN), "verdict": "DEPLOYED", "synced": synced}
open(os.path.join(CFG["receipts_dir"], "DEPLOY-%s.json" % datetime.datetime.now().strftime("%Y%m%dT%H%M%S")), "w").write(json.dumps(rcpt, ensure_ascii=False, indent=1))
finish("DEPLOYED", "部署完成 · 100 · 回执已生成")
except Exception as e:
step("ERROR", "FAIL", str(e)[:200]); finish("REJECTED", "异常: " + str(e)[:120])