258 lines
9.1 KiB
Python
258 lines
9.1 KiB
Python
#!/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()
|