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

371 lines
13 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_PORT = int(os.environ.get("OPEN_PORT", "2026"))
# 默认允许所有来源访问该端口。
DEFAULT_CIDR = os.environ.get("OPEN_CIDR", "0.0.0.0/0")
# 泛型只用于 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 用来查实例绑定的安全组。
from tencentcloud.cvm.v20170312 import cvm_client, models as cvm_models
# 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
# 逐行解析 shell 风格的 KEY=VALUE。
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]
# 只有环境里还没有这个 key 时才写入。
os.environ.setdefault(key, value)
# 先从项目根目录加载 .env。
load_env_file(ENV_FILE)
def env_required(name: str) -> str:
# 读取环境变量并去掉两端空白。
value = os.environ.get(name, "").strip()
# 缺失时直接退出,避免后面报一串 SDK 错误。
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)
# 返回 CVM 客户端。
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)
# 返回 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
# 最后一次重试不再 sleep直接退出循环。
if attempt == retries:
break
# 简单线性退避,降低偶发 EOF 的影响。
time.sleep(delay_seconds * attempt)
# 有异常就把最后一次异常抛出去。
if last_error is not None:
raise last_error
# 理论上不会到这里,这里只是兜底。
raise RuntimeError(f"{label} failed without error")
def resolve_security_group_id(client: cvm_client.CvmClient, instance_id: str) -> str:
# 创建查询实例详情的请求对象。
req = cvm_models.DescribeInstancesRequest()
# 只查询目标实例。
req.from_json_string(json.dumps({"InstanceIds": [instance_id]}))
# 发起实例查询请求。
resp = json.loads(invoke(lambda: client.DescribeInstances(req), "DescribeInstances").to_json_string())
# 拿到实例数组。
instances = resp.get("InstanceSet") or []
# 实例不存在时直接退出。
if not instances:
raise SystemExit(f"instance not found: {instance_id}")
# 读取第一个绑定安全组。
security_group_ids = instances[0].get("SecurityGroupIds") or []
# 没有绑定安全组时直接退出。
if not security_group_ids:
raise SystemExit(f"no security group attached: {instance_id}")
# 返回第一个安全组 ID。
return str(security_group_ids[0])
def remote_script(secret_id: str, secret_key: str, region: str, security_group_id: str, port: int, cidr: str) -> str:
# 把必要参数序列化后再 base64方便塞进远端 Python 脚本。
payload = {
"secret_id": secret_id,
"secret_key": secret_key,
"region": region,
"security_group_id": security_group_id,
"port": port,
"cidr": cidr,
}
# 远端脚本里直接解码这个 blob。
blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
# 返回完整远端 shell 脚本。
return f"""set -euo pipefail
if command -v firewall-cmd >/dev/null 2>&1; then
firewall-cmd --add-port={port}/tcp --permanent >/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 {port}/tcp >/dev/null 2>&1 || true
fi
if command -v iptables >/dev/null 2>&1; then
iptables -C INPUT -p tcp --dport {port} -j ACCEPT >/dev/null 2>&1 || iptables -I INPUT -p tcp --dport {port} -j ACCEPT || true
fi
python3 -m pip install --quiet --disable-pip-version-check tencentcloud-sdk-python >/dev/null 2>&1 || true
python3 - <<'PY'
import base64
import json
from tencentcloud.common import credential
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.vpc.v20170312 import models, vpc_client
# 读取本次放行需要的参数。
payload = json.loads(base64.b64decode({blob!r}).decode('utf-8'))
# 构造凭据对象。
cred = credential.Credential(payload['secret_id'], payload['secret_key'])
# 初始化 VPC 客户端,用来写安全组规则。
client = vpc_client.VpcClient(
cred,
payload['region'],
ClientProfile(httpProfile=HttpProfile(endpoint='vpc.tencentcloudapi.com')),
)
# 构造创建安全组入站规则的请求。
req = models.CreateSecurityGroupPoliciesRequest()
req.from_json_string(json.dumps({{
'SecurityGroupId': payload['security_group_id'],
'SecurityGroupPolicySet': {{
'Ingress': [{{
'Protocol': 'TCP',
'Port': str(payload['port']),
'CidrBlock': payload['cidr'],
'Action': 'ACCEPT',
'PolicyDescription': f"hy-app-monitor {{payload['port']}}",
}}]
}}
}}))
try:
# 创建安全组规则。
client.CreateSecurityGroupPolicies(req)
print('security_group_rule=created')
except TencentCloudSDKException as exc:
# 已存在时按成功处理,避免重复调用失败。
if 'Duplicate' in str(exc) or 'already exists' in str(exc):
print('security_group_rule=exists')
else:
raise
PY
echo "port_opened={port}"
"""
def run_tat_script(client: tat_client.TatClient, instance_id: str, script: str, timeout_seconds: int = 300) -> str:
# 创建执行命令请求对象。
req = tat_models.RunCommandRequest()
# 把 shell 脚本下发到目标实例执行。
req.from_json_string(
json.dumps(
{
"CommandName": "hy-app-monitor-open-port",
"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:
# 两次轮询之间稍微停一下。
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")
# 非 SUCCESS 直接退出并带上远端输出。
if status != "SUCCESS":
raise SystemExit(output or f"TAT task failed: {status}")
# SUCCESS 时返回远端输出。
return output
# 超过截止时间还没完成就直接报超时。
raise SystemExit("TAT task timed out")
def main() -> None:
# 从 .env 或外部环境中读取腾讯云密钥。
secret_id = env_required("TENCENT_SECRET_ID")
# 从 .env 或外部环境中读取腾讯云密钥。
secret_key = env_required("TENCENT_SECRET_KEY")
# 读取目标实例 ID。
instance_id = os.environ.get("DEPLOY_INSTANCE_ID", DEFAULT_INSTANCE_ID)
# 读取区域。
region = os.environ.get("DEPLOY_REGION", DEFAULT_REGION)
# 读取待放行端口。
port = int(os.environ.get("OPEN_PORT", str(DEFAULT_PORT)))
# 读取允许访问的网段。
cidr = os.environ.get("OPEN_CIDR", DEFAULT_CIDR)
# 初始化 CVM 客户端,用来查安全组。
cvm = build_cvm(secret_id, secret_key, region)
# 初始化 TAT 客户端,用来远程执行脚本。
tat = build_tat(secret_id, secret_key, region)
# 优先用显式指定的安全组,否则自动从实例上解析。
security_group_id = os.environ.get("SECURITY_GROUP_ID", "").strip() or resolve_security_group_id(cvm, instance_id)
# 远程执行端口放行脚本。
output = run_tat_script(tat, instance_id, remote_script(secret_id, secret_key, region, security_group_id, port, cidr))
# 打印实例 ID 方便确认目标机器。
print(f"instance_id={instance_id}")
# 打印实际使用的安全组 ID。
print(f"security_group_id={security_group_id}")
# 打印远端执行结果。
print(output.strip())
if __name__ == "__main__":
# 作为脚本执行时直接走主流程。
main()