#!/usr/bin/env python3 from __future__ import annotations import argparse import base64 import os from pathlib import Path import shlex import subprocess # 项目根目录,后面会从这里读取 .env 和当前 Git 仓信息。 ROOT = Path(__file__).resolve().parents[1] # 默认 .env 路径。 ENV_FILE = Path(os.environ.get("APP_ENV_FILE", ROOT / ".env")).expanduser() def load_env_file(path: Path) -> None: # .env 文件不存在时允许继续使用外部环境变量。 if not path.exists(): return # 逐行读取 .env 内容。 for raw_line in path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) key = key.strip() value = value.strip() if value.startswith('"') and value.endswith('"'): value = value[1:-1] if value.startswith("'") and value.endswith("'"): value = value[1:-1] os.environ.setdefault(key, value) def local_git_output(args: list[str], *, label: str) -> str: # 所有本地 Git 查询统一走这个函数,失败时直接抛明确错误。 try: result = subprocess.run( ["git", "-C", str(ROOT), *args], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, check=False, ) except FileNotFoundError as exc: raise SystemExit("missing command for local git query: git") from exc if result.returncode != 0: raise SystemExit((result.stdout or "").strip() or f"{label} failed") return (result.stdout or "").strip() def default_repo_url() -> str: # 默认直接沿用当前工作区 origin,避免脚本和实际仓库地址漂移。 configured = os.environ.get("MONITOR_REPO_URL", "").strip() if configured: return configured return local_git_output(["remote", "get-url", "origin"], label="detect git remote") def default_repo_ref() -> str: # 默认部署当前分支,满足“提交后推送、线上按同一分支拉取”的链路。 configured = os.environ.get("MONITOR_REPO_REF", "").strip() if configured: return configured branch = local_git_output(["rev-parse", "--abbrev-ref", "HEAD"], label="detect current branch") if branch == "HEAD": raise SystemExit("current git HEAD is detached; pass --repo-ref explicitly") return branch # 先加载项目根目录 .env,保证默认 SSH 目标也能从这里读取。 load_env_file(ENV_FILE) # 默认远端部署目录。 DEFAULT_REMOTE_DIR = os.environ.get("MONITOR_REMOTE_DIR", "/opt/hy-app-monitor") # 默认远端监听端口。 DEFAULT_APP_PORT = os.environ.get("APP_PORT", "2026") # 默认远端仓库地址和分支。 DEFAULT_REPO_URL = default_repo_url() DEFAULT_REPO_REF = default_repo_ref() # 默认 SSH 主机、用户和端口。 DEFAULT_DEPLOY_SSH_HOST = os.environ.get("DEPLOY_SSH_HOST", "").strip() DEFAULT_DEPLOY_SSH_USER = os.environ.get("DEPLOY_SSH_USER", "root").strip() or "root" DEFAULT_DEPLOY_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22")) DEFAULT_DEPLOY_SSH_IDENTITY_FILE = os.environ.get("DEPLOY_SSH_IDENTITY_FILE", "").strip() def parse_args() -> argparse.Namespace: # SSH 链路现在和 TAT 一样走远端 git 拉取,不再同步本地工作区文件树。 parser = argparse.ArgumentParser(description="deploy hy-app-monitor to deploy host via remote git pull over ssh") parser.add_argument("--host", default=DEFAULT_DEPLOY_SSH_HOST, help="deploy ssh host") parser.add_argument("--user", default=DEFAULT_DEPLOY_SSH_USER, help="deploy ssh user") parser.add_argument("--port", type=int, default=DEFAULT_DEPLOY_SSH_PORT, help="deploy ssh port") parser.add_argument("--identity-file", default=DEFAULT_DEPLOY_SSH_IDENTITY_FILE, help="optional ssh identity file") parser.add_argument("--repo-url", default=DEFAULT_REPO_URL, help="git repository url used by remote host") parser.add_argument("--repo-ref", default=DEFAULT_REPO_REF, help="git ref pulled on remote host") parser.add_argument("--remote-dir", default=DEFAULT_REMOTE_DIR, help="remote project directory") parser.add_argument("--app-port", default=DEFAULT_APP_PORT, help="remote app port") return parser.parse_args() def require_env_file() -> Path: # 当前项目约定 .env 是唯一运行时配置源,部署时必须显式同步。 if not ENV_FILE.exists(): raise SystemExit(f"missing local env file: {ENV_FILE}") return ENV_FILE def ssh_base_args(host: str, *, user: str, port: int, identity_file: str) -> list[str]: # 所有 SSH 命令统一复用同一组参数,避免行为不一致。 args = [ "-p", str(port), "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3", "-o", "StrictHostKeyChecking=accept-new", ] if identity_file: args.extend(["-i", identity_file]) args.append(f"{user}@{host}") return args def run_command(command: list[str], *, input_bytes: bytes | None = None, label: str) -> bytes: # SSH 命令统一把 stdout/stderr 合并,失败时直接抛完整错误文本。 try: result = subprocess.run( command, input=input_bytes, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False, ) except FileNotFoundError as exc: raise SystemExit(f"missing command for {label}: {command[0]}") from exc if result.returncode != 0: raise SystemExit((result.stdout or b"").decode("utf-8", errors="replace").strip() or f"{label} failed") return result.stdout or b"" def remote_script(*, repo_url: str, repo_ref: str, remote_dir: str, app_port: str, runtime_env_text: str) -> str: # .env 文本改走 base64 下发,避免 ssh heredoc 里的转义和编码问题。 env_blob = base64.b64encode(runtime_env_text.encode("utf-8")).decode("ascii") # 远端固定执行:git 拉取代码 -> 写入 .env -> 安装依赖 -> 重启 systemd -> 校验状态。 return f"""set -euo pipefail REPO_URL={shlex.quote(repo_url)} REPO_REF={shlex.quote(repo_ref)} REMOTE_DIR={shlex.quote(remote_dir)} APP_PORT={shlex.quote(app_port)} export GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=accept-new' mkdir -p "$(dirname "$REMOTE_DIR")" if [ -d "$REMOTE_DIR/.git" ]; then git -C "$REMOTE_DIR" fetch --all --tags --prune else rm -rf "$REMOTE_DIR" git clone "$REPO_URL" "$REMOTE_DIR" git -C "$REMOTE_DIR" fetch --all --tags --prune fi if git -C "$REMOTE_DIR" show-ref --verify --quiet "refs/remotes/origin/$REPO_REF"; then git -C "$REMOTE_DIR" checkout -B "$REPO_REF" "origin/$REPO_REF" git -C "$REMOTE_DIR" branch --set-upstream-to="origin/$REPO_REF" "$REPO_REF" >/dev/null 2>&1 || true elif git -C "$REMOTE_DIR" show-ref --verify --quiet "refs/tags/$REPO_REF"; then git -C "$REMOTE_DIR" checkout --detach "refs/tags/$REPO_REF" else git -C "$REMOTE_DIR" checkout --detach "$REPO_REF" fi cd "$REMOTE_DIR" python3 - <<'PY' import base64 from pathlib import Path # 本地项目根目录的 .env 必须同步到远端根目录,运行时只认这一份。 content = base64.b64decode({env_blob!r}).decode("utf-8") Path(".env").write_text(content, encoding="utf-8") PY chmod +x scripts/*.sh python3 -m venv .venv .venv/bin/pip install --quiet --disable-pip-version-check -r requirements-ops.txt bash scripts/install_systemd.sh systemctl reset-failed hy-app-monitor-yumi.service hy-app-monitor-haiyi.service hy-app-monitor.service || true systemctl restart hy-app-monitor-yumi.service hy-app-monitor-haiyi.service hy-app-monitor.service systemctl is-active hy-app-monitor-yumi.service systemctl is-active hy-app-monitor-haiyi.service systemctl is-active hy-app-monitor.service ss -lntp | grep ":$APP_PORT" || true """ def run_remote_deploy( *, repo_url: str, repo_ref: str, remote_dir: str, app_port: str, env_path: Path, ssh_args: list[str], ) -> str: # SSH 部署主逻辑和 TAT 保持一致,只是传输层从 TAT 换成 SSH。 script = remote_script( repo_url=repo_url, repo_ref=repo_ref, remote_dir=remote_dir, app_port=app_port, runtime_env_text=env_path.read_text(encoding="utf-8"), ) output = run_command( ["ssh", *ssh_args, "bash", "-se"], input_bytes=script.encode("utf-8"), label="remote deploy", ) return output.decode("utf-8", errors="replace").strip() def main() -> None: args = parse_args() if not args.host: raise SystemExit("deploy ssh host is required; set DEPLOY_SSH_HOST or pass --host") env_path = require_env_file() ssh_args = ssh_base_args(args.host, user=args.user, port=args.port, identity_file=args.identity_file) output = run_remote_deploy( repo_url=args.repo_url, repo_ref=args.repo_ref, remote_dir=args.remote_dir, app_port=args.app_port, env_path=env_path, ssh_args=ssh_args, ) print(output) if __name__ == "__main__": main()