diff --git a/README.md b/README.md index 9d46524..586c365 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ python3 ops/deploy_via_ssh.py 默认会: -1. 把当前工作区同步到远端 `/opt/hy-app-monitor` +1. 让远端主机从 Git 拉取指定分支到 `/opt/hy-app-monitor` 2. 把本地项目 `.env` 同步到远端项目 `.env` 3. 创建远端 `.venv` 4. 安装 `requirements-ops.txt` @@ -215,6 +215,13 @@ python3 ops/deploy_via_ssh.py `ops/deploy_via_tat.py` 仍可保留成带外兜底,不建议继续作为日常主链路。 +日常链路应先把本地改动提交并推送到远端仓库,再执行部署脚本,让 deploy 机按同一分支 `git pull`: + +```bash +git push origin main +python3 ops/deploy_via_ssh.py --repo-ref main +``` + ## 维护完整 Compose 默认只刷新本地完整模板: diff --git a/monitors/yumi/service_release.py b/monitors/yumi/service_release.py index 11c0351..64eb113 100644 --- a/monitors/yumi/service_release.py +++ b/monitors/yumi/service_release.py @@ -719,7 +719,11 @@ def current_template_image(service_name: str) -> str: # 优先从本地完整模板里拿当前运行时镜像仓路径。 records = service_records_by_name().get(service_name) or [] for record in records: - model = load_local_host_compose_model(record["host"]) + try: + model = load_local_host_compose_model(record["host"]) + except Exception: # noqa: BLE001 + # 发布页只需要尽量推导仓路径;模板暂时不可读时继续尝试其他主机并允许后续兜底。 + continue image_ref = str(((model.get("services") or {}).get(service_name) or {}).get("image") or "").strip() if image_ref.startswith(f"{HARBOR_REGISTRY}/{HARBOR_PROJECT}/"): return image_ref diff --git a/ops/deploy_via_ssh.py b/ops/deploy_via_ssh.py index f5b5893..1030e2d 100644 --- a/ops/deploy_via_ssh.py +++ b/ops/deploy_via_ssh.py @@ -3,13 +3,14 @@ from __future__ import annotations import argparse +import base64 import os from pathlib import Path import shlex import subprocess -# 项目根目录,后面会从这里读取 .env 并打包代码。 +# 项目根目录,后面会从这里读取 .env 和当前 Git 仓信息。 ROOT = Path(__file__).resolve().parents[1] # 默认 .env 路径。 ENV_FILE = Path(os.environ.get("APP_ENV_FILE", ROOT / ".env")).expanduser() @@ -35,12 +36,53 @@ def load_env_file(path: Path) -> None: 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" @@ -49,12 +91,14 @@ DEFAULT_DEPLOY_SSH_IDENTITY_FILE = os.environ.get("DEPLOY_SSH_IDENTITY_FILE", "" def parse_args() -> argparse.Namespace: - # 这里默认按当前项目目录直接同步到远端,不再依赖远端 git clone。 - parser = argparse.ArgumentParser(description="deploy hy-app-monitor to deploy host via ssh/scp") + # 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() @@ -68,7 +112,7 @@ def require_env_file() -> Path: def ssh_base_args(host: str, *, user: str, port: int, identity_file: str) -> list[str]: - # 所有 SSH / SCP 命令统一复用同一组参数,避免行为不一致。 + # 所有 SSH 命令统一复用同一组参数,避免行为不一致。 args = [ "-p", str(port), @@ -89,25 +133,8 @@ def ssh_base_args(host: str, *, user: str, port: int, identity_file: str) -> lis 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 合并,失败时直接抛完整错误文本。 + # SSH 命令统一把 stdout/stderr 合并,失败时直接抛完整错误文本。 try: result = subprocess.run( command, @@ -124,88 +151,55 @@ def run_command(command: list[str], *, input_bytes: bytes | None = None, label: 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") +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") - # 本地 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] + # 远端固定执行:git 拉取代码 -> 写入 .env -> 安装依赖 -> 重启 systemd -> 校验状态。 + return f"""set -euo pipefail - 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 +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 -r requirements-ops.txt +.venv/bin/pip install --quiet --disable-pip-version-check -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 @@ -213,10 +207,29 @@ 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 install", + label="remote deploy", ) return output.decode("utf-8", errors="replace").strip() @@ -227,18 +240,15 @@ def main() -> None: 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, + 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, - port=args.port, - identity_file=args.identity_file, + ssh_args=ssh_args, ) - output = run_remote_install(remote_dir=args.remote_dir, app_port=args.app_port, ssh_args=ssh_args) print(output) diff --git a/tests/test_service_release.py b/tests/test_service_release.py index dd87766..c45cb70 100644 --- a/tests/test_service_release.py +++ b/tests/test_service_release.py @@ -3,6 +3,7 @@ from __future__ import annotations import unittest from unittest.mock import patch +from core.config import HARBOR_PROJECT, HARBOR_REGISTRY from monitors.yumi.service_release import build_update_targets_payload @@ -90,6 +91,37 @@ class ServiceReleaseTargetsTests(unittest.TestCase): self.assertEqual(payload["items"][0]["currentImages"], []) self.assertEqual(payload["items"][0]["warnings"], ["app-1: broken compose"]) + @patch("monitors.yumi.service_release.frontend_release_enabled", return_value=False) + @patch("monitors.yumi.service_release.buildable_service_names", return_value=["auth"]) + @patch("monitors.yumi.service_release.service_records_by_name") + @patch("monitors.yumi.service_release.load_local_host_compose_model", side_effect=RuntimeError("broken compose")) + def test_build_update_targets_payload_falls_back_repository_when_compose_unavailable( + self, + load_local_host_compose_model_mock, + service_records_by_name_mock, + buildable_service_names_mock, + frontend_release_enabled_mock, + ) -> None: + del load_local_host_compose_model_mock + del buildable_service_names_mock, frontend_release_enabled_mock + service_records_by_name_mock.return_value = { + "auth": [ + { + "host": "app-1", + "ip": "10.0.0.1", + "instanceId": "ins-app-1", + "port": 8080, + "path": "/auth", + } + ] + } + + payload = build_update_targets_payload() + + self.assertTrue(payload["ok"]) + self.assertEqual(len(payload["items"]), 1) + self.assertEqual(payload["items"][0]["repository"], f"{HARBOR_REGISTRY}/{HARBOR_PROJECT}/java/chatapp3-auth") + if __name__ == "__main__": unittest.main()