hy-app-monitor/ops/deploy_via_ssh.py

258 lines
9.1 KiB
Python
Raw Permalink 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 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()