基底:#251 头 174b631(堆叠于 #242 feat-ci-expand 之上), 基线 origin/main aebc6238;本轨不改 #251 已定语义、不动 workflow 门槛、不删检查、不加 skip。 lint(ruff E,F@120):109 -> 10 - F401 43 -> 0:删未用导入;try/except 双绑定的名字出现 “打地鼠”(删 except 分支后 try 分支又被标出),两分支 均已核对正文无引用后清掉;MCP 三模块的 Error 名不删, 改走 importlib 属性绑定(见下),两路径绑定都保留。 - E702 40 -> 0:全在 host.py main() 的 argparse 段,纯分行, tokenize 按分号 token 拆(含 2377 行多行语句只断首个分号); 一次 5 空格缩进失误已回滚重做并以 ast + 全量 host 测试验证。 - E501 13 -> 0:host.py 5 项随 E702 分行连带清掉;mcp_server 3 项把描述拆成相邻字面量(AST 比对 24 条工具描述与原值 完全一致,注意 ruff 按东亚宽度计长,CJK 算 2);test_host 5 项断开 git commit 参数表。 - E402 8 -> 10:生产侧 2 项清掉(db.py _re、github_app _base64 移到顶部,之前无引用、无重名);剩余 10 项全是 tests/ 的 sys.path 前置 import(6 项旧 + 4 个新测试文件 沿用同一既定模式),移上去会破坏单文件直跑,有意保留。 - F841 4 -> 0:只去绑定、不删调用(run_task、DB 入队、 tarfile 上下文、git 提交),各有注释与原测试/新测试覆盖; host dispatch_from_branch 新增真实 git 仓库回归测试。 - F811 1 -> 0:server 本地 _row_get 与 peer_messages.row_get 逐行相同,删本地定义,全部调用点解析到同一 import; 新增 test_server_row_get(同一对象 + 三路行为)。 mypy(生产范围):24 -> 0,零新增 type: ignore - no-redef 9 -> 0:MCP 三模块 try/except 两次 from-import 改 importlib.import_module + 属性绑定(cloud 优先、flat copy 回落、内层 ImportError 同样回落,语义与原来一致; flat-copy 子进程测试证实回落后 __module__ 为顶层名); server _row_get 随 F811 消除。 - arg-type 10 -> 0:harness2_tasks shared 改 TypedDict 逐键精确类型;CodexProbeAdapter 补 no-op validate_tree (_adapter_validate 本就跳过缺失方法,行为一致)。 - var-annotated 3 -> 0:sub2api _SHARED_ADMIN/_login_slots、 verify-multiuser RESULTS 加精确注解。 - assignment/misc 2 -> 0:marker_tools ZoneInfo 先声明 Any 再 try import(草稿先验 mypy 通过)。 bandit:总数 286 不变,High 仍 4 项,全在 host.py,本轨不改 (属第 3 项白名单轨范围),只做 triage: - 1319 run_verify:无插值,整串验测命令,管道/&& 须 shell 语义(新增 test_host_run_verify 断言管道与 && 真实生效)。 - 1497 notify:模板来自算子配置,三处插值全部 shlex.quote, 证据为既有 test_notify_failure_quotes_shell_metacharacters (; touch 与 $(touch ) 不执行)。 - 2099/2103 goal verify:无插值,任务配置验测串,同 run_verify。 - 无新增 High;pip-audit 0 漏洞。 覆盖率(同口径三套件 coverage 计量):75.96% -> 77.87% (22392 行/缺 5385 -> 22437 行/缺 4963,显示 76% -> 78%) - client.py 0% -> 90%(pipe 假子进程 + main 分支) - run_parallel.py 0% -> 97%、migrate 包装 0% -> 95% - playwright_mcp 37% -> 62%、imagegen_mcp 47% -> 79%、 websearch_mcp 51% -> 86%、deploy.py 56% -> 77% - verify-multiuser 0% -> 27%(仅 check/req helper;main() 是主机侧真实多用户实弹验证,按门禁不建生产任务、 不以 mock 冒充,仍为缺口) - host.py 55% -> 57%(文件 2020 -> 2060 行系 E702 分行 的计量口径变化,非代码膨胀) - 未到 85%:门槛保持 75% 不动,不删低覆盖模块出统计口径; 缺口主力仍是 server.py / worker.py / host.py 大文件, 下一阶段须针对性深测,非本轨快赢可及。 验证:三套件 1576 -> 1629 项全过(tests 127、cloud/tests 1449 skipped=4、deploy/tests 53);ruff E9 通过;mypy 0; bandit High 4(同上);pip-audit 0 漏洞;试跑产物已删, 提交后工作树干净。
518 lines
24 KiB
Python
518 lines
24 KiB
Python
#!/usr/bin/env python3
|
||
"""统一发布入口:取代手工的 deploy.sh + scp_run + chunk_send + grep 验真。
|
||
|
||
为什么需要它(此前三次栽过的坑,见 deploy/README.md):
|
||
1. 通用 tar 路径 /tmp/codex-app-server-code.tar.gz:一次 scp 失败,
|
||
旧包还躺在原路径,下一步手工跑主机脚本就把旧代码“部署成功”了。
|
||
2. 传输不验 sha:scp 断在半路、chunk 首片写反,主机都可能拿到错包。
|
||
3. 主机脚本多份:deploy.sh 内嵌版、deploy_*_host.sh 各自演进,行为漂移。
|
||
4. health 通过不等于新代码上线:旧代码的 health 照样 ok=true。
|
||
|
||
本脚本把以上四点变成硬门禁,任何一步不符都退出非零、绝不打印成功:
|
||
- 产物唯一命名:codex-app-server-<commit>.tar.gz + .sha256,
|
||
远端路径 /tmp/codex-app-server-releases/<commit>.tar.gz,旧包无法冒充新包。
|
||
- 产物来自 git archive(已提交的 HEAD),默认拒绝工作区脏文件;
|
||
tar 根目录带 DEPLOYED_COMMIT 清单,主机侧先验清单再动代码。
|
||
- 传输链自动 fallback:scp(SSH_PROXY_CMD)-> ssh-helpers/scp_run.py
|
||
-> ssh-helpers/chunk_send.py(base64 分片),每种方式传完都先在远端
|
||
跑 sha256sum 比对,不符就删掉该次产物再试下一种,绝不把错包交给主机脚本。
|
||
- 主机侧只用 deploy/host_deploy.sh 这一份(VM 侧随包一起传过去,
|
||
不依赖主机旧代码里的版本),它做 staging 换目录 + 回滚。
|
||
- 上线证据双重核对:主机脚本打印的 RELEASE_OK 里的 commit,
|
||
再加 VM 侧独立 ssh cat live 的 DEPLOYED_COMMIT,两者都必须等于期望 commit。
|
||
|
||
用法:
|
||
python3 deploy/deploy.py <host> [user] [--ref HEAD] [--dry-run]
|
||
python3 deploy/deploy.py <host> --build-only [--out-dir DIR]
|
||
python3 deploy/deploy.py <host> --tarball <path> --commit <sha>
|
||
python3 deploy/deploy.py <host> --skip-tests # CI 里测试已跑过时用
|
||
python3 deploy/deploy.py <host> --source git [--repo <ssh-url>]
|
||
# git 源:VM 不建 tar、不传大包,只传两个小脚本(host_git_fetch.sh
|
||
# + host_deploy.sh)并让主机用只读 deploy key 直拉 GitHub;
|
||
# 主机在 staging 里 checkout 精确 commit、验 HEAD、本地建包,
|
||
# 再调 host_deploy.sh 换目录(tar 路径仍是默认,不改默认前
|
||
# 必须显式 --source git)。主机凭证不在本脚本里:deploy key
|
||
# 私钥只放主机(默认 /root/.ssh/id_codex_github,0600),
|
||
# 公钥以只读方式加到 GitHub 仓库 Settings > Deploy keys。
|
||
|
||
密钥规则:本脚本绝不接受密码参数、不读任何 env 文件、不打印任何密钥。
|
||
SSH 密码只由调用方已有的通道处理(key,或 ~/workspace/ssh-helpers 的
|
||
pty helper,密码只走 SSH_PASSWORD 环境变量);GitHub Actions 里走 key。
|
||
"""
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import tarfile
|
||
import tempfile
|
||
from datetime import datetime, timezone
|
||
|
||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
HOST_SCRIPT = os.path.join(REPO_ROOT, "deploy", "host_deploy.sh")
|
||
HOST_GIT_FETCH_SCRIPT = os.path.join(REPO_ROOT, "deploy", "host_git_fetch.sh")
|
||
DEFAULT_GIT_REPO = "git@github.com:openqj/codex-app-server.git"
|
||
DEFAULT_GIT_KEY = "/root/.ssh/id_codex_github"
|
||
HELPER_DIR = os.path.expanduser("~/workspace/ssh-helpers")
|
||
REMOTE_RELEASES_DIR = "/tmp/codex-app-server-releases"
|
||
|
||
|
||
def run(cmd, cwd=None, check=True):
|
||
"""跑命令并返回 stdout;失败时把 stderr 透传后按 check 决定是否退出。"""
|
||
r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
||
if check and r.returncode != 0:
|
||
print("命令失败:%s" % " ".join(cmd), file=sys.stderr)
|
||
print((r.stdout + r.stderr)[-2000:], file=sys.stderr)
|
||
sys.exit(1)
|
||
return r
|
||
|
||
|
||
def git_commit(ref):
|
||
return run(["git", "rev-parse", ref], cwd=REPO_ROOT).stdout.strip()
|
||
|
||
|
||
def is_full_sha(value):
|
||
"""git 源只接受 40 位完整 sha:短 sha 有歧义,主机 checkout
|
||
前后都必须能用它唯一确定一个提交。"""
|
||
return bool(re.fullmatch(r"[0-9a-f]{40}", value or ""))
|
||
|
||
|
||
def is_ssh_git_url(url):
|
||
"""git 源只接受 SSH 形式:HTTPS 需要 token,不在本模式内;
|
||
仓库是私有的,裸 https 拉不到,token 绝不能拼进 URL。"""
|
||
return bool(url) and (
|
||
url.startswith("git@github.com:") or
|
||
url.startswith("ssh://git@github.com/"))
|
||
|
||
|
||
def commit_on_origin_main(commit):
|
||
"""commit 是否已在 origin/main 里:主机只能拉到已推的提交,
|
||
本地有但未推的 HEAD 用 git 源部署必失败,先在 VM 侧拦住。"""
|
||
r = run(["git", "merge-base", "--is-ancestor", commit, "origin/main"],
|
||
cwd=REPO_ROOT, check=False)
|
||
return r.returncode == 0
|
||
|
||
|
||
def check_clean_tree(allow_dirty):
|
||
"""已提交内容与工作区不一致时,默认拒绝;--allow-dirty 只告警,
|
||
且部署的仍是 HEAD 的 archive,脏文件不会被偷偷带上去。"""
|
||
diff = run(["git", "diff", "--quiet"], cwd=REPO_ROOT, check=False)
|
||
cached = run(["git", "diff", "--cached", "--quiet"], cwd=REPO_ROOT, check=False)
|
||
if diff.returncode != 0 or cached.returncode != 0:
|
||
if not allow_dirty:
|
||
print("工作区有未提交改动,先提交再部署(或显式 --allow-dirty,"
|
||
"但部署的仍是 HEAD,不含脏文件)。", file=sys.stderr)
|
||
sys.exit(2)
|
||
print("警告:工作区有脏文件,部署的仍是 HEAD 的 archive,"
|
||
"脏文件不会上线。")
|
||
|
||
|
||
def sha256_of(path):
|
||
h = hashlib.sha256()
|
||
with open(path, "rb") as f:
|
||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||
h.update(chunk)
|
||
return h.hexdigest()
|
||
|
||
|
||
def release_tarball_name(commit):
|
||
return "codex-app-server-%s.tar.gz" % commit
|
||
|
||
|
||
def build_release(ref, out_dir):
|
||
"""从 git archive 建发布包,返回 (tarball_path, sha256, commit)。"""
|
||
commit = git_commit(ref)
|
||
os.makedirs(out_dir, exist_ok=True)
|
||
tarball = os.path.join(out_dir, release_tarball_name(commit))
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
# git archive 输出是二进制 tar 流,直接写文件,不经过文本解码。
|
||
with open(os.path.join(tmp, "archive.tar"), "wb") as f:
|
||
r = subprocess.run(["git", "archive", ref], cwd=REPO_ROOT, stdout=f)
|
||
if r.returncode != 0:
|
||
print("git archive 失败:%s" % ref, file=sys.stderr)
|
||
sys.exit(1)
|
||
with tarfile.open(os.path.join(tmp, "archive.tar")) as tf:
|
||
tf.extractall(tmp, filter="data")
|
||
with open(os.path.join(tmp, "DEPLOYED_COMMIT"), "w") as f:
|
||
f.write(commit + "\n")
|
||
with open(os.path.join(tmp, "RELEASE.json"), "w") as f:
|
||
json.dump({
|
||
"commit": commit, "ref": ref,
|
||
"built_at": datetime.now(timezone.utc).isoformat(),
|
||
}, f, indent=2, sort_keys=True)
|
||
f.write("\n")
|
||
with tarfile.open(tarball, "w:gz", format=tarfile.PAX_FORMAT) as tf:
|
||
for root, dirs, files in os.walk(tmp):
|
||
dirs.sort()
|
||
for name in sorted(files):
|
||
full = os.path.join(root, name)
|
||
arc = os.path.relpath(full, tmp)
|
||
if arc in ("archive.tar",):
|
||
continue
|
||
tf.add(full, arcname=arc)
|
||
sha = sha256_of(tarball)
|
||
with open(tarball + ".sha256", "w") as f:
|
||
f.write("%s %s\n" % (sha, os.path.basename(tarball)))
|
||
return tarball, sha, commit
|
||
|
||
|
||
def tarball_commit(tarball):
|
||
with tarfile.open(tarball, "r:gz") as tf:
|
||
try:
|
||
member = tf.getmember("DEPLOYED_COMMIT")
|
||
except KeyError:
|
||
print("发布包里没有 DEPLOYED_COMMIT,拒绝部署:%s" % tarball,
|
||
file=sys.stderr)
|
||
sys.exit(2)
|
||
data = tf.extractfile(member).read().decode().strip()
|
||
return data
|
||
|
||
|
||
def parse_release_ok(output, expected_commit):
|
||
"""主机脚本成功输出里必须有 RELEASE_OK 且 commit 等于期望值。"""
|
||
for line in output.splitlines():
|
||
if line.startswith("RELEASE_OK"):
|
||
fields = dict(
|
||
part.split("=", 1) for part in line.split()[1:]
|
||
if "=" in part)
|
||
return fields.get("commit") == expected_commit
|
||
return False
|
||
|
||
|
||
# ---------------- 传输:ssh / scp 抽象 ----------------
|
||
|
||
def helper_path(name):
|
||
path = os.path.join(HELPER_DIR, name)
|
||
return path if os.path.isfile(path) else None
|
||
|
||
|
||
def _deploy_key():
|
||
"""无密码时的部署密钥路径:显式环境变量优先,其次两处惯常
|
||
位置。/root/.ssh 的那份会在 VM 回收时被清空,~/.ssh 的那份
|
||
持久;两处都没有时返回 None(此时靠 ssh 配置或 agent,不
|
||
硬编造路径)。这里只返回路径,不读密钥内容。"""
|
||
env_key = os.environ.get("CODEX_DEPLOY_KEY")
|
||
if env_key:
|
||
return env_key
|
||
for cand in ("/root/.ssh/id_codex_deploy",
|
||
os.path.expanduser("~/.ssh/id_codex_deploy")):
|
||
if os.path.isfile(cand):
|
||
return cand
|
||
return None
|
||
|
||
|
||
def _proxy_cmd():
|
||
"""无密码时的 ProxyCommand:显式环境变量优先,其次 helper
|
||
目录的 proxy_tunnel.py(存在才用);都没有时返回 None(直
|
||
连,不把不存在的脚本写进命令)。"""
|
||
proxy = os.environ.get("SSH_PROXY_CMD")
|
||
if proxy:
|
||
return proxy
|
||
default_proxy = os.path.join(HELPER_DIR, "proxy_tunnel.py")
|
||
if os.path.isfile(default_proxy):
|
||
return "python3 %s %%h %%p" % default_proxy
|
||
return None
|
||
|
||
|
||
def ssh_cmd(host, user, remote_cmd):
|
||
"""返回一条 ssh 命令。有 SSH_PASSWORD 时优先用已验证稳定的
|
||
helper(pty 喂密码);无密码时走密钥直连:ProxyCommand 按
|
||
_proxy_cmd(),密钥按 _deploy_key() 显式传 -i(不能只依赖
|
||
/root/.ssh/config 的 Host 条目,那个目录会在 VM 回收时被
|
||
清空)。绝不把密码拼进命令。"""
|
||
helper = helper_path("ssh_run.py")
|
||
if helper and os.environ.get("SSH_PASSWORD"):
|
||
return ["python3", helper, "ssh", host, user, remote_cmd]
|
||
base = ["ssh", "-o", "StrictHostKeyChecking=no",
|
||
"-o", "UserKnownHostsFile=/dev/null"]
|
||
proxy = _proxy_cmd()
|
||
if proxy:
|
||
base += ["-o", "ProxyCommand %s" % proxy]
|
||
key = _deploy_key()
|
||
if key:
|
||
base += ["-i", key]
|
||
return base + ["%s@%s" % (user, host), remote_cmd]
|
||
|
||
|
||
def scp_cmd(host, user, local, remote):
|
||
helper = helper_path("scp_run.py")
|
||
if helper and os.environ.get("SSH_PASSWORD"):
|
||
# helper 自带 egress proxy + 密码 pty,是此前实测最稳的一路。
|
||
return ["python3", helper, "scp", host, user, local, remote]
|
||
base = ["scp", "-o", "StrictHostKeyChecking=no",
|
||
"-o", "UserKnownHostsFile=/dev/null"]
|
||
proxy = _proxy_cmd()
|
||
if proxy:
|
||
base += ["-o", "ProxyCommand %s" % proxy]
|
||
key = _deploy_key()
|
||
if key:
|
||
base += ["-i", key]
|
||
return base + [local, "%s@%s:%s" % (user, host, remote)]
|
||
|
||
|
||
def remote_sha(host, user, remote_path):
|
||
"""远端 sha256sum 的输出里取 64 位 hex。不能取 split()[0]:经 pty
|
||
的 ssh helper 会把 stderr 的 Warning(例如 Permanently added to
|
||
the list of known hosts)合并进 stdout 放在 sha 行前面,首 token
|
||
于是变成 Warning:,导致传输明明成功却被判验真失败(2026-10-27
|
||
新链路首推实测)。"""
|
||
r = subprocess.run(ssh_cmd(host, user, "sha256sum %s" % remote_path),
|
||
capture_output=True, text=True)
|
||
if r.returncode != 0:
|
||
return None
|
||
m = re.search(r"\b[0-9a-f]{64}\b", r.stdout)
|
||
return m.group(0) if m else None
|
||
|
||
|
||
def live_commit(text):
|
||
"""从 ssh 输出里取 40 位 commit。理由同 remote_sha:helper 的
|
||
Warning 行会混进 stdout,直接 strip() 比对必失败。"""
|
||
m = re.search(r"\b[0-9a-f]{40}\b", text)
|
||
return m.group(0) if m else None
|
||
|
||
|
||
def remote_rm(host, user, remote_path):
|
||
subprocess.run(ssh_cmd(host, user, "rm -f %s" % remote_path),
|
||
capture_output=True, text=True)
|
||
|
||
|
||
def transfer(host, user, local, remote, expected_sha):
|
||
"""传输链:scp -> chunk_key 分片(密钥版)-> chunk 分片(密码版)。
|
||
每路传完都验远端 sha,不符删掉重来;全部失败才退出,
|
||
绝不把错包交给主机脚本。chunk_key 一路不依赖 SSH_PASSWORD:
|
||
它用部署密钥走显式 ProxyCommand,是 VM 侧实测最稳的小文件
|
||
传输路(git 源只传两个小脚本时尤其重要:大 tar 的 scp 在
|
||
kex 阶段被 egress 代理 reset 的概率远高于小脚本,但小脚本
|
||
也不能只押 scp 一路)。"""
|
||
attempts = []
|
||
# 1. scp(含 helper 路由,由 scp_cmd 内部决定)
|
||
attempts.append(("scp", scp_cmd(host, user, local, remote)))
|
||
chunk_key = helper_path("chunk_send_key_explicit.py")
|
||
if chunk_key:
|
||
attempts.append(("chunk_key", None)) # 特殊处理,见下
|
||
chunk = helper_path("chunk_send.py")
|
||
if chunk and os.environ.get("SSH_PASSWORD"):
|
||
attempts.append(("chunk", None)) # 特殊处理,见下
|
||
remote_b64 = remote + ".b64"
|
||
for name, cmd in attempts:
|
||
print("传输尝试:%s -> %s" % (name, remote))
|
||
if name in ("chunk", "chunk_key"):
|
||
helper = chunk_key if name == "chunk_key" else chunk
|
||
r = subprocess.run(
|
||
["python3", helper, local, remote_b64, host, user],
|
||
capture_output=True, text=True)
|
||
if r.returncode != 0:
|
||
print("%s 传输失败,换下一路。" % name, file=sys.stderr)
|
||
continue
|
||
r = subprocess.run(
|
||
ssh_cmd(host, user,
|
||
"base64 -d %s > %s && sha256sum %s"
|
||
% (remote_b64, remote, remote)),
|
||
capture_output=True, text=True)
|
||
if r.returncode != 0:
|
||
print("%s 解码失败,换下一路。" % name, file=sys.stderr)
|
||
continue
|
||
else:
|
||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||
if r.returncode != 0:
|
||
print("scp 失败(%s),换下一路。" % (r.stderr or "").strip()[-200:],
|
||
file=sys.stderr)
|
||
continue
|
||
actual = remote_sha(host, user, remote)
|
||
if actual == expected_sha:
|
||
print("传输验真通过:%s sha=%s" % (name, actual))
|
||
return
|
||
print("远端 sha 不符(expected=%s actual=%s),删掉该次产物,换下一路。"
|
||
% (expected_sha, actual), file=sys.stderr)
|
||
remote_rm(host, user, remote)
|
||
print("所有传输方式都失败或验真不通过,部署未开始(旧代码不动)。",
|
||
file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
|
||
# 部署前默认跑的测试清单:必须用 discover -s <目录>,
|
||
# 绝不能用 deploy.tests 这种包路径——包路径会把 deploy 当包
|
||
# import、盖掉 deploy.py 本体(2026-10-27 教训),导致
|
||
# 13 个假报错。P2-ops:deploy/tests 以前漏在清单外,
|
||
# deploy.py 自身的回归(如 ssh 分发)可能带病上线,
|
||
# 现补进默认清单。
|
||
DEFAULT_TEST_SUITES = ("tests", "cloud/tests", "deploy/tests")
|
||
|
||
|
||
def run_tests():
|
||
for suite in DEFAULT_TEST_SUITES:
|
||
run(["python3", "-m", "unittest", "discover", "-s", suite],
|
||
cwd=REPO_ROOT)
|
||
|
||
|
||
def deploy_via_git(args):
|
||
"""git 源部署:VM 只传两个小脚本,主机直拉 GitHub。
|
||
|
||
门禁顺序与 tar 路径同款强度,只是取包方换了主人:
|
||
脏工作区、测试、commit 未推到 origin/main、repo 非 SSH、
|
||
commit 非完整 sha,任一不符都拒绝,主机绝不动。
|
||
主机侧 host_git_fetch.sh 负责重试、checkout 校验、本地建包,
|
||
再调 host_deploy.sh 换目录;换目录后的 RELEASE_OK 解析与
|
||
live 二次核对与 tar 路径完全一致。"""
|
||
if args.tarball:
|
||
print("--tarball 与 --source git 冲突:git 源不传包,请去掉 --tarball。",
|
||
file=sys.stderr)
|
||
sys.exit(2)
|
||
if args.build_only:
|
||
print("--source git 不在 VM 建包,--build-only 不适用。",
|
||
file=sys.stderr)
|
||
sys.exit(2)
|
||
if not args.host and not args.dry_run:
|
||
print("git 源需要 host,或用 --dry-run。", file=sys.stderr)
|
||
sys.exit(2)
|
||
if not is_ssh_git_url(args.repo):
|
||
print("git 源只接受 SSH 仓库(deploy key),HTTPS 需要 token:%s"
|
||
% args.repo, file=sys.stderr)
|
||
sys.exit(2)
|
||
check_clean_tree(args.allow_dirty)
|
||
if not args.skip_tests:
|
||
run_tests()
|
||
commit = git_commit(args.ref)
|
||
if not is_full_sha(commit):
|
||
print("git 源需要 40 位完整 commit,解析结果不符:%s" % commit,
|
||
file=sys.stderr)
|
||
sys.exit(2)
|
||
if not commit_on_origin_main(commit):
|
||
print("commit 未在 origin/main 里,主机拉不到:先推分支/合 PR 再推。",
|
||
file=sys.stderr)
|
||
print(" commit=%s" % commit, file=sys.stderr)
|
||
sys.exit(2)
|
||
print("git 源:repo=%s commit=%s git_key=%s(只传路径)"
|
||
% (args.repo, commit, args.git_key))
|
||
if args.dry_run:
|
||
print("[dry-run] 将传 host_git_fetch.sh 与 host_deploy.sh 到 "
|
||
"%s@%s:%s(唯一路径,传完验 sha)" % (args.user, args.host,
|
||
REMOTE_RELEASES_DIR))
|
||
print("[dry-run] 将执行:sh host_git_fetch.sh <repo> <commit> "
|
||
"<git_key> <host_deploy.sh>,主机直拉、校验、本地建包、"
|
||
"换目录,成功须打印 RELEASE_OK 且 commit 一致,否则回滚。")
|
||
return 0
|
||
host, user = args.host, args.user
|
||
remote_git_fetch = "%s/host_git_fetch-%s.sh" % (REMOTE_RELEASES_DIR, commit)
|
||
remote_host_deploy = "%s/host_deploy-%s.sh" % (REMOTE_RELEASES_DIR, commit)
|
||
run(ssh_cmd(host, user, "mkdir -p %s" % REMOTE_RELEASES_DIR), check=False)
|
||
transfer(host, user, HOST_GIT_FETCH_SCRIPT, remote_git_fetch,
|
||
sha256_of(HOST_GIT_FETCH_SCRIPT))
|
||
transfer(host, user, HOST_SCRIPT, remote_host_deploy,
|
||
sha256_of(HOST_SCRIPT))
|
||
print("执行主机 git 取包 + 部署脚本(staging 取包 + 换目录 + 失败回滚)…")
|
||
r = subprocess.run(
|
||
ssh_cmd(host, user,
|
||
"sh %s %s %s %s %s" % (remote_git_fetch, args.repo, commit,
|
||
args.git_key, remote_host_deploy)),
|
||
capture_output=True, text=True)
|
||
print(r.stdout)
|
||
if r.stderr:
|
||
print(r.stderr, file=sys.stderr)
|
||
if r.returncode != 0 or not parse_release_ok(r.stdout, commit):
|
||
print("主机 git 部署未返回有效的 RELEASE_OK,部署失败(主机侧已回滚或未动)。",
|
||
file=sys.stderr)
|
||
sys.exit(1)
|
||
live = subprocess.run(
|
||
ssh_cmd(host, user, "cat /opt/codex-app-server/DEPLOYED_COMMIT"),
|
||
capture_output=True, text=True)
|
||
if live_commit(live.stdout) != commit:
|
||
print("二次核对失败:live DEPLOYED_COMMIT=%s,期望 %s。"
|
||
"请立即用 deploy/rollback.sh 检查,不要再推新版。"
|
||
% (live.stdout.strip(), commit), file=sys.stderr)
|
||
sys.exit(1)
|
||
print("部署成功且二次核对通过:source=git commit=%s repo=%s"
|
||
% (commit, args.repo))
|
||
return 0
|
||
|
||
|
||
def main(argv=None):
|
||
parser = argparse.ArgumentParser(description="codex-app-server 统一发布入口")
|
||
parser.add_argument("host", nargs="?", help="部署目标主机")
|
||
parser.add_argument("user", nargs="?", default="root", help="SSH 用户")
|
||
parser.add_argument("--ref", default="HEAD")
|
||
parser.add_argument("--tarball", help="直接部署已有发布包(CI 产物)")
|
||
parser.add_argument("--commit", help="配合 --tarball 指定期望 commit")
|
||
parser.add_argument("--source", choices=("tar", "git"), default="tar",
|
||
help="取包方式:tar(默认,VM 建包传包)或 git"
|
||
"(主机用只读 deploy key 直拉 GitHub,见本文件头注释)")
|
||
parser.add_argument("--repo", default=DEFAULT_GIT_REPO,
|
||
help="git 源仓库(只接受 SSH 形式,默认 openqj/codex-app-server)")
|
||
parser.add_argument("--git-key", default=DEFAULT_GIT_KEY,
|
||
help="git 源主机 deploy key 私钥路径(只传路径,不传内容)")
|
||
parser.add_argument("--out-dir", default=os.path.join(
|
||
REPO_ROOT, "work", "external-install", "releases"))
|
||
parser.add_argument("--allow-dirty", action="store_true")
|
||
parser.add_argument("--skip-tests", action="store_true")
|
||
parser.add_argument("--build-only", action="store_true")
|
||
parser.add_argument("--dry-run", action="store_true")
|
||
args = parser.parse_args(argv)
|
||
|
||
if args.source == "git":
|
||
return deploy_via_git(args)
|
||
|
||
if args.tarball:
|
||
commit = args.commit or tarball_commit(args.tarball)
|
||
tarball, sha = args.tarball, sha256_of(args.tarball)
|
||
if tarball_commit(tarball) != commit:
|
||
print("发布包内 DEPLOYED_COMMIT 与 --commit 不符,拒绝部署。",
|
||
file=sys.stderr)
|
||
sys.exit(2)
|
||
else:
|
||
if not args.build_only and not args.dry_run and not args.host:
|
||
parser.error("需要 host,或用 --build-only / --dry-run")
|
||
check_clean_tree(args.allow_dirty)
|
||
if not args.skip_tests:
|
||
run_tests()
|
||
tarball, sha, commit = build_release(args.ref, args.out_dir)
|
||
print("发布包:%s" % tarball)
|
||
print("commit=%s sha256=%s" % (commit, sha))
|
||
|
||
if args.build_only:
|
||
print("仅构建,不传输、不部署。")
|
||
return 0
|
||
if args.dry_run:
|
||
print("[dry-run] 将传输 %s 到 %s@%s:%s/%s(唯一路径,传完验 sha)"
|
||
% (tarball, args.user, args.host, REMOTE_RELEASES_DIR,
|
||
os.path.basename(tarball)))
|
||
print("[dry-run] 将传主机脚本 %s 并执行:"
|
||
"sh host_deploy.sh <tarball> <sha> <commit>,"
|
||
"成功须打印 RELEASE_OK 且 commit 一致,否则回滚。" % HOST_SCRIPT)
|
||
return 0
|
||
|
||
host, user = args.host, args.user
|
||
remote_tar = "%s/%s" % (REMOTE_RELEASES_DIR, os.path.basename(tarball))
|
||
remote_script = "%s/host_deploy-%s.sh" % (REMOTE_RELEASES_DIR, commit)
|
||
run(ssh_cmd(host, user, "mkdir -p %s" % REMOTE_RELEASES_DIR), check=False)
|
||
transfer(host, user, tarball, remote_tar, sha)
|
||
transfer(host, user, HOST_SCRIPT, remote_script, sha256_of(HOST_SCRIPT))
|
||
print("执行主机部署脚本(唯一脚本,staging 换目录 + 失败回滚)…")
|
||
r = subprocess.run(
|
||
ssh_cmd(host, user,
|
||
"sh %s %s %s %s" % (remote_script, remote_tar, sha, commit)),
|
||
capture_output=True, text=True)
|
||
print(r.stdout)
|
||
if r.stderr:
|
||
print(r.stderr, file=sys.stderr)
|
||
if r.returncode != 0 or not parse_release_ok(r.stdout, commit):
|
||
print("主机部署未返回有效的 RELEASE_OK,部署失败(主机侧已回滚或未动)。",
|
||
file=sys.stderr)
|
||
sys.exit(1)
|
||
# 独立二次核对:不只信主机脚本的打印,再 ssh 读一次 live 清单。
|
||
live = subprocess.run(
|
||
ssh_cmd(host, user, "cat /opt/codex-app-server/DEPLOYED_COMMIT"),
|
||
capture_output=True, text=True)
|
||
if live_commit(live.stdout) != commit:
|
||
print("二次核对失败:live DEPLOYED_COMMIT=%s,期望 %s。"
|
||
"请立即用 deploy/rollback.sh 检查,不要再推新版。"
|
||
% (live.stdout.strip(), commit), file=sys.stderr)
|
||
sys.exit(1)
|
||
print("部署成功且二次核对通过:commit=%s sha256=%s" % (commit, sha))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|