hy-app-monitor/ops/deploy_via_ssh.py

247 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()