"""阿里云适配器(含国际版,OpenAPI RPC 风格 + HMAC-SHA1 签名) API 文档:https://help.aliyun.com/document_detail/25484.html 所需配置:{"access_key_id": "...", "access_key_secret": "...", "region": "cn-hangzhou"} 国际版(alibabacloud)只需将 region 设为海外区域(如 ap-southeast-1),端点自动按区域构造。 说明:此处实现 RPC V1 签名(HMAC-SHA1),可直接调用 ECS OpenAPI; 生产环境也可替换为官方 alibabacloud-ecs SDK,接口契约保持不变。 """ import base64 import hashlib import hmac import time import uuid from urllib.parse import quote import httpx from app.adapters.base import BaseAdapter, NormalizedVPS from app.adapters.registry import register @register("aliyun-sdk", "alibabacloud-sdk") class AliyunAdapter(BaseAdapter): required_config = ["access_key_id", "access_key_secret"] API_VERSION = "2014-05-26" def _region(self) -> str: return self.config.get("region") or "cn-hangzhou" def _endpoint(self) -> str: return f"https://ecs.{self._region()}.aliyuncs.com" @staticmethod def _percent_encode(value) -> str: return quote(str(value), safe="~") def _sign(self, params: dict, method: str = "GET") -> str: canonical = "&".join( f"{self._percent_encode(k)}={self._percent_encode(v)}" for k, v in sorted(params.items()) ) string_to_sign = f"{method}&{self._percent_encode('/')}&{self._percent_encode(canonical)}" key = (self.config.get("access_key_secret", "") + "&").encode() digest = hmac.new(key, string_to_sign.encode(), hashlib.sha1).digest() return base64.b64encode(digest).decode() def _call(self, action: str, biz_params: dict = None) -> dict: params = { "Format": "JSON", "Version": self.API_VERSION, "AccessKeyId": self.config.get("access_key_id", ""), "SignatureMethod": "HMAC-SHA1", "Timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "SignatureVersion": "1.0", "SignatureNonce": str(uuid.uuid4()), "Action": action, "RegionId": self._region(), } if biz_params: params.update(biz_params) params["Signature"] = self._sign(params) with httpx.Client(timeout=30) as client: resp = client.get(self._endpoint(), params=params) resp.raise_for_status() data = resp.json() if data.get("Code"): raise RuntimeError(f"{data.get('Code')}: {data.get('Message')}") return data def test_connection(self) -> dict: try: data = self._call("DescribeRegions") count = len(data.get("Regions", {}).get("Region", [])) 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 = [] page = 1 while True: data = self._call("DescribeInstances", {"PageSize": "100", "PageNumber": str(page)}) instances = data.get("Instances", {}).get("Instance", []) for inst in instances: public_ips = inst.get("PublicIpAddress", {}).get("IpAddress", []) eip = inst.get("EipAddress", {}).get("IpAddress") mem_mb = inst.get("Memory") or 0 status_map = {"Running": "active", "Stopped": "stopped"} result.append( NormalizedVPS( external_id=inst.get("InstanceId"), name=inst.get("InstanceName") or inst.get("InstanceId"), ip_address=eip or (public_ips[0] if public_ips else None), region=inst.get("RegionId"), os=inst.get("OSName"), cpu_cores=inst.get("Cpu"), memory_gb=round(mem_mb / 1024, 1) if mem_mb else None, status=status_map.get(inst.get("Status"), inst.get("Status") or "unknown"), currency="CNY", raw=inst, ) ) total = int(data.get("TotalCount") or 0) if not instances or page * 100 >= total: break page += 1 return result