344 lines
12 KiB
Python
Executable File
344 lines
12 KiB
Python
Executable File
#!/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"YUMI_INTERNAL_HOST={os.environ.get('YUMI_INTERNAL_HOST', '127.0.0.1')}",
|
||
f"YUMI_INTERNAL_PORT={os.environ.get('YUMI_INTERNAL_PORT', '2027')}",
|
||
f"HAIYI_INTERNAL_HOST={os.environ.get('HAIYI_INTERNAL_HOST', '127.0.0.1')}",
|
||
f"HAIYI_INTERNAL_PORT={os.environ.get('HAIYI_INTERNAL_PORT', '2028')}",
|
||
f"INTERNAL_PROXY_SHARED_SECRET={os.environ.get('INTERNAL_PROXY_SHARED_SECRET', 'hy-app-monitor-internal')}",
|
||
f"GATEWAY_PROXY_TIMEOUT_SECONDS={os.environ.get('GATEWAY_PROXY_TIMEOUT_SECONDS', '600')}",
|
||
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
|
||
|
||
restart_unit() {{
|
||
local unit="$1"
|
||
# 先清理一次可能残留的 start-limit 状态。
|
||
systemctl reset-failed "$unit" || true
|
||
# 优先直接重启;如果被限流或中途失败,再补一次 reset/start。
|
||
if ! systemctl restart "$unit"; then
|
||
systemctl reset-failed "$unit" || true
|
||
systemctl start "$unit"
|
||
fi
|
||
# 每个服务都单独校验 active,避免批量命令掩盖具体失败项。
|
||
systemctl is-active "$unit"
|
||
}}
|
||
|
||
restart_unit hy-app-monitor-yumi.service
|
||
restart_unit hy-app-monitor-haiyi.service
|
||
restart_unit 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()
|