hy-app-monitor/ops/deploy_via_tat.py
2026-04-22 14:39:40 +08:00

323 lines
11 KiB
Python
Executable File
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 base64
import json
import os
import site
import sys
import time
from pathlib import Path
from typing import Callable, TypeVar
# 项目根目录,后面要从这里读取 .env。
ROOT = Path(__file__).resolve().parents[1]
# 默认 .env 路径。
ENV_FILE = Path(os.environ.get("APP_ENV_FILE", ROOT / ".env")).expanduser()
# 默认 deploy 机器实例 ID。
DEFAULT_INSTANCE_ID = os.environ.get("DEPLOY_INSTANCE_ID", "ins-ntmpcd3a")
# 默认区域。
DEFAULT_REGION = os.environ.get("DEPLOY_REGION", "me-saudi-arabia")
# 默认仓库地址。
DEFAULT_REPO_URL = os.environ.get("MONITOR_REPO_URL", "git@gitea.haiyihy.com:hy/hy-app-monitor.git")
# 默认分支。
DEFAULT_REPO_REF = os.environ.get("MONITOR_REPO_REF", "main")
# 默认远端部署目录。
DEFAULT_REMOTE_DIR = os.environ.get("MONITOR_REMOTE_DIR", "/opt/hy-app-monitor")
# 默认应用端口。
DEFAULT_APP_PORT = os.environ.get("APP_PORT", "2026")
# 泛型只用于 invoke 的返回类型提示。
T = TypeVar("T")
def load_project_venv() -> None:
# 如果当前本来就在项目虚拟环境里运行,就不需要重复处理。
if str(ROOT / ".venv") in sys.prefix:
return
# 遍历项目 .venv 下可能存在的 site-packages 目录。
for candidate in (ROOT / ".venv" / "lib").glob("python*/site-packages"):
# 把依赖目录加入解释器搜索路径。
site.addsitedir(str(candidate))
# 先注入项目虚拟环境,再导入腾讯云 SDK。
load_project_venv()
# 腾讯云凭据对象。
from tencentcloud.common import credential
# 腾讯云 SDK 统一异常类型。
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
# 客户端公共配置。
from tencentcloud.common.profile.client_profile import ClientProfile
# HTTP endpoint 配置。
from tencentcloud.common.profile.http_profile import HttpProfile
# TAT 客户端和模型。
from tencentcloud.tat.v20201028 import models as tat_models, tat_client
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:
continue
# 注释直接跳过。
if line.startswith("#"):
continue
# 没有等号的无效行直接跳过。
if "=" not in line:
continue
# 只按第一个等号拆分。
key, value = line.split("=", 1)
# 清理 key 空白。
key = key.strip()
# 清理 value 空白。
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。
load_env_file(ENV_FILE)
def env_required(name: str) -> str:
# 读取环境变量并去掉两端空白。
value = os.environ.get(name, "").strip()
# 缺失时直接退出。
if not value:
raise SystemExit(f"missing env: {name}")
# 返回有效值。
return value
def build_tat(secret_id: str, secret_key: str, region: str) -> tat_client.TatClient:
# 构造腾讯云凭据对象。
cred = credential.Credential(secret_id, secret_key)
# 返回 TAT 客户端。
return tat_client.TatClient(
cred,
region,
ClientProfile(httpProfile=HttpProfile(endpoint="tat.tencentcloudapi.com")),
)
def invoke(action: Callable[[], T], label: str, retries: int = 4, delay_seconds: int = 2) -> T:
# 保存最后一次异常,便于最终抛出。
last_error: Exception | None = None
# 按给定次数重试。
for attempt in range(1, retries + 1):
try:
# 成功时直接返回。
return action()
except TencentCloudSDKException as exc:
# 保存最后一次异常。
last_error = exc
# 最后一次不再等待。
if attempt == retries:
break
# 做一个简单线性退避。
time.sleep(delay_seconds * attempt)
# 有异常时直接抛出最后一次异常。
if last_error is not None:
raise last_error
# 理论上不会执行到这里,只做兜底。
raise RuntimeError(f"{label} failed without error")
def load_runtime_env() -> str:
# 如果项目根目录已经有 .env就直接把整个文件同步到远端。
if ENV_FILE.exists():
return ENV_FILE.read_text(encoding="utf-8")
# 没有 .env 时,就从当前环境里兜底构造一个最小版本。
lines = [
f"APP_BIND={os.environ.get('APP_BIND', '0.0.0.0')}",
f"APP_PORT={os.environ.get('APP_PORT', DEFAULT_APP_PORT)}",
f"PROBE_TIMEOUT_SECONDS={os.environ.get('PROBE_TIMEOUT_SECONDS', '3')}",
f"PROBE_CACHE_TTL_SECONDS={os.environ.get('PROBE_CACHE_TTL_SECONDS', '5')}",
f"PROBE_MAX_WORKERS={os.environ.get('PROBE_MAX_WORKERS', '12')}",
f"HOST_METRIC_CACHE_TTL_SECONDS={os.environ.get('HOST_METRIC_CACHE_TTL_SECONDS', '25')}",
f"TAT_METRIC_TIMEOUT_SECONDS={os.environ.get('TAT_METRIC_TIMEOUT_SECONDS', '30')}",
f"TAT_CPU_SAMPLE_SECONDS={os.environ.get('TAT_CPU_SAMPLE_SECONDS', '1')}",
f"DEPLOY_REGION={os.environ.get('DEPLOY_REGION', DEFAULT_REGION)}",
f"TENCENT_SECRET_ID={env_required('TENCENT_SECRET_ID')}",
f"TENCENT_SECRET_KEY={env_required('TENCENT_SECRET_KEY')}",
]
# 返回最终 .env 文本内容。
return "\n".join(lines) + "\n"
def remote_script(repo_url: str, repo_ref: str, remote_dir: str, app_port: str, runtime_env_text: str) -> str:
# 把 .env 文本做 base64 编码,远端可以无损写入。
env_blob = base64.b64encode(runtime_env_text.encode("utf-8")).decode("ascii")
# 返回完整远端部署脚本。
return f"""set -euo pipefail
REPO_URL={json.dumps(repo_url)}
REPO_REF={json.dumps(repo_ref)}
REMOTE_DIR={json.dumps(remote_dir)}
APP_PORT={json.dumps(app_port)}
ENV_BLOB={json.dumps(env_blob)}
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 --prune
git -C "$REMOTE_DIR" checkout "$REPO_REF"
git -C "$REMOTE_DIR" pull --ff-only origin "$REPO_REF"
else
rm -rf "$REMOTE_DIR"
git clone --branch "$REPO_REF" "$REPO_URL" "$REMOTE_DIR"
fi
cd "$REMOTE_DIR"
python3 - <<'PY'
import base64
from pathlib import Path
# 把本地同步过来的 .env 内容写入远端项目根目录。
content = base64.b64decode({json.dumps(env_blob)}).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 restart hy-app-monitor.service
systemctl is-active hy-app-monitor.service
ss -lntp | grep ":$APP_PORT" || true
"""
def run_tat_script(client: tat_client.TatClient, instance_id: str, script: str, timeout_seconds: int = 600) -> str:
# 创建执行命令请求。
req = tat_models.RunCommandRequest()
# 把远端 shell 脚本下发到 deploy 机器执行。
req.from_json_string(
json.dumps(
{
"CommandName": "hy-app-monitor-deploy",
"CommandType": "SHELL",
"Content": base64.b64encode(script.encode("utf-8")).decode("ascii"),
"InstanceIds": [instance_id],
"Timeout": timeout_seconds,
"WorkingDirectory": "/root",
}
)
)
# 发起命令执行。
resp = json.loads(invoke(lambda: client.RunCommand(req), "RunCommand").to_json_string())
# 获取 invocation id。
invocation_id = resp["InvocationId"]
# 轮询截止时间。
deadline = time.time() + timeout_seconds + 120
# 开始轮询远端执行结果。
while time.time() < deadline:
# 两次轮询之间等待 3 秒。
time.sleep(3)
# 构造查询请求。
query = tat_models.DescribeInvocationTasksRequest()
# 按 invocation id 过滤本次任务。
query.from_json_string(
json.dumps(
{
"HideOutput": False,
"Limit": 20,
"Filters": [{"Name": "invocation-id", "Values": [invocation_id]}],
}
)
)
# 查询执行结果。
data = json.loads(invoke(lambda: client.DescribeInvocationTasks(query), "DescribeInvocationTasks").to_json_string())
# 拿到任务列表。
tasks = data.get("InvocationTaskSet") or []
# 任务还没生成时继续等。
if not tasks:
continue
# 这里只打单实例,所以取第一条即可。
task = tasks[0]
# 读取当前任务状态。
status = task["TaskStatus"]
# 进入终态时退出轮询。
if status in {"SUCCESS", "FAILED", "TIMEOUT", "CANCELLED", "START_FAILED"}:
# 解码标准输出。
output = base64.b64decode(task.get("TaskResult", {}).get("Output", "")).decode("utf-8", errors="replace")
# 非成功状态直接退出。
if status != "SUCCESS":
raise SystemExit(output or f"TAT task failed: {status}")
# 成功时返回远端输出。
return output
# 轮询超时则退出。
raise SystemExit("TAT task timed out")
def main() -> None:
# 读取腾讯云密钥。
secret_id = env_required("TENCENT_SECRET_ID")
# 读取腾讯云密钥。
secret_key = env_required("TENCENT_SECRET_KEY")
# 读取 deploy 实例 ID。
instance_id = os.environ.get("DEPLOY_INSTANCE_ID", DEFAULT_INSTANCE_ID)
# 读取区域。
region = os.environ.get("DEPLOY_REGION", DEFAULT_REGION)
# 读取仓库地址。
repo_url = os.environ.get("MONITOR_REPO_URL", DEFAULT_REPO_URL)
# 读取仓库分支。
repo_ref = os.environ.get("MONITOR_REPO_REF", DEFAULT_REPO_REF)
# 读取远端部署目录。
remote_dir = os.environ.get("MONITOR_REMOTE_DIR", DEFAULT_REMOTE_DIR)
# 读取服务端口。
app_port = os.environ.get("APP_PORT", DEFAULT_APP_PORT)
# 读取要同步到远端的 .env 内容。
runtime_env_text = load_runtime_env()
# 初始化 TAT 客户端。
tat = build_tat(secret_id, secret_key, region)
# 远端执行部署脚本。
output = run_tat_script(tat, instance_id, remote_script(repo_url, repo_ref, remote_dir, app_port, runtime_env_text))
# 打印远端输出,便于观察部署结果。
print(output.strip())
if __name__ == "__main__":
# 作为脚本执行时进入主流程。
main()