#!/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()