#!/usr/bin/env python3 from __future__ import annotations import argparse import base64 import json import os import shlex import site import sys import time from pathlib import Path from typing import Callable, TypeVar # 项目根目录,后面需要从这里读取 .env 和 targets.json。 ROOT = Path(__file__).resolve().parents[1] # 默认 .env 路径。 ENV_FILE = Path(os.environ.get("APP_ENV_FILE", ROOT / ".env")).expanduser() # 泛型只用于 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 # CVM 用来解析实例私网 IP 和安全组。 from tencentcloud.cvm.v20170312 import cvm_client, models as cvm_models # TAT 用来做一次性带外引导。 from tencentcloud.tat.v20201028 import models as tat_models, tat_client # VPC 用来写安全组规则。 from tencentcloud.vpc.v20170312 import models as vpc_models, vpc_client def load_env_file(path: Path) -> None: # .env 文件不存在时允许继续使用外部环境变量。 if not path.exists(): return # 逐行解析 KEY=VALUE。 for raw_line in path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) key = key.strip() 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) def env_truthy(name: str, default: bool = False) -> bool: # 兼容 .env 里常见的布尔字符串写法。 raw = os.environ.get(name) if raw is None: return default return raw.strip().lower() in {"1", "true", "yes", "on"} # 先加载项目根目录的 .env。 load_env_file(ENV_FILE) # 默认 deploy 实例 ID。 DEFAULT_DEPLOY_INSTANCE_ID = os.environ.get("DEPLOY_INSTANCE_ID", "").strip() # 默认区域。 DEFAULT_REGION = os.environ.get("DEPLOY_REGION", "").strip() # 默认监控目标文件。 TARGETS_FILE = Path(os.environ.get("TARGETS_FILE", ROOT / "config" / "targets.json")).expanduser() # deploy 机上生成的专用 SSH 私钥路径。 DEFAULT_REMOTE_SSH_KEY_PATH = os.environ.get("SSH_PRIVATE_KEY_PATH", "/opt/hy-app-monitor/run/ssh/id_ed25519").strip() # 默认远端 SSH 端口。 DEFAULT_SSH_PORT = int(os.environ.get("SSH_PORT", "22")) # 默认远端 SSH 用户。 DEFAULT_SSH_USER = os.environ.get("SSH_USER", "root").strip() or "root" # 独立 admin 前端目标机配置;配置齐备时会一并加入 SSH 引导。 ADMIN_FRONTEND_ENABLED = env_truthy("ADMIN_FRONTEND_ENABLED", False) ADMIN_FRONTEND_HOST = os.environ.get("ADMIN_FRONTEND_HOST", "").strip() ADMIN_FRONTEND_IP = os.environ.get("ADMIN_FRONTEND_IP", "").strip() ADMIN_FRONTEND_INSTANCE_ID = os.environ.get("ADMIN_FRONTEND_INSTANCE_ID", "").strip() # 独立 chatapp-cron 目标机配置;配置齐备时也会加入 SSH 引导。 CHATAPP_CRON_ENABLED = env_truthy("CHATAPP_CRON_ENABLED", False) CHATAPP_CRON_HOST = os.environ.get("CHATAPP_CRON_HOST", "").strip() CHATAPP_CRON_IP = os.environ.get("CHATAPP_CRON_IP", "").strip() CHATAPP_CRON_INSTANCE_ID = os.environ.get("CHATAPP_CRON_INSTANCE_ID", "").strip() def parse_args() -> argparse.Namespace: # 默认对 targets.json 里的所有实例做 SSH 引导。 parser = argparse.ArgumentParser(description="bootstrap deploy->target ssh connectivity via TAT") parser.add_argument("--hosts", nargs="*", default=[], help="target host names, default all hosts from targets.json") parser.add_argument("--deploy-instance-id", default=DEFAULT_DEPLOY_INSTANCE_ID, help="deploy machine instance id") parser.add_argument("--region", default=DEFAULT_REGION, help="tencent cloud region") parser.add_argument("--ssh-user", default=DEFAULT_SSH_USER, help="remote ssh login user, default root") parser.add_argument("--ssh-port", type=int, default=DEFAULT_SSH_PORT, help="remote ssh port") parser.add_argument( "--remote-key-path", default=DEFAULT_REMOTE_SSH_KEY_PATH, help="private key path generated on deploy machine", ) parser.add_argument( "--deploy-ip", default="", help="override deploy private ip used in security group and firewall rules", ) return parser.parse_args() 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_cvm(secret_id: str, secret_key: str, region: str) -> cvm_client.CvmClient: # 构造腾讯云凭据对象。 cred = credential.Credential(secret_id, secret_key) return cvm_client.CvmClient( cred, region, ClientProfile(httpProfile=HttpProfile(endpoint="cvm.tencentcloudapi.com")), ) def build_tat(secret_id: str, secret_key: str, region: str) -> tat_client.TatClient: # 构造腾讯云凭据对象。 cred = credential.Credential(secret_id, secret_key) return tat_client.TatClient( cred, region, ClientProfile(httpProfile=HttpProfile(endpoint="tat.tencentcloudapi.com")), ) def build_vpc(secret_id: str, secret_key: str, region: str) -> vpc_client.VpcClient: # 构造腾讯云凭据对象。 cred = credential.Credential(secret_id, secret_key) return vpc_client.VpcClient( cred, region, ClientProfile(httpProfile=HttpProfile(endpoint="vpc.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 describe_instances(client: cvm_client.CvmClient, instance_ids: list[str]) -> dict[str, dict[str, object]]: # 一次性把 deploy 和目标机实例详情取回来,减少 API 往返。 req = cvm_models.DescribeInstancesRequest() req.from_json_string(json.dumps({"InstanceIds": instance_ids})) resp = json.loads(invoke(lambda: client.DescribeInstances(req), "DescribeInstances").to_json_string()) items = resp.get("InstanceSet") or [] return { str(item.get("InstanceId") or ""): { "privateIp": str((item.get("PrivateIpAddresses") or [""])[0] or "").strip(), "securityGroupIds": [str(group_id) for group_id in (item.get("SecurityGroupIds") or []) if str(group_id)], "instanceName": str(item.get("InstanceName") or "").strip(), } for item in items } def load_target_hosts(host_names: list[str]) -> list[dict[str, str]]: # 读取 targets.json,按 host 名过滤后返回唯一实例列表。 if not TARGETS_FILE.exists(): raise SystemExit(f"targets file not found: {TARGETS_FILE}") payload = json.loads(TARGETS_FILE.read_text(encoding="utf-8")) configured = payload.get("hosts") or [] selected = {str(name).strip() for name in host_names if str(name).strip()} items: list[dict[str, str]] = [] seen_instance_ids: set[str] = set() for host in configured: host_name = str(host.get("name") or "").strip() instance_id = str(host.get("instanceId") or "").strip() if not host_name or not instance_id: continue if selected and host_name not in selected: continue if instance_id in seen_instance_ids: continue seen_instance_ids.add(instance_id) items.append( { "host": host_name, "ip": str(host.get("ip") or "").strip(), "instanceId": instance_id, } ) # Admin 前端机器不在 targets.json 时,也允许通过 .env 把它并进同一轮 SSH 引导。 if ADMIN_FRONTEND_ENABLED and ADMIN_FRONTEND_HOST and ADMIN_FRONTEND_INSTANCE_ID: if (not selected) or ADMIN_FRONTEND_HOST in selected: if ADMIN_FRONTEND_INSTANCE_ID not in seen_instance_ids: seen_instance_ids.add(ADMIN_FRONTEND_INSTANCE_ID) items.append( { "host": ADMIN_FRONTEND_HOST, "ip": ADMIN_FRONTEND_IP, "instanceId": ADMIN_FRONTEND_INSTANCE_ID, } ) # 定时任务机器也不在 targets.json 时,通过 .env 并入同一轮 SSH 引导。 if CHATAPP_CRON_ENABLED and CHATAPP_CRON_HOST and CHATAPP_CRON_INSTANCE_ID: if (not selected) or CHATAPP_CRON_HOST in selected: if CHATAPP_CRON_INSTANCE_ID not in seen_instance_ids: seen_instance_ids.add(CHATAPP_CRON_INSTANCE_ID) items.append( { "host": CHATAPP_CRON_HOST, "ip": CHATAPP_CRON_IP, "instanceId": CHATAPP_CRON_INSTANCE_ID, } ) return items def render_keygen_script(remote_key_path: str) -> str: # deploy 机上只生成一次专用 SSH key,后续运行链路直接复用。 return f"""set -euo pipefail KEY_PATH={json.dumps(remote_key_path)} KEY_DIR="$(dirname "$KEY_PATH")" mkdir -p "$KEY_DIR" chmod 700 "$KEY_DIR" if [ ! -f "$KEY_PATH" ]; then ssh-keygen -t ed25519 -N '' -C 'hy-app-monitor-deploy' -f "$KEY_PATH" >/dev/null fi touch "$KEY_DIR/known_hosts" chmod 600 "$KEY_PATH" "$KEY_PATH.pub" "$KEY_DIR/known_hosts" cat "$KEY_PATH.pub" """ def render_target_bootstrap_script(public_key: str, deploy_ip: str, ssh_user: str, ssh_port: int) -> str: # 目标机侧只做 SSH 基础设施准备,不夹带业务运维逻辑。 quoted_key = shlex.quote(public_key) quoted_ip = shlex.quote(deploy_ip) quoted_user = shlex.quote(ssh_user) root_login = "yes" if ssh_user == "root" else "no" if ssh_user == "root": principal_block = """ HOME_DIR=/root install -d -m 700 "$HOME_DIR/.ssh" touch "$HOME_DIR/.ssh/authorized_keys" chmod 600 "$HOME_DIR/.ssh/authorized_keys" grep -qxF "$PUBKEY" "$HOME_DIR/.ssh/authorized_keys" || printf '%s\\n' "$PUBKEY" >> "$HOME_DIR/.ssh/authorized_keys" """ else: principal_block = """ if ! id -u "$SSH_USER" >/dev/null 2>&1; then useradd -m -s /bin/bash "$SSH_USER" fi if getent group docker >/dev/null 2>&1; then usermod -aG docker "$SSH_USER" >/dev/null 2>&1 || true fi HOME_DIR="$(getent passwd "$SSH_USER" | cut -d: -f6)" install -d -o "$SSH_USER" -g "$SSH_USER" -m 700 "$HOME_DIR/.ssh" touch "$HOME_DIR/.ssh/authorized_keys" chown "$SSH_USER":"$SSH_USER" "$HOME_DIR/.ssh/authorized_keys" chmod 600 "$HOME_DIR/.ssh/authorized_keys" grep -qxF "$PUBKEY" "$HOME_DIR/.ssh/authorized_keys" || printf '%s\\n' "$PUBKEY" >> "$HOME_DIR/.ssh/authorized_keys" chown "$SSH_USER":"$SSH_USER" "$HOME_DIR/.ssh/authorized_keys" printf '%s\\n' "$SSH_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/hy-app-monitor-ssh-user chmod 440 /etc/sudoers.d/hy-app-monitor-ssh-user """ return f"""set -euo pipefail PUBKEY={quoted_key} DEPLOY_IP={quoted_ip} SSH_USER={quoted_user} SSH_PORT={int(ssh_port)} DEPLOY_CIDR="${{DEPLOY_IP}}/32" if ! command -v sshd >/dev/null 2>&1; then if command -v apt-get >/dev/null 2>&1; then export DEBIAN_FRONTEND=noninteractive apt-get update -y >/dev/null 2>&1 || true apt-get install -y openssh-server >/dev/null elif command -v dnf >/dev/null 2>&1; then dnf install -y openssh-server >/dev/null elif command -v yum >/dev/null 2>&1; then yum install -y openssh-server >/dev/null else echo "openssh-server unavailable" >&2 exit 1 fi fi {principal_block} mkdir -p /etc/ssh/sshd_config.d if ! grep -Eq '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\\.d/\\*\\.conf' /etc/ssh/sshd_config; then printf '\\nInclude /etc/ssh/sshd_config.d/*.conf\\n' >> /etc/ssh/sshd_config fi cat > /etc/ssh/sshd_config.d/hy-app-monitor.conf </dev/null 2>&1; then firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=${{DEPLOY_CIDR}} port protocol=tcp port=${{SSH_PORT}} accept" >/dev/null 2>&1 || true firewall-cmd --reload >/dev/null 2>&1 || true fi if command -v ufw >/dev/null 2>&1; then ufw allow from "${{DEPLOY_IP}}" to any port "${{SSH_PORT}}" proto tcp >/dev/null 2>&1 || true fi if command -v iptables >/dev/null 2>&1; then iptables -C INPUT -s "${{DEPLOY_CIDR}}" -p tcp --dport "${{SSH_PORT}}" -j ACCEPT >/dev/null 2>&1 || iptables -I INPUT -s "${{DEPLOY_CIDR}}" -p tcp --dport "${{SSH_PORT}}" -j ACCEPT || true fi if command -v sshd >/dev/null 2>&1; then sshd -t fi if systemctl list-unit-files sshd.service >/dev/null 2>&1; then systemctl enable sshd >/dev/null 2>&1 || true systemctl restart sshd elif systemctl list-unit-files ssh.service >/dev/null 2>&1; then systemctl enable ssh >/dev/null 2>&1 || true systemctl restart ssh else echo "ssh systemd service not found" >&2 exit 1 fi echo "ssh_bootstrap_ok user=${{SSH_USER}} port=${{SSH_PORT}}" """ def run_tat_script(client: tat_client.TatClient, instance_id: str, script: str, timeout_seconds: int = 600) -> str: # 创建执行命令请求。 req = tat_models.RunCommandRequest() req.from_json_string( json.dumps( { "CommandName": "hy-app-monitor-bootstrap-ssh", "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 = resp["InvocationId"] deadline = time.time() + timeout_seconds + 120 # 轮询远端执行结果。 while time.time() < deadline: time.sleep(3) query = tat_models.DescribeInvocationTasksRequest() 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.get("TaskStatus") or "" 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 ensure_security_group_rule( client: vpc_client.VpcClient, security_group_id: str, *, deploy_ip: str, ssh_port: int, ) -> str: # 安全组只允许 deploy 私网 IP 访问 22,避免直接对整段网开放 SSH。 req = vpc_models.CreateSecurityGroupPoliciesRequest() req.from_json_string( json.dumps( { "SecurityGroupId": security_group_id, "SecurityGroupPolicySet": { "Ingress": [ { "Protocol": "TCP", "Port": str(ssh_port), "CidrBlock": f"{deploy_ip}/32", "Action": "ACCEPT", "PolicyDescription": f"hy-app-monitor ssh {deploy_ip}", } ] }, } ) ) try: invoke(lambda: client.CreateSecurityGroupPolicies(req), "CreateSecurityGroupPolicies") return "created" except TencentCloudSDKException as exc: if "Duplicate" in str(exc) or "already exists" in str(exc): return "exists" raise def prepare_deploy_public_key( client: tat_client.TatClient, deploy_instance_id: str, remote_key_path: str, ) -> str: # deploy 机上生成完专用 key 后,把公钥文本拿回来继续给目标机铺设 authorized_keys。 output = run_tat_script(client, deploy_instance_id, render_keygen_script(remote_key_path), timeout_seconds=300) public_key = output.strip().splitlines()[-1].strip() if not public_key.startswith("ssh-"): raise SystemExit(f"invalid public key output: {public_key or output.strip()}") return public_key def main() -> None: args = parse_args() if not args.deploy_instance_id: raise SystemExit("deploy instance id is required; set DEPLOY_INSTANCE_ID or pass --deploy-instance-id") if not args.region: raise SystemExit("deploy region is required; set DEPLOY_REGION or pass --region") secret_id = env_required("TENCENT_SECRET_ID") secret_key = env_required("TENCENT_SECRET_KEY") targets = load_target_hosts(args.hosts) if not targets: raise SystemExit("no target hosts found in config/targets.json") cvm = build_cvm(secret_id, secret_key, args.region) tat = build_tat(secret_id, secret_key, args.region) vpc = build_vpc(secret_id, secret_key, args.region) instance_ids = [args.deploy_instance_id, *[target["instanceId"] for target in targets]] instance_map = describe_instances(cvm, instance_ids) deploy_detail = instance_map.get(args.deploy_instance_id) if not deploy_detail: raise SystemExit(f"deploy instance not found: {args.deploy_instance_id}") deploy_ip = str(args.deploy_ip or deploy_detail.get("privateIp") or "").strip() if not deploy_ip: raise SystemExit(f"missing deploy private ip: {args.deploy_instance_id}") public_key = prepare_deploy_public_key(tat, args.deploy_instance_id, args.remote_key_path) results: list[dict[str, object]] = [] for target in targets: detail = instance_map.get(target["instanceId"]) if not detail: raise SystemExit(f"instance not found: {target['instanceId']}") output = run_tat_script( tat, target["instanceId"], render_target_bootstrap_script(public_key, deploy_ip, args.ssh_user, args.ssh_port), timeout_seconds=600, ).strip() security_groups = list(detail.get("securityGroupIds") or []) sg_results = [ { "securityGroupId": security_group_id, "status": ensure_security_group_rule( vpc, security_group_id, deploy_ip=deploy_ip, ssh_port=args.ssh_port, ), } for security_group_id in security_groups ] results.append( { "host": target["host"], "instanceId": target["instanceId"], "ip": target["ip"] or detail.get("privateIp") or "", "sshUser": args.ssh_user, "sshPort": args.ssh_port, "bootstrap": output, "securityGroups": sg_results, } ) print( json.dumps( { "ok": True, "deployInstanceId": args.deploy_instance_id, "deployIp": deploy_ip, "remoteKeyPath": args.remote_key_path, "sshUser": args.ssh_user, "sshPort": args.ssh_port, "targets": results, }, ensure_ascii=False, indent=2, ) ) if __name__ == "__main__": main()