"""腾讯云适配器(含国际版,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