#!/usr/bin/env python3 from __future__ import annotations import argparse import os from pathlib import Path import shlex import subprocess # 项目根目录,后面会从这里读取 .env 并打包代码。 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) # 先加载项目根目录 .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") # 默认 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: # 这里默认按当前项目目录直接同步到远端,不再依赖远端 git clone。 parser = argparse.ArgumentParser(description="deploy hy-app-monitor to deploy host via ssh/scp") 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("--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 / SCP 命令统一复用同一组参数,避免行为不一致。 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 scp_base_args(*, port: int, identity_file: str) -> list[str]: # SCP 的端口参数和 SSH 不同,这里单独组装一份。 args = [ "-P", str(port), "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=accept-new", ] if identity_file: args.extend(["-i", identity_file]) return args def run_command(command: list[str], *, input_bytes: bytes | None = None, label: str) -> bytes: # 小工具统一把 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 sync_project_tree(*, remote_dir: str, ssh_args: list[str]) -> None: # 远端保留 .env / .venv / run,其他项目内容全部按当前工作区覆盖。 remote_prepare_script = f"""set -euo pipefail REMOTE_DIR={shlex.quote(remote_dir)} mkdir -p "$REMOTE_DIR" find "$REMOTE_DIR" -mindepth 1 -maxdepth 1 \\ ! -name '.env' \\ ! -name '.venv' \\ ! -name 'run' \\ -exec rm -rf {{}} + """ run_command(["ssh", *ssh_args, "bash", "-se"], input_bytes=remote_prepare_script.encode("utf-8"), label="remote prepare") # 本地 tar 流直接送到远端解压,避免要求远端额外安装 rsync。 tar_command = [ "tar", "-czf", "-", "--exclude=.git", "--exclude=.env", "--exclude=.venv", "--exclude=run", "--exclude=__pycache__", "--exclude=.pytest_cache", "--exclude=.mypy_cache", "--exclude=.DS_Store", ".", ] ssh_command = ["ssh", *ssh_args, "tar", "-xzf", "-", "-C", remote_dir] try: tar_process = subprocess.Popen(tar_command, cwd=str(ROOT), stdout=subprocess.PIPE, stderr=subprocess.PIPE) except FileNotFoundError as exc: raise SystemExit("missing command for tar sync: tar") from exc try: ssh_process = subprocess.run( ssh_command, stdin=tar_process.stdout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False, ) if tar_process.stdout is not None: tar_process.stdout.close() tar_stderr = (tar_process.stderr.read() if tar_process.stderr is not None else b"").decode("utf-8", errors="replace") tar_return = tar_process.wait() finally: if tar_process.poll() is None: tar_process.kill() tar_process.wait() if tar_return != 0: raise SystemExit(tar_stderr.strip() or "local tar packaging failed") if ssh_process.returncode != 0: raise SystemExit((ssh_process.stdout or b"").decode("utf-8", errors="replace").strip() or "remote extract failed") def copy_env_file( target: str, *, remote_dir: str, env_path: Path, port: int, identity_file: str, ) -> None: # 运行配置单独走 scp,和代码同步解耦,避免误把远端 .env 覆盖成空文件。 run_command( ["scp", *scp_base_args(port=port, identity_file=identity_file), str(env_path), f"{target}:{remote_dir}/.env"], label="copy env", ) def run_remote_install(*, remote_dir: str, app_port: str, ssh_args: list[str]) -> str: # 远端只负责安装依赖、重启 systemd 并校验监听。 script = f"""set -euo pipefail REMOTE_DIR={shlex.quote(remote_dir)} APP_PORT={shlex.quote(app_port)} cd "$REMOTE_DIR" chmod +x scripts/*.sh python3 -m venv .venv .venv/bin/pip install -r requirements-ops.txt bash scripts/install_systemd.sh 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 """ output = run_command( ["ssh", *ssh_args, "bash", "-se"], input_bytes=script.encode("utf-8"), label="remote install", ) 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() target = f"{args.user}@{args.host}" ssh_args = ssh_base_args(args.host, user=args.user, port=args.port, identity_file=args.identity_file) sync_project_tree(remote_dir=args.remote_dir, ssh_args=ssh_args) copy_env_file( target, remote_dir=args.remote_dir, env_path=env_path, port=args.port, identity_file=args.identity_file, ) output = run_remote_install(remote_dir=args.remote_dir, app_port=args.app_port, ssh_args=ssh_args) print(output) if __name__ == "__main__": main()