112 lines
4.6 KiB
Python
112 lines
4.6 KiB
Python
"""阿里云适配器(含国际版,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
|