fix release targets fallback and git-based ssh deploy
This commit is contained in:
parent
579e8a4aa5
commit
9cbb777674
@ -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
|
||||
|
||||
默认只刷新本地完整模板:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user