2026-06-08 14:44:09 +08:00
|
|
|
|
"""部署:从 Gitea 拉取最新代码到远程服务器(免密)。
|
|
|
|
|
|
|
|
|
|
|
|
行为:
|
|
|
|
|
|
- 服务器项目目录不存在?git clone : git fetch + reset --hard
|
|
|
|
|
|
- 拉前记录当前 HEAD(用于失败回滚)
|
|
|
|
|
|
- 失败回滚:git reset --hard <之前 sha>
|
|
|
|
|
|
- 输出 commit hash / log / working tree 状态 / 跟本地 main 对比
|
|
|
|
|
|
|
|
|
|
|
|
用法:
|
|
|
|
|
|
python scripts/deploy_pull.py
|
|
|
|
|
|
python scripts/deploy_pull.py --dry-run
|
|
|
|
|
|
python scripts/deploy_pull.py --repo-dir /opt/diary-news
|
|
|
|
|
|
python scripts/deploy_pull.py --rollback <sha> # 手动回退
|
|
|
|
|
|
|
|
|
|
|
|
环境变量(覆盖默认值):
|
|
|
|
|
|
DEPLOY_HOST, DEPLOY_PORT, DEPLOY_USER,
|
|
|
|
|
|
DEPLOY_REPO_DIR, DEPLOY_REPO_URL, DEPLOY_SSH_KEY
|
|
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import os
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
import sys
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import paramiko
|
|
|
|
|
|
|
|
|
|
|
|
# ===== 默认配置(适配当前项目)=====
|
|
|
|
|
|
DEFAULT_HOST = "207.57.129.228"
|
|
|
|
|
|
DEFAULT_PORT = 19717
|
|
|
|
|
|
DEFAULT_USER = "root"
|
|
|
|
|
|
DEFAULT_REPO_DIR = "/root/diary-news"
|
|
|
|
|
|
DEFAULT_REPO_URL = "http://124.223.26.33:3000/xiaji/diary-news.git"
|
|
|
|
|
|
DEFAULT_SSH_KEY = os.path.expanduser("~/.ssh/id_ed25519")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run(c: paramiko.SSHClient, cmd: str, timeout: int = 60) -> tuple[int, str, str]:
|
|
|
|
|
|
si, so, se = c.exec_command(cmd, timeout=timeout)
|
|
|
|
|
|
out = so.read().decode(errors="replace")
|
|
|
|
|
|
err = se.read().decode(errors="replace")
|
|
|
|
|
|
rc = so.channel.recv_exit_status()
|
|
|
|
|
|
return rc, out, err
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _connect(host: str, port: int, user: str, ssh_key: str) -> paramiko.SSHClient:
|
2026-06-08 21:20:43 +08:00
|
|
|
|
# 依次尝试 RSA / Ed25519 / ECDSA(paramiko 5 没有统一入口)
|
|
|
|
|
|
pkey: Any = None
|
|
|
|
|
|
last_err: Exception | None = None
|
|
|
|
|
|
for loader in (paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey):
|
|
|
|
|
|
try:
|
|
|
|
|
|
pkey = loader.from_private_key_file(ssh_key)
|
|
|
|
|
|
break
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
last_err = e
|
|
|
|
|
|
if pkey is None:
|
|
|
|
|
|
raise RuntimeError(f"无法解析 SSH key {ssh_key}: {last_err}")
|
2026-06-08 14:44:09 +08:00
|
|
|
|
c = paramiko.SSHClient()
|
|
|
|
|
|
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
|
|
|
|
c.connect(host, port=port, username=user, pkey=pkey, timeout=30,
|
|
|
|
|
|
allow_agent=False, look_for_keys=False)
|
|
|
|
|
|
return c
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _local_head() -> str:
|
|
|
|
|
|
"""本地 HEAD sha(用作同步参考)。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
r = subprocess.run(
|
|
|
|
|
|
["git", "rev-parse", "HEAD"],
|
|
|
|
|
|
capture_output=True, text=True, cwd=str(Path(__file__).resolve().parents[1]),
|
|
|
|
|
|
check=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
return r.stdout.strip()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return f"unknown:{e}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def deploy(
|
|
|
|
|
|
host: str = DEFAULT_HOST,
|
|
|
|
|
|
port: int = DEFAULT_PORT,
|
|
|
|
|
|
user: str = DEFAULT_USER,
|
|
|
|
|
|
repo_dir: str = DEFAULT_REPO_DIR,
|
|
|
|
|
|
repo_url: str = DEFAULT_REPO_URL,
|
|
|
|
|
|
ssh_key: str = DEFAULT_SSH_KEY,
|
|
|
|
|
|
branch: str = "main",
|
|
|
|
|
|
dry_run: bool = False,
|
|
|
|
|
|
rollback: str | None = None,
|
|
|
|
|
|
) -> int:
|
|
|
|
|
|
if not Path(ssh_key).exists():
|
|
|
|
|
|
print(f"ERROR: SSH key 不存在: {ssh_key}", file=sys.stderr)
|
|
|
|
|
|
return 2
|
|
|
|
|
|
|
|
|
|
|
|
print(f"=== 连接 {user}@{host}:{port} ===")
|
|
|
|
|
|
c = _connect(host, port, user, ssh_key)
|
|
|
|
|
|
print(f"✓ 已免密登录")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 1) 手动回退
|
|
|
|
|
|
if rollback:
|
|
|
|
|
|
print(f"\n=== 手动回退到 {rollback[:12]} ===")
|
|
|
|
|
|
rc, out, err = _run(c, f"cd {repo_dir} && git reset --hard {rollback}", timeout=30)
|
|
|
|
|
|
if rc != 0:
|
|
|
|
|
|
print(f"✗ 回退失败: {err}")
|
|
|
|
|
|
return 1
|
|
|
|
|
|
print("✓ 已回退")
|
|
|
|
|
|
rc, head, _ = _run(c, f"cd {repo_dir} && git rev-parse HEAD", timeout=10)
|
|
|
|
|
|
print(f"现在 HEAD: {head.strip()[:12]}")
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
# 2) 探查:目录在不在?
|
|
|
|
|
|
rc, exists, _ = _run(c, f"test -d {repo_dir}/.git && echo EXISTS || echo MISSING", timeout=10)
|
|
|
|
|
|
action = "pull" if "EXISTS" in exists else "clone"
|
|
|
|
|
|
|
|
|
|
|
|
# 3) 拉前 HEAD(用于回滚)
|
|
|
|
|
|
before_sha: str | None = None
|
|
|
|
|
|
if action == "pull":
|
|
|
|
|
|
rc, h, _ = _run(c, f"cd {repo_dir} && git rev-parse HEAD", timeout=10)
|
|
|
|
|
|
before_sha = h.strip()
|
|
|
|
|
|
print(f"拉前 HEAD: {before_sha[:12]}")
|
|
|
|
|
|
|
|
|
|
|
|
if dry_run:
|
|
|
|
|
|
print(f"DRY-RUN: 将要 {action} from {repo_url} → {repo_dir}")
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
# 4) 拉
|
|
|
|
|
|
print(f"\n=== {action.upper()} ===")
|
|
|
|
|
|
if action == "clone":
|
|
|
|
|
|
cmd = f"git clone --depth 50 {repo_url} {repo_dir}"
|
|
|
|
|
|
else:
|
|
|
|
|
|
cmd = (
|
|
|
|
|
|
f"cd {repo_dir} && "
|
|
|
|
|
|
f"git fetch origin {branch} && "
|
|
|
|
|
|
f"git reset --hard origin/{branch}"
|
|
|
|
|
|
)
|
|
|
|
|
|
rc, out, err = _run(c, cmd, timeout=180)
|
|
|
|
|
|
|
|
|
|
|
|
if rc != 0:
|
|
|
|
|
|
print(f"✗ {action} 失败 (rc={rc})")
|
|
|
|
|
|
if out.strip(): print(f"stdout:\n{out}")
|
|
|
|
|
|
if err.strip(): print(f"stderr:\n{err}")
|
|
|
|
|
|
# 回滚(只在 pull 时)
|
|
|
|
|
|
if before_sha and action == "pull":
|
|
|
|
|
|
print(f"\n=== 回退到 {before_sha[:12]} ===")
|
|
|
|
|
|
rrc, _, rerr = _run(c, f"cd {repo_dir} && git reset --hard {before_sha}", timeout=30)
|
|
|
|
|
|
if rrc == 0:
|
|
|
|
|
|
print(f"✓ 已回退到 {before_sha[:12]}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f"✗ 回退失败: {rerr}")
|
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
|
|
print(f"✓ {action} 成功")
|
|
|
|
|
|
if out.strip(): print(out)
|
|
|
|
|
|
|
|
|
|
|
|
# 5) 拉后状态
|
|
|
|
|
|
rc, head_sha, _ = _run(c, f"cd {repo_dir} && git rev-parse HEAD", timeout=10)
|
|
|
|
|
|
head_sha = head_sha.strip()
|
|
|
|
|
|
print(f"\n拉后 HEAD: {head_sha[:12]}")
|
|
|
|
|
|
|
|
|
|
|
|
rc, log, _ = _run(c, f"cd {repo_dir} && git log --oneline -5", timeout=10)
|
|
|
|
|
|
print("--- 最近的 commit ---")
|
|
|
|
|
|
print(log)
|
|
|
|
|
|
|
|
|
|
|
|
rc, status, _ = _run(c, f"cd {repo_dir} && git status --porcelain", timeout=10)
|
|
|
|
|
|
if status.strip():
|
|
|
|
|
|
print(f"⚠️ working tree 不干净:\n{status}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("✓ working tree 干净")
|
|
|
|
|
|
|
|
|
|
|
|
# 6) 跟本地 main 对比
|
|
|
|
|
|
local = _local_head()
|
|
|
|
|
|
print(f"\n=== 同步对比 ===")
|
|
|
|
|
|
print(f"本地 HEAD: {local[:12]}")
|
|
|
|
|
|
print(f"服务器 HEAD: {head_sha[:12]}")
|
|
|
|
|
|
if local.startswith(head_sha):
|
|
|
|
|
|
print("✓ 服务器 == 本地")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("ℹ️ 服务器跟本地不完全一致(可能本地未推送,或服务器在另一条 commit 上)")
|
|
|
|
|
|
|
|
|
|
|
|
# 7) 总结
|
|
|
|
|
|
print(f"\n=== 部署报告 ===")
|
|
|
|
|
|
print(f"服务器: {user}@{host}:{port}")
|
|
|
|
|
|
print(f"项目目录: {repo_dir}")
|
|
|
|
|
|
print(f"动作: {action}")
|
|
|
|
|
|
print(f"最终 HEAD: {head_sha[:12]}")
|
|
|
|
|
|
return 0
|
|
|
|
|
|
finally:
|
|
|
|
|
|
c.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
|
|
|
p = argparse.ArgumentParser(
|
|
|
|
|
|
description=__doc__,
|
|
|
|
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
|
|
|
|
)
|
|
|
|
|
|
p.add_argument("--host", default=os.environ.get("DEPLOY_HOST", DEFAULT_HOST))
|
|
|
|
|
|
p.add_argument("--port", type=int, default=int(os.environ.get("DEPLOY_PORT", str(DEFAULT_PORT))))
|
|
|
|
|
|
p.add_argument("--user", default=os.environ.get("DEPLOY_USER", DEFAULT_USER))
|
|
|
|
|
|
p.add_argument("--repo-dir", default=os.environ.get("DEPLOY_REPO_DIR", DEFAULT_REPO_DIR))
|
|
|
|
|
|
p.add_argument("--repo-url", default=os.environ.get("DEPLOY_REPO_URL", DEFAULT_REPO_URL))
|
|
|
|
|
|
p.add_argument("--ssh-key", default=os.environ.get("DEPLOY_SSH_KEY", DEFAULT_SSH_KEY))
|
|
|
|
|
|
p.add_argument("--branch", default="main")
|
|
|
|
|
|
p.add_argument("--dry-run", action="store_true")
|
|
|
|
|
|
p.add_argument("--rollback", metavar="SHA", help="手动回退到指定 commit")
|
|
|
|
|
|
args = p.parse_args()
|
|
|
|
|
|
sys.exit(deploy(
|
|
|
|
|
|
host=args.host, port=args.port, user=args.user,
|
|
|
|
|
|
repo_dir=args.repo_dir, repo_url=args.repo_url,
|
|
|
|
|
|
ssh_key=args.ssh_key, branch=args.branch,
|
|
|
|
|
|
dry_run=args.dry_run, rollback=args.rollback,
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|