126 lines
5.4 KiB
Python
126 lines
5.4 KiB
Python
"""腾讯云适配器(含国际版,CVM OpenAPI + TC3-HMAC-SHA256 签名)
|
||
|
||
API 文档:https://cloud.tencent.com/document/api/213/15753
|
||
所需配置:{"secret_id": "...", "secret_key": "...", "region": "ap-guangzhou"}
|
||
国际版(intl)将 region 设为海外区域即可,端点统一为 cvm.tencentcloudapi.com。
|
||
说明:此处实现 TC3-HMAC-SHA256 签名,可直接调用 CVM OpenAPI;
|
||
生产环境也可替换为官方 tencentcloud-sdk-python,接口契约保持不变。
|
||
"""
|
||
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import time
|
||
|
||
import httpx
|
||
|
||
from app.adapters.base import BaseAdapter, NormalizedVPS
|
||
from app.adapters.registry import register
|
||
|
||
|
||
@register("tencent-sdk", "tencent-intl-sdk")
|
||
class TencentAdapter(BaseAdapter):
|
||
required_config = ["secret_id", "secret_key"]
|
||
HOST = "cvm.tencentcloudapi.com"
|
||
SERVICE = "cvm"
|
||
VERSION = "2017-03-12"
|
||
ALGORITHM = "TC3-HMAC-SHA256"
|
||
|
||
def _region(self) -> str:
|
||
return self.config.get("region") or "ap-guangzhou"
|
||
|
||
@staticmethod
|
||
def _hmac_sha256(key: bytes, msg: str) -> bytes:
|
||
return hmac.new(key, msg.encode(), hashlib.sha256).digest()
|
||
|
||
def _build_authorization(self, action: str, payload_json: str, timestamp: int, date: str):
|
||
secret_id = self.config.get("secret_id", "")
|
||
secret_key = self.config.get("secret_key", "")
|
||
content_type = "application/json; charset=utf-8"
|
||
canonical_headers = (
|
||
f"content-type:{content_type}\nhost:{self.HOST}\nx-tc-action:{action.lower()}\n"
|
||
)
|
||
signed_headers = "content-type;host;x-tc-action"
|
||
hashed_payload = hashlib.sha256(payload_json.encode()).hexdigest()
|
||
canonical_request = (
|
||
f"POST\n/\n\n{canonical_headers}\n{signed_headers}\n{hashed_payload}"
|
||
)
|
||
credential_scope = f"{date}/{self.SERVICE}/tc3_request"
|
||
hashed_canonical = hashlib.sha256(canonical_request.encode()).hexdigest()
|
||
string_to_sign = (
|
||
f"{self.ALGORITHM}\n{timestamp}\n{credential_scope}\n{hashed_canonical}"
|
||
)
|
||
secret_date = self._hmac_sha256(("TC3" + secret_key).encode(), date)
|
||
secret_service = self._hmac_sha256(secret_date, self.SERVICE)
|
||
secret_signing = self._hmac_sha256(secret_service, "tc3_request")
|
||
signature = hmac.new(secret_signing, string_to_sign.encode(), hashlib.sha256).hexdigest()
|
||
return (
|
||
f"{self.ALGORITHM} Credential={secret_id}/{credential_scope}, "
|
||
f"SignedHeaders={signed_headers}, Signature={signature}"
|
||
)
|
||
|
||
def _call(self, action: str, payload: dict = None) -> dict:
|
||
payload_json = json.dumps(payload or {})
|
||
timestamp = int(time.time())
|
||
date = time.strftime("%Y-%m-%d", time.gmtime(timestamp))
|
||
authorization = self._build_authorization(action, payload_json, timestamp, date)
|
||
headers = {
|
||
"Authorization": authorization,
|
||
"Content-Type": "application/json; charset=utf-8",
|
||
"Host": self.HOST,
|
||
"X-TC-Action": action,
|
||
"X-TC-Timestamp": str(timestamp),
|
||
"X-TC-Version": self.VERSION,
|
||
"X-TC-Region": self._region(),
|
||
}
|
||
with httpx.Client(timeout=30) as client:
|
||
resp = client.post(f"https://{self.HOST}", content=payload_json, headers=headers)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
response = data.get("Response", {})
|
||
if response.get("Error"):
|
||
err = response["Error"]
|
||
raise RuntimeError(f"{err.get('Code')}: {err.get('Message')}")
|
||
return response
|
||
|
||
def test_connection(self) -> dict:
|
||
try:
|
||
data = self._call("DescribeRegions")
|
||
count = len(data.get("RegionSet", []))
|
||
return {"ok": True, "message": f"连接成功,可用区域 {count} 个"}
|
||
except httpx.HTTPStatusError as e:
|
||
return {"ok": False, "message": f"HTTP {e.response.status_code}:凭证无效"}
|
||
except Exception as e: # noqa: BLE001
|
||
return {"ok": False, "message": str(e)}
|
||
|
||
def list_vps(self) -> list:
|
||
# 分页拉取:单次最多 100 条,按 TotalCount 翻页,避免实例超 100 台时漏同步
|
||
result = []
|
||
status_map = {"RUNNING": "active", "STOPPED": "stopped"}
|
||
offset = 0
|
||
while True:
|
||
data = self._call("DescribeInstances", {"Limit": 100, "Offset": offset})
|
||
inst_set = data.get("InstanceSet", [])
|
||
for inst in inst_set:
|
||
public_ips = inst.get("PublicIpAddresses", [])
|
||
result.append(
|
||
NormalizedVPS(
|
||
external_id=inst.get("InstanceId"),
|
||
name=inst.get("InstanceName") or inst.get("InstanceId"),
|
||
ip_address=public_ips[0] if public_ips else None,
|
||
region=inst.get("Placement", {}).get("Zone"),
|
||
os=inst.get("OsName"),
|
||
cpu_cores=inst.get("CPU"),
|
||
memory_gb=inst.get("Memory"),
|
||
disk_gb=inst.get("SystemDisk", {}).get("DiskSize"),
|
||
status=status_map.get(inst.get("InstanceState"), inst.get("InstanceState") or "unknown"),
|
||
currency="CNY",
|
||
raw=inst,
|
||
)
|
||
)
|
||
total = int(data.get("TotalCount") or 0)
|
||
offset += len(inst_set)
|
||
if not inst_set or offset >= total:
|
||
break
|
||
return result
|