Files

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