From 46faadbcaed22f4eabe9bcc03bae0b93b8e4b7d8 Mon Sep 17 00:00:00 2001 From: gouki Date: Mon, 12 Oct 2026 00:43:37 +0000 Subject: [PATCH] =?UTF-8?q?feat(multicloud):=20=E8=A1=A5=E9=BD=90=E4=B8=8A?= =?UTF-8?q?=E7=BA=BF=E5=89=8D=E7=9A=84=E5=A4=9A=E4=BA=91=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=BC=BA=E5=8F=A3=EF=BC=88=E9=80=82=E9=85=8D=E5=99=A8/?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E5=B1=82/DNS/=E5=AE=9A=E6=97=B6=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐: 适配层 - 新增 aws / linode / namesilo 适配器,registry 登记;阿里云、腾讯云补 list_domains 与 get_account 余额 - NormalizedVPS 补 expiry_date + charge_type:此前云端同步进来的 VPS 拿不到 到期日,永远进不了续费提醒——而续费提醒正是这套台账存在的理由 - base.py 统一云端错误可读化(http_error_message / cloud_error_text):各厂商 把原因放的位置都不同(Linode errors[].reason、Cloudflare errors[].message、 腾讯 Response.Error.Message、AWS XML),此前直接 str(e) 会把 httpx 的多行文本 连同 MDN 链接弹给用户 动作层与 DNS - 新增 /api/actions:实例启停/重启/续费,写操作全部二次确认 + 审计 - 新增 Cloudflare DNS 记录的增删改查 定时同步 - scripts/sync_all_assets.py + /api/sync/assets + vps-sync-assets.timer(08:30) - 08:30 而非随机时刻:必须早于 09:00 的续费检查,否则提醒读到上一天的旧到期日 - sync_all_assets 修正计数:每一项子同步都失败时不再计为「成功」,否则凭证过期 会显示「成功 1」,用户以为数据拉到了、实际一条没有 数据一致性 - AssetRead 输出 external_id(只出不进):前端「云操作」「DNS」按钮靠它渲染, 缺了则所有按钮永远出不来 - import_assets 落库 external_id:导出带了去重键而导入丢弃,会让迁移后首次同步 把所有云端资产当新增再建一份 - _align_preset_providers 取代 _backfill_provider_services:库里声明但注册表未 实现的 sdk_type 清空(声明支持却不支持,比明说不支持更糟),services 只增量 补标签不覆盖,用户自建平台不动 前端如实展示 - cost 为 0 统一显示「未填」而非「0 USD」:0 既可能是没填、也可能是云端不返回 价格,而汇总处又把 0 当「无费用」排除,两处口径不一致 - 总览「本年支出」增加 year_cost_missing,把合计的覆盖缺口说出来 - DNS 弹窗补 MX/SRV 优先级与备注;列表请求失败时 provider 用资产自带 slug 兜底, 否则 Cloudflare 代理勾选框永远不出现 测试:257 -> 309 passed --- app/adapters/aliyun.py | 263 ++++++++++- app/adapters/aws.py | 369 +++++++++++++++ app/adapters/base.py | 165 ++++++- app/adapters/cloudflare.py | 101 ++++- app/adapters/digitalocean.py | 132 +++++- app/adapters/linode.py | 213 +++++++++ app/adapters/namesilo.py | 163 +++++++ app/adapters/registry.py | 3 + app/adapters/tencent.py | 280 +++++++++++- app/adapters/vultr.py | 113 ++++- app/core/seed.py | 18 +- app/database.py | 60 ++- app/main.py | 3 +- app/routers/actions.py | 87 ++++ app/routers/sync.py | 9 + app/schemas/action.py | 56 +++ app/schemas/asset.py | 4 + app/services/action_service.py | 282 ++++++++++++ app/services/asset_service.py | 22 +- app/services/sync_service.py | 101 ++++- deploy/setup.sh | 4 +- deploy/vps-sync-assets.service | 13 + deploy/vps-sync-assets.timer | 16 + scripts/sync_all_assets.py | 38 ++ static/js/api.js | 9 + static/js/app.js | 4 + static/js/modals.js | 203 +++++++++ static/js/store.js | 179 +++++++- static/js/views/assets.js | 26 +- static/js/views/dashboard.js | 2 + static/js/views/domains.js | 3 +- static/js/views/monitor.js | 4 +- static/js/views/providers.js | 32 +- static/js/views/subscriptions.js | 2 +- tests/test_actions.py | 718 ++++++++++++++++++++++++++++++ tests/test_adapters.py | 43 +- tests/test_asset_export_import.py | 204 +++++++++ tests/test_new_adapters.py | 716 +++++++++++++++++++++++++++++ tests/test_provider_alignment.py | 196 ++++++++ tests/test_stats_overview.py | 183 ++++++++ tests/test_sync_chain.py | 228 ++++++++++ tests/test_vps_expiry_sync.py | 402 +++++++++++++++++ 42 files changed, 5570 insertions(+), 99 deletions(-) create mode 100644 app/adapters/aws.py create mode 100644 app/adapters/linode.py create mode 100644 app/adapters/namesilo.py create mode 100644 app/routers/actions.py create mode 100644 app/schemas/action.py create mode 100644 app/services/action_service.py create mode 100644 deploy/vps-sync-assets.service create mode 100644 deploy/vps-sync-assets.timer create mode 100644 scripts/sync_all_assets.py create mode 100644 tests/test_actions.py create mode 100644 tests/test_asset_export_import.py create mode 100644 tests/test_new_adapters.py create mode 100644 tests/test_provider_alignment.py create mode 100644 tests/test_stats_overview.py create mode 100644 tests/test_sync_chain.py create mode 100644 tests/test_vps_expiry_sync.py diff --git a/app/adapters/aliyun.py b/app/adapters/aliyun.py index fefebf0..a3efc8a 100644 --- a/app/adapters/aliyun.py +++ b/app/adapters/aliyun.py @@ -3,8 +3,14 @@ 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,接口契约保持不变。 + +RPC V1 签名对所有产品线一致,只有 endpoint 与 Version 不同,故 _call 接受这两个参数: + - ECS ecs.{region}.aliyuncs.com 2014-05-26 实例清单与启停/重启/续费 + - Domain domain.aliyuncs.com 2018-01-29 注册域名清单(含到期日) + - BSS business.aliyuncs.com 2017-12-14 账户余额 + - Alidns alidns.aliyuncs.com 2015-01-09 解析记录增删改查 + +DNS 的 zone_id 即域名本身(阿里云不用 zone uuid)。 """ import base64 @@ -16,19 +22,36 @@ from urllib.parse import quote import httpx -from app.adapters.base import BaseAdapter, NormalizedVPS +from app.adapters.base import ( + AccountInfo, + BaseAdapter, + NormalizedDNSRecord, + NormalizedDomain, + NormalizedVPS, + parse_iso_date, +) from app.adapters.registry import register +ECS_VERSION = "2014-05-26" +DOMAIN_VERSION = "2018-01-29" +BSS_VERSION = "2017-12-14" +ALIDNS_VERSION = "2015-01-09" + +#: ECS InstanceChargeType -> 台账 charge_type +_CHARGE_TYPE_MAP = {"PrePaid": "prepaid", "PostPaid": "postpaid"} + +#: ECS Status -> 台账状态 +_STATUS_MAP = {"Running": "active", "Stopped": "stopped", "Stopping": "stopped", "Starting": "active"} + @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: + def _ecs_endpoint(self) -> str: return f"https://ecs.{self._region()}.aliyuncs.com" @staticmethod @@ -45,23 +68,33 @@ class AliyunAdapter(BaseAdapter): 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: + def _call( + self, + action: str, + biz_params: dict = None, + endpoint: str = None, + version: str = ECS_VERSION, + with_region: bool = True, + ) -> dict: + """调用任一阿里云 RPC 产品;endpoint 缺省为 ECS 区域端点""" params = { "Format": "JSON", - "Version": self.API_VERSION, + "Version": 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(), } + # BSS / Domain / Alidns 是中心化服务,传 RegionId 会被拒或忽略 + if with_region: + params["RegionId"] = self._region() if biz_params: - params.update(biz_params) + params.update({k: v for k, v in biz_params.items() if v is not None}) params["Signature"] = self._sign(params) with httpx.Client(timeout=30) as client: - resp = client.get(self._endpoint(), params=params) + resp = client.get(endpoint or self._ecs_endpoint(), params=params) resp.raise_for_status() data = resp.json() if data.get("Code"): @@ -78,6 +111,8 @@ class AliyunAdapter(BaseAdapter): except Exception as e: # noqa: BLE001 return {"ok": False, "message": str(e)} + # ---------------- 只读清单 ---------------- + def list_vps(self) -> list: # 分页拉取:单次最多 100 条,按 TotalCount 翻页,避免实例超 100 台时漏同步 result = [] @@ -89,7 +124,7 @@ class AliyunAdapter(BaseAdapter): 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"} + charge = inst.get("InstanceChargeType") result.append( NormalizedVPS( external_id=inst.get("InstanceId"), @@ -99,8 +134,11 @@ class AliyunAdapter(BaseAdapter): 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"), + status=_STATUS_MAP.get(inst.get("Status"), inst.get("Status") or "unknown"), currency="CNY", + # 预付费实例必须带上到期日,否则续费提醒对云端同步的机器完全失效 + expiry_date=parse_iso_date(inst.get("ExpiredTime")), + charge_type=_CHARGE_TYPE_MAP.get(charge, charge), raw=inst, ) ) @@ -109,3 +147,204 @@ class AliyunAdapter(BaseAdapter): break page += 1 return result + + def list_domains(self) -> list: + """注册域名清单(domain.aliyuncs.com QueryDomainList) + + 与 DNS 托管是两回事:这里取的是「在阿里云注册的域名 + 到期日」, + 正是续费提醒需要的数据。Token 无域名权限时抛错由上层捕获,不静默返回空。 + """ + result = [] + page = 1 + while True: + data = self._call( + "QueryDomainList", + {"PageNum": str(page), "PageSize": "100"}, + endpoint="https://domain.aliyuncs.com", + version=DOMAIN_VERSION, + with_region=False, + ) + domains = data.get("Data", {}).get("Domain", []) + for d in domains: + result.append( + NormalizedDomain( + external_id=d.get("DomainName"), + domain_name=d.get("DomainName"), + registrar="aliyun", + expiry_date=parse_iso_date(d.get("ExpirationDate") or d.get("ExpirationDateLong")), + status="active" if not d.get("Premium") else "active", + raw=d, + ) + ) + total = int(data.get("TotalItemNum") or data.get("TotalCount") or 0) + if not domains or page * 100 >= total: + break + page += 1 + return result + + def get_account(self) -> AccountInfo: + """账户余额(business.aliyuncs.com QueryAccountBalance)""" + data = self._call( + "QueryAccountBalance", + endpoint="https://business.aliyuncs.com", + version=BSS_VERSION, + with_region=False, + ) + d = data.get("Data", {}) + # AvailableAmount 返回字符串(如 "123.45"),转 float 失败时保持 None + balance = self._to_float(d.get("AvailableAmount")) + return AccountInfo( + balance=balance, + currency=d.get("Currency") or "CNY", + pending_charges=self._to_float(d.get("MybankCreditAmount")), + raw=d, + ) + + @staticmethod + def _to_float(value): + try: + return float(value) + except (TypeError, ValueError): + return None + + # ---------------- 实例管理动作 ---------------- + + def _action(self, action: str, params: dict, label: str) -> dict: + try: + self._call(action, params) + return {"ok": True, "message": f"{label}指令已下发"} + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"HTTP {e.response.status_code}:{label}失败"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"{label}失败:{e}"} + + def start_instance(self, external_id: str) -> dict: + return self._action("StartInstance", {"InstanceId": external_id}, "开机") + + def stop_instance(self, external_id: str) -> dict: + # ForceStop=false 走正常关机;强制断电有丢数据风险,不默认开启 + return self._action( + "StopInstance", {"InstanceId": external_id, "ForceStop": "false"}, "关机" + ) + + def reboot_instance(self, external_id: str) -> dict: + return self._action( + "RebootInstance", {"InstanceId": external_id, "ForceStop": "false"}, "重启" + ) + + def renew_instance(self, external_id: str, period_months: int = 1) -> dict: + # RenewInstance 仅对预付费实例有效,后付费会返回 IncorrectInstanceStatus + return self._action( + "RenewInstance", + {"InstanceId": external_id, "Period": str(period_months)}, + f"续费 {period_months} 个月", + ) + + # ---------------- DNS 记录管理 ---------------- + + def list_dns_records(self, zone_id: str) -> list: + result = [] + page = 1 + while True: + data = self._call( + "DescribeDomainRecords", + {"DomainName": zone_id, "PageNumber": str(page), "PageSize": "100"}, + endpoint="https://alidns.aliyuncs.com", + version=ALIDNS_VERSION, + with_region=False, + ) + records = data.get("DomainRecords", {}).get("Record", []) + for r in records: + result.append( + NormalizedDNSRecord( + record_id=str(r.get("RecordId")), + zone_id=zone_id, + name=self._fqdn(r.get("RR"), zone_id), + type=r.get("Type"), + content=r.get("Value"), + ttl=int(r.get("TTL")) if r.get("TTL") else None, + priority=int(r.get("Priority")) if r.get("Priority") else None, + comment=r.get("Remark"), + raw=r, + ) + ) + total = int(data.get("TotalCount") or 0) + if not records or page * 100 >= total: + break + page += 1 + return result + + @staticmethod + def _fqdn(rr: str, zone_id: str) -> str: + """阿里云存的是主机记录(RR,如 www / @),拼成完整域名供台账展示""" + if not rr or rr == "@": + return zone_id + return f"{rr}.{zone_id}" + + @staticmethod + def _rr(name: str, zone_id: str) -> str: + """反向:把完整域名拆回主机记录""" + if name == zone_id: + return "@" + suffix = f".{zone_id}" + return name[: -len(suffix)] if name.endswith(suffix) else name + + def create_dns_record(self, zone_id: str, record: dict) -> dict: + params = { + "DomainName": zone_id, + "RR": self._rr(record.get("name", ""), zone_id), + "Type": record.get("type", "A"), + "Value": record.get("content", ""), + "TTL": str(record.get("ttl") or 600), + "Priority": str(record["priority"]) if record.get("priority") else None, + } + try: + data = self._call( + "AddDomainRecord", + params, + endpoint="https://alidns.aliyuncs.com", + version=ALIDNS_VERSION, + with_region=False, + ) + return { + "ok": True, + "message": "记录已创建", + "record_id": str(data.get("RecordId", "")), + "raw": data, + } + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"创建失败:{e}"} + + def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict: + params = { + "RecordId": record_id, + "RR": self._rr(record.get("name", ""), zone_id), + "Type": record.get("type", "A"), + "Value": record.get("content", ""), + "TTL": str(record.get("ttl") or 600), + "Priority": str(record["priority"]) if record.get("priority") else None, + } + try: + data = self._call( + "UpdateDomainRecord", + params, + endpoint="https://alidns.aliyuncs.com", + version=ALIDNS_VERSION, + with_region=False, + ) + return {"ok": True, "message": "记录已更新", "raw": data} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"更新失败:{e}"} + + def delete_dns_record(self, zone_id: str, record_id: str) -> dict: + try: + data = self._call( + "DeleteDomainRecord", + {"RecordId": record_id}, + endpoint="https://alidns.aliyuncs.com", + version=ALIDNS_VERSION, + with_region=False, + ) + return {"ok": True, "message": "记录已删除", "raw": data} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"删除失败:{e}"} diff --git a/app/adapters/aws.py b/app/adapters/aws.py new file mode 100644 index 0000000..6ea178d --- /dev/null +++ b/app/adapters/aws.py @@ -0,0 +1,369 @@ +"""AWS 适配器(SigV4 签名,EC2 查询协议 + Route53 JSON 协议) + +所需配置:{"access_key_id": "...", "secret_access_key": "...", "region": "us-east-1"} + +不引入 boto3:项目其余适配器统一走 httpx + 手写签名(阿里云 RPC V1、腾讯云 TC3), +boto3 会带来数十 MB 依赖与独立的凭证/重试体系,与现有风格割裂。 + +协议差异(SigV4 相同,封装不同): + - EC2 ec2.{region}.amazonaws.com 查询协议,POST 表单编码 body + - Route53 route53.amazonaws.com JSON 1.1,X-Amz-Target 头,全局无区域 + - Route53Domains route53domains.{region}... JSON 1.1,域名注册清单(含到期日) + +刻意不实现 get_account:AWS 没有「账户余额」这一概念(预付的是信用额/账单), +硬凑一个数字会比如实报「不支持」更误导,参见 ai.py 对 OpenAI 的同类处理。 +""" + +import hashlib +import hmac +import json +from datetime import datetime, timezone +from urllib.parse import quote + +import httpx + +from app.adapters.base import ( + BaseAdapter, + NormalizedDNSRecord, + NormalizedDomain, + NormalizedVPS, + parse_iso_date, +) +from app.adapters.registry import register + +EC2_VERSION = "2016-11-15" +ROUTE53DOMAINS_TARGET_PREFIX = "Route53Domains_v20140515" + +#: EC2 实例状态 -> 台账状态;terminated/shutting-down 不入库,避免台账堆满已销毁实例 +_STATE_MAP = { + "running": "active", + "stopped": "stopped", + "stopping": "stopped", + "pending": "active", +} + +_ALGORITHM = "AWS4-HMAC-SHA256" + + +def _sign(key: bytes, msg: str) -> bytes: + return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest() + + +def _signing_key(secret: str, date_stamp: str, region: str, service: str) -> bytes: + k_date = _sign(("AWS4" + secret).encode("utf-8"), date_stamp) + k_region = _sign(k_date, region) + k_service = _sign(k_region, service) + return _sign(k_service, "aws4_request") + + +@register("aws-api") +class AWSAdapter(BaseAdapter): + required_config = ["access_key_id", "secret_access_key"] + + def _region(self) -> str: + return self.config.get("region") or "us-east-1" + + def _credentials(self) -> tuple[str, str]: + return ( + self.config.get("access_key_id", ""), + self.config.get("secret_access_key", ""), + ) + + def _signed_request( + self, + service: str, + host: str, + region: str, + method: str, + path: str, + body: str = "", + content_type: str = "application/x-www-form-urlencoded; charset=utf-8", + extra_headers: dict = None, + ) -> dict: + """构造并发送一个 SigV4 签名请求,返回解析后的 JSON/XML-less dict""" + access_key, secret_key = self._credentials() + now = datetime.now(timezone.utc) + amz_date = now.strftime("%Y%m%dT%H%M%SZ") + date_stamp = now.strftime("%Y%m%d") + + headers = {"host": host, "x-amz-date": amz_date, "content-type": content_type} + if extra_headers: + headers.update({k.lower(): v for k, v in extra_headers.items()}) + + signed_headers = ";".join(sorted(headers)) + canonical_headers = "".join(f"{k}:{headers[k].strip()}\n" for k in sorted(headers)) + payload_hash = hashlib.sha256(body.encode("utf-8")).hexdigest() + + # 查询协议的参数在 body 里,CanonicalQueryString 为空;path 恒为 "/" + canonical_request = ( + f"{method}\n{path}\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}" + ) + credential_scope = f"{date_stamp}/{region}/{service}/aws4_request" + string_to_sign = ( + f"{_ALGORITHM}\n{amz_date}\n{credential_scope}\n" + f"{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}" + ) + signature = hmac.new( + _signing_key(secret_key, date_stamp, region, service), + string_to_sign.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + headers["Authorization"] = ( + f"{_ALGORITHM} Credential={access_key}/{credential_scope}, " + f"SignedHeaders={signed_headers}, Signature={signature}" + ) + # httpx 会自己算 Content-Length,host 头也由它设;这里传签名时用的小写 host 值必须一致 + send_headers = {k: v for k, v in headers.items() if k != "host"} + with httpx.Client(timeout=30) as client: + resp = client.request( + method, f"https://{host}{path}", content=body.encode("utf-8"), headers=send_headers + ) + resp.raise_for_status() + return resp.json() if resp.content else {} + + # ---------------- EC2(查询协议) ---------------- + + def _ec2_call(self, params: dict) -> dict: + body = "&".join(f"{quote(k, safe='')}={quote(str(v), safe='')}" for k, v in sorted(params.items())) + return self._signed_request( + service="ec2", + host=f"ec2.{self._region()}.amazonaws.com", + region=self._region(), + method="POST", + path="/", + body=body, + ) + + def test_connection(self) -> dict: + try: + data = self._ec2_call({"Action": "DescribeRegions", "Version": EC2_VERSION}) + count = len((data.get("regionInfo") or {}).get("item") or []) + 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: + result = [] + token = None + while True: + params = {"Action": "DescribeInstances", "Version": EC2_VERSION} + if token: + params["NextToken"] = token + data = self._ec2_call(params) + # EC2 查询协议转 JSON 后是驼峰包装:reservationSet.item[].instancesSet.item[] + reservations = (data.get("reservationSet") or {}).get("item") or [] + if isinstance(reservations, dict): + reservations = [reservations] + for res in reservations: + instances = (res.get("instancesSet") or {}).get("item") or [] + if isinstance(instances, dict): + instances = [instances] + for inst in instances: + state = ((inst.get("instanceState") or {}).get("name") or "").lower() + if state not in _STATE_MAP: + continue # terminated / shutting-down 不入台账 + tags = { + t.get("key"): t.get("value") + for t in ((inst.get("tagSet") or {}).get("item") or []) + } + result.append( + NormalizedVPS( + external_id=inst.get("instanceId"), + name=tags.get("Name") or inst.get("instanceId"), + ip_address=inst.get("ipAddress") or inst.get("privateIpAddress"), + region=(inst.get("placement") or {}).get("availabilityZone"), + os=None, # EC2 列表只给 imageId,解析平台名需额外 DescribeImages + cpu_cores=None, + memory_gb=None, + status=_STATE_MAP[state], + currency="USD", + # AWS 以按需/预留为主,实例列表里没有到期日;预留实例到期在 + # DescribeReservedInstances 里,与单机不是一对一,故不猜 + expiry_date=None, + charge_type=(inst.get("instanceLifecycle") or "on-demand"), + raw=inst, + ) + ) + token = data.get("nextToken") + if not token: + break + return result + + def _ec2_action(self, action: str, external_id: str, label: str) -> dict: + try: + self._ec2_call( + {"Action": action, "Version": EC2_VERSION, "InstanceId.1": external_id} + ) + return {"ok": True, "message": f"{label}指令已下发"} + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"HTTP {e.response.status_code}:{label}失败"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"{label}失败:{e}"} + + def start_instance(self, external_id: str) -> dict: + return self._ec2_action("StartInstances", external_id, "开机") + + def stop_instance(self, external_id: str) -> dict: + return self._ec2_action("StopInstances", external_id, "关机") + + def reboot_instance(self, external_id: str) -> dict: + return self._ec2_action("RebootInstances", external_id, "重启") + + # ---------------- Route53 注册域名(含到期日) ---------------- + + def _r53domains_call(self, action: str, payload: dict) -> dict: + return self._signed_request( + service="route53domains", + host=f"route53domains.{self._region()}.amazonaws.com", + region=self._region(), + method="POST", + path="/", + body=json.dumps(payload), + content_type="application/x-amz-json-1.1", + extra_headers={"X-Amz-Target": f"{ROUTE53DOMAINS_TARGET_PREFIX}.{action}"}, + ) + + def list_domains(self) -> list: + """Route53 注册域名清单(含到期日) + + 只覆盖在 Route53 注册的域名;仅用 Route53 托管 DNS、域名在别处注册的场景 + 请走 list_dns_records(hosted zone)而不是这里。 + """ + result = [] + marker = None + while True: + payload = {"MaxItems": 100} + if marker: + payload["Marker"] = marker + data = self._r53domains_call("ListDomains", payload) + for d in data.get("Domains", []): + result.append( + NormalizedDomain( + external_id=d.get("DomainName"), + domain_name=d.get("DomainName"), + registrar="aws-route53", + # Expiry 是 epoch 秒 + expiry_date=self._epoch_to_date(d.get("Expiry")), + status="active", + raw=d, + ) + ) + marker = data.get("NextPageMarker") + if not marker: + break + return result + + @staticmethod + def _epoch_to_date(value): + try: + return parse_iso_date(datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat()) + except (TypeError, ValueError, OSError): + return None + + # ---------------- Route53 DNS 记录 ---------------- + + @staticmethod + def _hosted_zone_id(zone_id: str) -> str: + """Route53 的 zone id 形如 /hostedzone/Z123,API 只要 Z123""" + return zone_id.rsplit("/", 1)[-1] + + def list_dns_records(self, zone_id: str) -> list: + zid = self._hosted_zone_id(zone_id) + data = self._signed_request( + service="route53", + host="route53.amazonaws.com", + region="us-east-1", + method="GET", + path=f"/2013-04-01/hostedzone/{zid}/rrset", + body="", + content_type="application/json", + ) + result = [] + for r in data.get("ResourceRecordSets", []): + values = [rr.get("Value") for rr in (r.get("ResourceRecords") or [])] + result.append( + NormalizedDNSRecord( + record_id=f"{r.get('Name')}|{r.get('Type')}", + zone_id=zone_id, + name=(r.get("Name") or "").rstrip("."), + type=r.get("Type"), + content=", ".join(v for v in values if v), + ttl=r.get("TTL"), + priority=None, + raw=r, + ) + ) + return result + + def _change(self, zone_id: str, action: str, record: dict) -> dict: + zid = self._hosted_zone_id(zone_id) + # Route53 的记录值必须是数组,单值也要包一层 + values = [v.strip() for v in str(record.get("content", "")).split(",") if v.strip()] + change = { + "Action": action, + "ResourceRecordSet": { + "Name": record.get("name", ""), + "Type": record.get("type", "A"), + "TTL": record.get("ttl") or 300, + "ResourceRecords": [{"Value": v} for v in values], + }, + } + body = json.dumps({"Changes": [change]}) + try: + self._signed_request( + service="route53", + host="route53.amazonaws.com", + region="us-east-1", + method="POST", + path=f"/2013-04-01/hostedzone/{zid}/change", + body=body, + # Route53 的 /2013-04-01/* 是 REST-JSON(按路径路由,无 X-Amz-Target), + # 用 x-amz-json-1.1 会被当成 JSON 协议服务而报 MissingAuthenticationToken + content_type="application/json", + ) + return {"ok": True} + 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 create_dns_record(self, zone_id: str, record: dict) -> dict: + res = self._change(zone_id, "CREATE", record) + if not res["ok"]: + return {"ok": False, "message": f"创建失败:{res['message']}"} + return { + "ok": True, + "message": "记录已创建(Route53 变更为异步,生效需数秒)", + "record_id": f"{record.get('name')}|{record.get('type')}", + } + + def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict: + res = self._change(zone_id, "UPSERT", record) + if not res["ok"]: + return {"ok": False, "message": f"更新失败:{res['message']}"} + return {"ok": True, "message": "记录已更新(Route53 变更为异步,生效需数秒)"} + + def delete_dns_record(self, zone_id: str, record_id: str) -> dict: + # record_id 形如 "name|TYPE",Route53 删除必须带上完整的记录集 + if "|" not in record_id: + return {"ok": False, "message": "删除失败:record_id 需为 name|TYPE 形式"} + name, rtype = record_id.rsplit("|", 1) + try: + existing = next( + (r for r in self.list_dns_records(zone_id) if r.record_id == record_id), None + ) + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"删除失败:无法读取现有记录 {e}"} + if not existing: + return {"ok": False, "message": "删除失败:记录不存在"} + res = self._change( + zone_id, + "DELETE", + {"name": name, "type": rtype, "content": existing.content, "ttl": existing.ttl}, + ) + if not res["ok"]: + return {"ok": False, "message": f"删除失败:{res['message']}"} + return {"ok": True, "message": "记录已删除(Route53 变更为异步)"} diff --git a/app/adapters/base.py b/app/adapters/base.py index 301c768..05b774c 100644 --- a/app/adapters/base.py +++ b/app/adapters/base.py @@ -5,11 +5,14 @@ 供同步服务写入数据库。这样上层逻辑无需关心各平台 API 差异。 """ +import re from abc import ABC, abstractmethod from dataclasses import asdict, dataclass, field from datetime import date, datetime from typing import Any, Optional +import httpx + def parse_iso_date(value: Any) -> Optional[date]: """把到期日归一化为 date 对象(接受 date / datetime / ISO 字符串,解析失败返回 None) @@ -33,6 +36,78 @@ def parse_iso_date(value: Any) -> Optional[date]: return None +# 状态码 -> 排查提示。厂商 body 有时只给一个 code 不给原因(AWS 的 403、 +# Vultr 的 401 就常这样),这时至少告诉用户往哪个方向查。 +_STATUS_HINTS = { + 401: "凭证无效或已过期", + 403: "凭证权限不足", + 404: "资源不存在(实例 ID 或域名可能已变更)", + 429: "触发平台限流,请稍后重试", +} + +# 厂商放错误原因的键名(各家不统一,按此顺序找第一个可读字符串) +_ERROR_KEYS = ("message", "msg", "reason", "error", "errormsg", "detail", "description") + + +def _scan_error_text(node: Any, depth: int = 0) -> Optional[str]: + """在嵌套 JSON 里递归找第一个像错误原因的字符串""" + if depth > 4: + return None + if isinstance(node, dict): + for key in _ERROR_KEYS: + for k, v in node.items(): + if k.lower() == key and isinstance(v, str) and v.strip(): + return v.strip() + for v in node.values(): + found = _scan_error_text(v, depth + 1) + if found: + return found + elif isinstance(node, list): + for v in node: + found = _scan_error_text(v, depth + 1) + if found: + return found + return None + + +def http_error_message(e: httpx.HTTPStatusError) -> str: + """把云厂商的 HTTP 错误压成一句人话 + + 直接 str(e) 拿到的是 httpx 的多行文本,末尾还附一段 MDN 链接, + 显示在弹窗里既难看也没法排查。各厂商把原因放的位置又都不一样 + (Linode 在 body.errors[0].reason、Cloudflare 在 body.errors[0].message、 + 腾讯云在 Response.Error.Message、AWS 在 XML 里),所以统一在这里提取。 + """ + resp = e.response + code = resp.status_code + detail = None + try: + detail = _scan_error_text(resp.json()) + except Exception: # noqa: BLE001 + # AWS 等返回 XML,退化成抓 + found = re.search(r"(.*?)", resp.text or "", re.S) + if found: + detail = found.group(1).strip() + parts = [f"HTTP {code}"] + if detail: + parts.append(detail[:200]) + hint = _STATUS_HINTS.get(code) + if hint and (not detail or hint not in detail): + parts.append(hint) + return ":".join(parts) + + +def cloud_error_text(e: Exception) -> str: + """云端异常 -> 一句可排查的话 + + HTTP 错误交给 http_error_message 提取厂商 body 里的原因; + 其余(超时、DNS 解析失败、连接被重置)本身就短,直接用 str(e)。 + """ + if isinstance(e, httpx.HTTPStatusError): + return http_error_message(e) + return str(e) + + @dataclass class NormalizedVPS: """标准化的 VPS 实例""" @@ -46,8 +121,41 @@ class NormalizedVPS: memory_gb: Optional[float] = None disk_gb: Optional[int] = None status: str = "active" + #: 月度费用。只有 Vultr / DO 的列表接口直接返回价格,其余厂商(阿里云、腾讯云、 + #: AWS、Linode)的 DescribeInstances 一律没有价格字段——要拿到得另调计价 API, + #: 而那是挂牌价,不等于用户实付(折扣、备份、IPv4、额外硬盘都会改变金额)。 + #: 台账里 Asset.cost 的语义是「单次续费费用」,属于用户自己维护的账目值; + #: 用一个看似合理的挂牌价去覆盖它,比留空更糟。所以这里刻意留 None, + #: 前端把 cost=0 显示为「未填」而非「0 USD」,总览页则报出未填项数。 monthly_cost: Optional[float] = None currency: str = "USD" + #: 预付费实例的到期日;后付费(按量/按小时)平台恒为 None。 + #: 缺了它,云端同步进来的 VPS 永远进不了续费提醒——这是台账的核心用途。 + expiry_date: Optional[date] = None + #: 计费方式,用于前端区分「预付费会到期」与「后付费不会到期」 + charge_type: Optional[str] = None + raw: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + d = asdict(self) + if d.get("expiry_date") is not None: + d["expiry_date"] = d["expiry_date"].isoformat() + return d + + +@dataclass +class NormalizedDNSRecord: + """标准化的 DNS 解析记录""" + + record_id: str + zone_id: str + name: str + type: str + content: str + ttl: Optional[int] = None + proxied: Optional[bool] = None + priority: Optional[int] = None + comment: Optional[str] = None raw: dict = field(default_factory=dict) def to_dict(self) -> dict: @@ -96,13 +204,26 @@ def _overridden(instance: "BaseAdapter", method_name: str) -> bool: return impl is not None and impl is not getattr(BaseAdapter, method_name, None) +#: 只读能力(同步用) +READ_CAPABILITIES = ("list_vps", "list_domains", "get_account") +#: 实例管理动作(写操作,前端须二次确认) +INSTANCE_ACTIONS = ("start_instance", "stop_instance", "reboot_instance", "renew_instance") +#: DNS 记录管理动作(写操作) +DNS_ACTIONS = ("list_dns_records", "create_dns_record", "update_dns_record", "delete_dns_record") + + class BaseAdapter(ABC): """云厂商适配器抽象基类 子类需: - 通过 @register("xxx-sdk") 注册到注册表 - 设置 required_config(所需凭证字段,供前端表单提示) - - 实现 test_connection;按需重写 list_vps / list_domains / get_account + - 实现 test_connection;按需重写只读能力与动作方法 + + 能力分三层,全部通过 capabilities() 如实上报,前端据此决定按钮可用性: + 1. 只读清单:list_vps / list_domains / get_account + 2. 实例动作:start / stop / reboot / renew_instance(写操作) + 3. DNS 动作:list / create / update / delete_dns_record(写操作) """ #: 适配器所需的配置字段名(如 ["api_key"] 或 ["access_key_id", "access_key_secret"]) @@ -115,6 +236,8 @@ class BaseAdapter(ABC): def test_connection(self) -> dict: """测试连接 / 凭证有效性,返回 {"ok": bool, "message": str, ...}""" + # ---------------- 只读清单 ---------------- + def list_vps(self) -> list: raise NotImplementedError(f"{type(self).__name__} 未实现 list_vps") @@ -124,10 +247,40 @@ class BaseAdapter(ABC): def get_account(self) -> AccountInfo: raise NotImplementedError(f"{type(self).__name__} 未实现 get_account") + # ---------------- 实例管理动作 ---------------- + # 统一返回 {"ok": bool, "message": str, "raw": Any},与 test_connection 同构, + # 让路由层不必区分「厂商返回成功」与「适配器不支持」。 + + def start_instance(self, external_id: str) -> dict: + raise NotImplementedError(f"{type(self).__name__} 未实现 start_instance") + + def stop_instance(self, external_id: str) -> dict: + raise NotImplementedError(f"{type(self).__name__} 未实现 stop_instance") + + def reboot_instance(self, external_id: str) -> dict: + raise NotImplementedError(f"{type(self).__name__} 未实现 reboot_instance") + + def renew_instance(self, external_id: str, period_months: int = 1) -> dict: + """续费预付费实例(后付费平台无此概念,保持未实现即可)""" + raise NotImplementedError(f"{type(self).__name__} 未实现 renew_instance") + + # ---------------- DNS 记录管理 ---------------- + + def list_dns_records(self, zone_id: str) -> list: + raise NotImplementedError(f"{type(self).__name__} 未实现 list_dns_records") + + def create_dns_record(self, zone_id: str, record: dict) -> dict: + raise NotImplementedError(f"{type(self).__name__} 未实现 create_dns_record") + + def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict: + raise NotImplementedError(f"{type(self).__name__} 未实现 update_dns_record") + + def delete_dns_record(self, zone_id: str, record_id: str) -> dict: + raise NotImplementedError(f"{type(self).__name__} 未实现 delete_dns_record") + + # ---------------- 能力探测 ---------------- + def capabilities(self) -> dict: """返回适配器实际支持的能力(是否重写了相应方法)""" - return { - "list_vps": _overridden(self, "list_vps"), - "list_domains": _overridden(self, "list_domains"), - "get_account": _overridden(self, "get_account"), - } + names = READ_CAPABILITIES + INSTANCE_ACTIONS + DNS_ACTIONS + return {name: _overridden(self, name) for name in names} diff --git a/app/adapters/cloudflare.py b/app/adapters/cloudflare.py index 6bea799..c86c419 100644 --- a/app/adapters/cloudflare.py +++ b/app/adapters/cloudflare.py @@ -1,14 +1,21 @@ """Cloudflare 适配器(REST API v4,Bearer Token) API 文档:https://developers.cloudflare.com/api/ -所需配置:{"api_token": "..."}(建议用 API Token,权限含 Zone:Read / Account:Read / Registrar:Read) +所需配置:{"api_token": "..."} +Token 权限建议:Zone:Read / Zone:Edit(DNS 记录写操作需要)/ Account:Read / Registrar:Read Cloudflare 无传统 VPS,主要同步托管域名(zones);Workers/R2/Tunnel 子资产留待后续阶段。 域名到期日通过 Registrar API(/accounts/{id}/registrar/domains)补充获取。 +DNS 的 zone_id 即 zone uuid(与 list_domains 返回的 external_id 一致)。 """ import httpx -from app.adapters.base import BaseAdapter, NormalizedDomain, parse_iso_date +from app.adapters.base import ( + BaseAdapter, + NormalizedDNSRecord, + NormalizedDomain, + parse_iso_date, +) from app.adapters.registry import register @@ -20,16 +27,19 @@ class CloudflareAdapter(BaseAdapter): def _headers(self) -> dict: return {"Authorization": f"Bearer {self.config.get('api_token', '')}"} - def _get(self, path: str) -> dict: + def _request(self, method: str, path: str, json_body: dict = None) -> dict: with httpx.Client(timeout=30) as client: - resp = client.get(self.BASE + path, headers=self._headers()) + resp = client.request(method, self.BASE + path, headers=self._headers(), json=json_body) resp.raise_for_status() - data = resp.json() + data = resp.json() if resp.content else {} if not data.get("success", True): errors = data.get("errors", []) raise RuntimeError(errors[0].get("message") if errors else "Cloudflare API 返回失败") return data + def _get(self, path: str) -> dict: + return self._request("GET", path) + def test_connection(self) -> dict: try: data = self._get("/user/tokens/verify") @@ -98,3 +108,84 @@ class CloudflareAdapter(BaseAdapter): if parsed: expiry[name] = parsed return expiry + + # ---------------- DNS 记录管理 ---------------- + + def list_dns_records(self, zone_id: str) -> list: + result = [] + page = 1 + while True: + data = self._get(f"/zones/{zone_id}/dns_records?per_page=100&page={page}") + for r in data.get("result", []): + result.append( + NormalizedDNSRecord( + record_id=str(r.get("id")), + zone_id=zone_id, + name=r.get("name"), + type=r.get("type"), + content=r.get("content"), + ttl=r.get("ttl"), + proxied=r.get("proxied"), + priority=r.get("priority"), + comment=r.get("comment"), + raw=r, + ) + ) + info = data.get("result_info", {}) + if page >= info.get("total_pages", 1): + break + page += 1 + return result + + @staticmethod + def _record_body(record: dict) -> dict: + body = { + "type": record.get("type", "A"), + "name": record.get("name", ""), + "content": record.get("content", ""), + "ttl": record.get("ttl") or 1, # 1 = Cloudflare 的「自动」TTL + } + # proxied 只对 A/AAAA/CNAME 有意义,其余类型传了会被 API 拒绝 + if record.get("proxied") is not None and body["type"] in ("A", "AAAA", "CNAME"): + body["proxied"] = bool(record["proxied"]) + if record.get("priority") is not None: + body["priority"] = record["priority"] + if record.get("comment"): + body["comment"] = record["comment"] + return body + + def create_dns_record(self, zone_id: str, record: dict) -> dict: + try: + data = self._request( + "POST", f"/zones/{zone_id}/dns_records", self._record_body(record) + ) + return { + "ok": True, + "message": "记录已创建", + "record_id": str(data.get("result", {}).get("id", "")), + "raw": data, + } + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"HTTP {e.response.status_code}:创建失败(Token 可能缺 Zone:Edit)"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"创建失败:{e}"} + + def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict: + try: + self._request( + "PATCH", f"/zones/{zone_id}/dns_records/{record_id}", self._record_body(record) + ) + return {"ok": True, "message": "记录已更新"} + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"HTTP {e.response.status_code}:更新失败(Token 可能缺 Zone:Edit)"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"更新失败:{e}"} + + def delete_dns_record(self, zone_id: str, record_id: str) -> dict: + try: + self._request("DELETE", f"/zones/{zone_id}/dns_records/{record_id}") + return {"ok": True, "message": "记录已删除"} + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"HTTP {e.response.status_code}:删除失败(Token 可能缺 Zone:Edit)"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"删除失败:{e}"} diff --git a/app/adapters/digitalocean.py b/app/adapters/digitalocean.py index f9b0e2d..2881964 100644 --- a/app/adapters/digitalocean.py +++ b/app/adapters/digitalocean.py @@ -2,6 +2,9 @@ API 文档:https://docs.digitalocean.com/reference/api/ 所需配置:{"api_key": "..."} +支持功能:Droplet 清单、账户余额、域名清单、启停/重启、DNS 记录管理 +DNS 的 zone_id 即域名本身(DO 用域名字符串定位)。 +DO 为按小时计费的订阅制,无「续费」概念,故不实现 renew_instance。 """ import httpx @@ -9,6 +12,7 @@ import httpx from app.adapters.base import ( AccountInfo, BaseAdapter, + NormalizedDNSRecord, NormalizedDomain, NormalizedVPS, parse_iso_date, @@ -24,11 +28,14 @@ class DigitalOceanAdapter(BaseAdapter): def _headers(self) -> dict: return {"Authorization": f"Bearer {self.config.get('api_key', '')}"} - def _get(self, path: str) -> dict: + def _request(self, method: str, path: str, json_body: dict = None) -> dict: with httpx.Client(timeout=30) as client: - resp = client.get(self.BASE + path, headers=self._headers()) + resp = client.request(method, self.BASE + path, headers=self._headers(), json=json_body) resp.raise_for_status() - return resp.json() + return resp.json() if resp.content else {} + + def _get(self, path: str) -> dict: + return self._request("GET", path) def test_connection(self) -> dict: try: @@ -73,7 +80,10 @@ class DigitalOceanAdapter(BaseAdapter): memory_gb=round(mem_mb / 1024, 1) if mem_mb else None, disk_gb=d.get("disk"), status="active" if d.get("status") == "active" else (d.get("status") or "unknown"), + # size.price_monthly 是官方规格月价,比留空让台账自己猜准得多 + monthly_cost=(d.get("size", {}) or {}).get("price_monthly"), currency="USD", + charge_type="postpaid", raw=d, ) ) @@ -102,3 +112,119 @@ class DigitalOceanAdapter(BaseAdapter): break page += 1 return result + + # ---------------- 实例管理动作 ---------------- + + def _droplet_action(self, external_id: str, action_type: str, label: str) -> dict: + try: + data = self._request( + "POST", f"/droplets/{external_id}/actions", {"type": action_type} + ) + return { + "ok": True, + "message": f"{label}指令已下发", + "raw": data.get("action", {}), + } + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"HTTP {e.response.status_code}:{label}失败"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"{label}失败:{e}"} + + def start_instance(self, external_id: str) -> dict: + return self._droplet_action(external_id, "power_on", "开机") + + def stop_instance(self, external_id: str) -> dict: + # shutdown 是优雅关机;power_off 等同拔电源,有丢数据风险,不默认使用 + return self._droplet_action(external_id, "shutdown", "关机") + + def reboot_instance(self, external_id: str) -> dict: + return self._droplet_action(external_id, "reboot", "重启") + + # ---------------- DNS 记录管理 ---------------- + + def list_dns_records(self, zone_id: str) -> list: + result = [] + page = 1 + while True: + data = self._get(f"/domains/{zone_id}/records?per_page=100&page={page}") + for r in data.get("domain_records", []): + sub = r.get("name") or "@" + result.append( + NormalizedDNSRecord( + record_id=str(r.get("id")), + zone_id=zone_id, + name=self._fqdn(sub, zone_id), + type=r.get("type"), + content=r.get("data"), + ttl=r.get("ttl"), + priority=r.get("priority"), + raw=r, + ) + ) + if not data.get("links", {}).get("pages", {}).get("next"): + break + page += 1 + return result + + @staticmethod + def _fqdn(sub: str, zone_id: str) -> str: + """DO 存的是子域(name,如 www / @),拼成完整域名供台账展示""" + if not sub or sub == "@": + return zone_id + return f"{sub}.{zone_id}" + + @staticmethod + def _sub(name: str, zone_id: str) -> str: + if name == zone_id: + return "@" + suffix = f".{zone_id}" + return name[: -len(suffix)] if name.endswith(suffix) else name + + def _record_body(self, zone_id: str, record: dict) -> dict: + body = { + "name": self._sub(record.get("name", ""), zone_id), + "type": record.get("type", "A"), + "data": record.get("content", ""), + "ttl": record.get("ttl") or 1800, + } + if record.get("priority") is not None: + body["priority"] = record["priority"] + return body + + def create_dns_record(self, zone_id: str, record: dict) -> dict: + try: + data = self._request( + "POST", f"/domains/{zone_id}/records", self._record_body(zone_id, record) + ) + return { + "ok": True, + "message": "记录已创建", + "record_id": str(data.get("domain_record", {}).get("id", "")), + "raw": data, + } + 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": f"创建失败:{e}"} + + def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict: + try: + self._request( + "PUT", + f"/domains/{zone_id}/records/{record_id}", + self._record_body(zone_id, record), + ) + return {"ok": True, "message": "记录已更新"} + 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": f"更新失败:{e}"} + + def delete_dns_record(self, zone_id: str, record_id: str) -> dict: + try: + self._request("DELETE", f"/domains/{zone_id}/records/{record_id}") + return {"ok": True, "message": "记录已删除"} + 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": f"删除失败:{e}"} diff --git a/app/adapters/linode.py b/app/adapters/linode.py new file mode 100644 index 0000000..cd70c41 --- /dev/null +++ b/app/adapters/linode.py @@ -0,0 +1,213 @@ +"""Linode(Akamai)适配器(REST API v4,Bearer Token) + +API 文档:https://www.linode.com/docs/api/ +所需配置:{"api_key": "..."}(Personal Access Token) + +覆盖:实例清单与启停/重启、账户余额、托管域名清单、DNS 记录增删改查。 +Linode 为按小时计费,无「续费」概念,故不实现 renew_instance。 +DNS 的 zone_id 是数字域名 ID(/domains 返回的 id),不是域名字符串。 +""" + +import httpx + +from app.adapters.base import ( + AccountInfo, + BaseAdapter, + NormalizedDNSRecord, + NormalizedDomain, + NormalizedVPS, + parse_iso_date, +) +from app.adapters.registry import register + +_STATUS_MAP = {"running": "active", "offline": "stopped", "booting": "active", "shutting_down": "stopped"} + + +@register("linode-api") +class LinodeAdapter(BaseAdapter): + required_config = ["api_key"] + BASE = "https://api.linode.com/v4" + + def _headers(self) -> dict: + return {"Authorization": f"Bearer {self.config.get('api_key', '')}"} + + def _request(self, method: str, path: str, json_body: dict = None) -> dict: + with httpx.Client(timeout=30) as client: + resp = client.request(method, self.BASE + path, headers=self._headers(), json=json_body) + resp.raise_for_status() + return resp.json() if resp.content else {} + + def _get(self, path: str) -> dict: + return self._request("GET", path) + + @staticmethod + def _error_message(e: httpx.HTTPStatusError) -> str: + """Linode 把原因放在 body.errors[0].reason,比裸状态码有用得多""" + try: + errors = e.response.json().get("errors", []) + if errors: + return f"HTTP {e.response.status_code}:{errors[0].get('reason')}" + except Exception: # noqa: BLE001 + pass + return f"HTTP {e.response.status_code}" + + def test_connection(self) -> dict: + try: + data = self._get("/account") + return {"ok": True, "message": f"连接成功,账户 {data.get('email') or data.get('company') or 'OK'}"} + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"{self._error_message(e)}:Token 无效或权限不足"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": str(e)} + + # ---------------- 只读清单 ---------------- + + def get_account(self) -> AccountInfo: + acc = self._get("/account") + # Linode 的 balance 为负数表示账户有余额(预付信用),正数表示欠款 + return AccountInfo( + balance=acc.get("balance"), + currency="USD", + raw=acc, + ) + + def list_vps(self) -> list: + result = [] + page = 1 + while True: + data = self._get(f"/linode/instances?page={page}&page_size=100") + for inst in data.get("data", []): + specs = inst.get("specs", {}) or {} + mem_mb = specs.get("memory") or 0 + ipv4 = inst.get("ipv4") or [] + result.append( + NormalizedVPS( + external_id=str(inst.get("id")), + name=inst.get("label") or str(inst.get("id")), + ip_address=ipv4[0] if ipv4 else None, + region=inst.get("region"), + os=inst.get("image"), + cpu_cores=specs.get("vcpus"), + memory_gb=round(mem_mb / 1024, 1) if mem_mb else None, + disk_gb=specs.get("disk"), + status=_STATUS_MAP.get(inst.get("status"), inst.get("status") or "unknown"), + currency="USD", + charge_type="postpaid", + raw=inst, + ) + ) + if page >= (data.get("pages") or 1): + break + page += 1 + return result + + def list_domains(self) -> list: + result = [] + page = 1 + while True: + data = self._get(f"/domains?page={page}&page_size=100") + for d in data.get("data", []): + result.append( + NormalizedDomain( + external_id=str(d.get("id")), + domain_name=d.get("domain"), + registrar="linode", + # 仅 Linode 注册的域名有 expiry;纯 DNS 托管的为 null + expiry_date=parse_iso_date(d.get("expiry")), + status="active" if d.get("status") == "active" else (d.get("status") or "unknown"), + raw=d, + ) + ) + if page >= (data.get("pages") or 1): + break + page += 1 + return result + + # ---------------- 实例管理动作 ---------------- + + def _instance_action(self, external_id: str, verb: str, label: str) -> dict: + try: + self._request("POST", f"/linode/instances/{external_id}/{verb}") + return {"ok": True, "message": f"{label}指令已下发"} + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"{self._error_message(e)}:{label}失败"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"{label}失败:{e}"} + + def start_instance(self, external_id: str) -> dict: + return self._instance_action(external_id, "boot", "开机") + + def stop_instance(self, external_id: str) -> dict: + return self._instance_action(external_id, "shutdown", "关机") + + def reboot_instance(self, external_id: str) -> dict: + return self._instance_action(external_id, "reboot", "重启") + + # ---------------- DNS 记录管理 ---------------- + + def list_dns_records(self, zone_id: str) -> list: + result = [] + page = 1 + while True: + data = self._get(f"/domains/{zone_id}/records?page={page}&page_size=100") + for r in data.get("data", []): + result.append( + NormalizedDNSRecord( + record_id=str(r.get("id")), + zone_id=zone_id, + name=r.get("name") or "", + type=r.get("type"), + content=r.get("target"), + ttl=r.get("ttl_sec"), + priority=r.get("priority"), + raw=r, + ) + ) + if page >= (data.get("pages") or 1): + break + page += 1 + return result + + @staticmethod + def _record_body(record: dict) -> dict: + body = { + "type": record.get("type", "A"), + "name": record.get("name", ""), + "target": record.get("content", ""), + "ttl_sec": record.get("ttl") or 300, + } + if record.get("priority") is not None: + body["priority"] = record["priority"] + return body + + def create_dns_record(self, zone_id: str, record: dict) -> dict: + try: + data = self._request("POST", f"/domains/{zone_id}/records", self._record_body(record)) + return { + "ok": True, + "message": "记录已创建", + "record_id": str(data.get("id", "")), + "raw": data, + } + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"{self._error_message(e)}:创建失败"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"创建失败:{e}"} + + def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict: + try: + self._request("PUT", f"/domains/{zone_id}/records/{record_id}", self._record_body(record)) + return {"ok": True, "message": "记录已更新"} + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"{self._error_message(e)}:更新失败"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"更新失败:{e}"} + + def delete_dns_record(self, zone_id: str, record_id: str) -> dict: + try: + self._request("DELETE", f"/domains/{zone_id}/records/{record_id}") + return {"ok": True, "message": "记录已删除"} + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"{self._error_message(e)}:删除失败"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"删除失败:{e}"} diff --git a/app/adapters/namesilo.py b/app/adapters/namesilo.py new file mode 100644 index 0000000..233a31b --- /dev/null +++ b/app/adapters/namesilo.py @@ -0,0 +1,163 @@ +"""Namesilo 适配器(REST API v1,API Key 查询参数) + +API 文档:https://www.namesilo.com/api_reference.php +所需配置:{"api_key": "..."} + +覆盖:注册域名清单(含到期日)、DNS 记录增删改查。 +Namesilo 是纯域名注册商,无 VPS,故不实现 list_vps / 实例动作。 + +响应约定:所有命令都返回 {"request": ..., "reply": {...}}, +reply.code == 300 才是成功,其余(如 301 未授权、400 参数错)需读 reply.detail。 +""" + +import httpx + +from app.adapters.base import ( + BaseAdapter, + NormalizedDNSRecord, + NormalizedDomain, + parse_iso_date, +) +from app.adapters.registry import register + +SUCCESS_CODE = 300 + + +@register("namesilo-api") +class NamesiloAdapter(BaseAdapter): + required_config = ["api_key"] + BASE = "https://www.namesilo.com/api" + + def _call(self, command: str, params: dict = None) -> dict: + query = {"version": "1", "type": "json", "key": self.config.get("api_key", "")} + if params: + query.update({k: v for k, v in params.items() if v is not None}) + with httpx.Client(timeout=30) as client: + resp = client.get(f"{self.BASE}/{command}", params=query) + resp.raise_for_status() + data = resp.json() + reply = data.get("reply", {}) or {} + code = reply.get("code") + # Namesilo 的 code 可能是 int 也可能是 str,统一按 int 比较 + try: + code = int(code) + except (TypeError, ValueError): + code = None + if code != SUCCESS_CODE: + raise RuntimeError(f"Namesilo {command} 失败(code={reply.get('code')}):{reply.get('detail')}") + return reply + + def test_connection(self) -> dict: + try: + reply = self._call("checkApiKey") + return { + "ok": True, + "message": f"连接成功,令牌属于 {reply.get('username') or '未知账户'}", + } + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"HTTP {e.response.status_code}:API Key 无效"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": str(e)} + + # ---------------- 只读清单 ---------------- + + def list_domains(self) -> list: + reply = self._call("listDomains") + result = [] + for d in reply.get("domains", []) or []: + result.append( + NormalizedDomain( + external_id=d.get("domain"), + domain_name=d.get("domain"), + registrar="namesilo", + # expires 形如 "2027-01-01";Namesilo 也可能给 "Jan 01, 2027",解析失败归 None + expiry_date=parse_iso_date(d.get("expires")), + status="active", + raw=d, + ) + ) + return result + + # ---------------- DNS 记录管理 ---------------- + # zone_id 即域名字符串;record_id 用 Namesilo 的 record_id + + def list_dns_records(self, zone_id: str) -> list: + reply = self._call("listDnsRecords", {"domain": zone_id}) + result = [] + for r in reply.get("dns_records", reply.get("resource_record", [])) or []: + host = r.get("host") or "" + result.append( + NormalizedDNSRecord( + record_id=str(r.get("record_id") or r.get("id") or ""), + zone_id=zone_id, + name=self._fqdn(host, zone_id), + type=r.get("type"), + content=r.get("value"), + ttl=int(r["ttl"]) if r.get("ttl") else None, + priority=int(r["distance"]) if r.get("distance") else None, + raw=r, + ) + ) + return result + + @staticmethod + def _fqdn(host: str, zone_id: str) -> str: + """Namesilo 的 host 是子域(空或 @ 表示裸域),拼成完整域名供台账展示""" + if not host or host == "@": + return zone_id + return f"{host}.{zone_id}" + + @staticmethod + def _host(name: str, zone_id: str) -> str: + """反向:把完整域名拆回子域(裸域用空串)""" + if not name or name == zone_id: + return "" + suffix = f".{zone_id}" + return name[: -len(suffix)] if name.endswith(suffix) else name + + def create_dns_record(self, zone_id: str, record: dict) -> dict: + # Namesilo 用 dnsRecordUpdate 同时承担新增(不带 record_id)与修改(带 record_id) + params = { + "domain": zone_id, + "rr_type": record.get("type", "A"), + "rr_host": self._host(record.get("name", ""), zone_id), + "rr_value": record.get("content", ""), + "rr_ttl": record.get("ttl") or 3600, + } + if record.get("priority") is not None: + params["rr_distance"] = record["priority"] + try: + self._call("dnsRecordUpdate", params) + return {"ok": True, "message": "记录已创建"} + 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": f"创建失败:{e}"} + + def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict: + params = { + "domain": zone_id, + "rr_id": record_id, + "rr_type": record.get("type", "A"), + "rr_host": self._host(record.get("name", ""), zone_id), + "rr_value": record.get("content", ""), + "rr_ttl": record.get("ttl") or 3600, + } + if record.get("priority") is not None: + params["rr_distance"] = record["priority"] + try: + self._call("dnsRecordUpdate", params) + return {"ok": True, "message": "记录已更新"} + 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": f"更新失败:{e}"} + + def delete_dns_record(self, zone_id: str, record_id: str) -> dict: + try: + self._call("dnsRecordDelete", {"domain": zone_id, "rr_id": record_id}) + return {"ok": True, "message": "记录已删除"} + 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": f"删除失败:{e}"} diff --git a/app/adapters/registry.py b/app/adapters/registry.py index 6750d60..d33fefc 100644 --- a/app/adapters/registry.py +++ b/app/adapters/registry.py @@ -28,8 +28,11 @@ def load_all() -> None: from app.adapters import ( # noqa: F401 ai, aliyun, + aws, cloudflare, digitalocean, + linode, + namesilo, tencent, vultr, ) diff --git a/app/adapters/tencent.py b/app/adapters/tencent.py index 315e202..3472ca6 100644 --- a/app/adapters/tencent.py +++ b/app/adapters/tencent.py @@ -1,10 +1,16 @@ -"""腾讯云适配器(含国际版,CVM OpenAPI + TC3-HMAC-SHA256 签名) +"""腾讯云适配器(含国际版,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,接口契约保持不变。 +国际版(intl)将 region 设为海外区域即可,端点统一为 *.tencentcloudapi.com。 + +TC3 签名对各产品一致,只有 host / service / version 不同,故 _call 接受这三者: + - CVM cvm.tencentcloudapi.com 2017-03-12 实例清单与启停/重启/续费 + - Billing billing.tencentcloudapi.com 2018-07-09 账户余额(单位:分) + - Domain domain.tencentcloudapi.com 2018-08-08 注册域名清单(含到期日) + - DNSPod dnspod.tencentcloudapi.com 2021-03-23 解析记录增删改查 + +DNS 的 zone_id 即域名本身(DNSPod 用域名而非 zone uuid 定位)。 """ import hashlib @@ -14,17 +20,39 @@ import time import httpx -from app.adapters.base import BaseAdapter, NormalizedVPS +from app.adapters.base import ( + AccountInfo, + BaseAdapter, + NormalizedDNSRecord, + NormalizedDomain, + NormalizedVPS, + parse_iso_date, +) from app.adapters.registry import register +CVM_HOST = "cvm.tencentcloudapi.com" +CVM_VERSION = "2017-03-12" +BILLING_HOST = "billing.tencentcloudapi.com" +BILLING_VERSION = "2018-07-09" +DOMAIN_HOST = "domain.tencentcloudapi.com" +DOMAIN_VERSION = "2018-08-08" +DNSPOD_HOST = "dnspod.tencentcloudapi.com" +DNSPOD_VERSION = "2021-03-23" + +ALGORITHM = "TC3-HMAC-SHA256" + +_STATUS_MAP = {"RUNNING": "active", "STOPPED": "stopped", "STOPPING": "stopped", "STARTING": "active"} +_CHARGE_TYPE_MAP = {"PREPAID": "prepaid", "POSTPAID_BY_HOUR": "postpaid", "POSTPAID_BY_MONTH": "postpaid"} + + +def _service_of(host: str) -> str: + """从 host 推导 TC3 credential scope 用的 service 名""" + return host.split(".")[0] + @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" @@ -33,48 +61,62 @@ class TencentAdapter(BaseAdapter): 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): + def _build_authorization( + self, action: str, payload_json: str, timestamp: int, date: str, host: str, service: 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" + f"content-type:{content_type}\nhost:{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" + credential_scope = f"{date}/{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}" + f"{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_service = self._hmac_sha256(secret_date, 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"{ALGORITHM} Credential={secret_id}/{credential_scope}, " f"SignedHeaders={signed_headers}, Signature={signature}" ) - def _call(self, action: str, payload: dict = None) -> dict: + def _call( + self, + action: str, + payload: dict = None, + host: str = CVM_HOST, + version: str = CVM_VERSION, + with_region: bool = True, + ) -> 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) + service = _service_of(host) + authorization = self._build_authorization( + action, payload_json, timestamp, date, host, service + ) headers = { "Authorization": authorization, "Content-Type": "application/json; charset=utf-8", - "Host": self.HOST, + "Host": host, "X-TC-Action": action, "X-TC-Timestamp": str(timestamp), - "X-TC-Version": self.VERSION, - "X-TC-Region": self._region(), + "X-TC-Version": version, } + # Billing / Domain / DNSPod 是中心化服务,带 Region 反而可能报参数错误 + if with_region: + headers["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 = client.post(f"https://{host}", content=payload_json, headers=headers) resp.raise_for_status() data = resp.json() response = data.get("Response", {}) @@ -93,16 +135,18 @@ class TencentAdapter(BaseAdapter): 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", []) + charge = inst.get("InstanceChargeType") result.append( NormalizedVPS( external_id=inst.get("InstanceId"), @@ -113,8 +157,11 @@ class TencentAdapter(BaseAdapter): 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"), + status=_STATUS_MAP.get(inst.get("InstanceState"), inst.get("InstanceState") or "unknown"), currency="CNY", + # 预付费实例必须带上到期日,否则续费提醒对云端同步的机器完全失效 + expiry_date=parse_iso_date(inst.get("ExpiredTime")), + charge_type=_CHARGE_TYPE_MAP.get(charge, charge), raw=inst, ) ) @@ -123,3 +170,190 @@ class TencentAdapter(BaseAdapter): if not inst_set or offset >= total: break return result + + def list_domains(self) -> list: + """注册域名清单(domain.tencentcloudapi.com DescribeDomainNameList)""" + result = [] + offset = 0 + while True: + data = self._call( + "DescribeDomainNameList", + {"Offset": offset, "Limit": 100}, + host=DOMAIN_HOST, + version=DOMAIN_VERSION, + with_region=False, + ) + domains = data.get("DomainSet", []) + for d in domains: + result.append( + NormalizedDomain( + external_id=d.get("DomainName") or d.get("DomainId"), + domain_name=d.get("DomainName"), + registrar="tencent", + # 该接口返回 ExpireDate(毫秒时间戳)或 ExpireTime(ISO),两者都兼容 + expiry_date=parse_iso_date(d.get("ExpireTime") or self._ms_to_iso(d.get("ExpireDate"))), + status="active", + raw=d, + ) + ) + total = int(data.get("TotalCount") or 0) + offset += len(domains) + if not domains or offset >= total: + break + return result + + @staticmethod + def _ms_to_iso(value) -> str: + """毫秒时间戳 -> ISO 字符串(parse_iso_date 只取前 10 位)""" + try: + return time.strftime("%Y-%m-%d", time.gmtime(int(value) / 1000)) + except (TypeError, ValueError): + return "" + + def get_account(self) -> AccountInfo: + """账户余额(billing.tencentcloudapi.com DescribeAccountBalance) + + 注意 Balance 单位是「分」,必须除 100 才是元,否则台账金额会放大百倍。 + """ + data = self._call( + "DescribeAccountBalance", + host=BILLING_HOST, + version=BILLING_VERSION, + with_region=False, + ) + balance = data.get("Balance") + return AccountInfo( + balance=round(balance / 100, 2) if isinstance(balance, (int, float)) else None, + currency="CNY", + raw=data, + ) + + # ---------------- 实例管理动作 ---------------- + + def _action(self, action: str, payload: dict, label: str) -> dict: + try: + data = self._call(action, payload) + return {"ok": True, "message": f"{label}指令已下发", "raw": data} + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"HTTP {e.response.status_code}:{label}失败"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"{label}失败:{e}"} + + def start_instance(self, external_id: str) -> dict: + return self._action("StartInstances", {"InstanceIds": [external_id]}, "开机") + + def stop_instance(self, external_id: str) -> dict: + # SOFT_FIRST:先正常关机,超时才强制;直接 HARD 有丢数据风险 + return self._action( + "StopInstances", + {"InstanceIds": [external_id], "StopType": "SOFT_FIRST", "StoppedBehavior": "KEEP_CHARGING"}, + "关机", + ) + + def reboot_instance(self, external_id: str) -> dict: + return self._action( + "RebootInstances", {"InstanceIds": [external_id], "RebootType": "SOFT_FIRST"}, "重启" + ) + + def renew_instance(self, external_id: str, period_months: int = 1) -> dict: + # 仅对 PREPAID 实例有效;Period 单位为月 + return self._action( + "RenewInstances", + { + "InstanceIds": [external_id], + "InstanceChargePrepaid": { + "RenewFlag": "NOTIFY_AND_MANUAL_RENEW", + "Period": period_months, + }, + }, + f"续费 {period_months} 个月", + ) + + # ---------------- DNS 记录管理 ---------------- + + def _dnspod(self, action: str, payload: dict) -> dict: + return self._call( + action, payload, host=DNSPOD_HOST, version=DNSPOD_VERSION, with_region=False + ) + + def list_dns_records(self, zone_id: str) -> list: + data = self._dnspod("DescribeRecordList", {"Domain": zone_id}) + result = [] + for r in data.get("RecordList", []): + sub = r.get("Name") or "@" + result.append( + NormalizedDNSRecord( + record_id=str(r.get("RecordId") or r.get("ID")), + zone_id=zone_id, + name=self._fqdn(sub, zone_id), + type=r.get("Type"), + content=r.get("Value"), + ttl=int(r.get("TTL")) if r.get("TTL") else None, + priority=int(r["MX"]) if r.get("MX") else None, + comment=r.get("Comment"), + raw=r, + ) + ) + return result + + @staticmethod + def _fqdn(sub: str, zone_id: str) -> str: + """DNSPod 存的是子域(Name,如 www / @),拼成完整域名供台账展示""" + if not sub or sub == "@": + return zone_id + return f"{sub}.{zone_id}" + + @staticmethod + def _sub(name: str, zone_id: str) -> str: + """反向:把完整域名拆回子域""" + if name == zone_id: + return "@" + suffix = f".{zone_id}" + return name[: -len(suffix)] if name.endswith(suffix) else name + + def create_dns_record(self, zone_id: str, record: dict) -> dict: + payload = { + "Domain": zone_id, + "SubDomain": self._sub(record.get("name", ""), zone_id), + "RecordType": record.get("type", "A"), + "RecordLine": record.get("line") or "默认", + "Value": record.get("content", ""), + "TTL": record.get("ttl") or 600, + } + if record.get("priority"): + payload["MX"] = record["priority"] + try: + data = self._dnspod("CreateRecord", payload) + return { + "ok": True, + "message": "记录已创建", + "record_id": str(data.get("RecordId", "")), + "raw": data, + } + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"创建失败:{e}"} + + def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict: + payload = { + "Domain": zone_id, + "RecordId": record_id, + "SubDomain": self._sub(record.get("name", ""), zone_id), + "RecordType": record.get("type", "A"), + "RecordLine": record.get("line") or "默认", + "Value": record.get("content", ""), + "TTL": record.get("ttl") or 600, + } + if record.get("priority"): + payload["MX"] = record["priority"] + try: + data = self._dnspod("ModifyRecord", payload) + return {"ok": True, "message": "记录已更新", "raw": data} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"更新失败:{e}"} + + def delete_dns_record(self, zone_id: str, record_id: str) -> dict: + try: + data = self._dnspod("DeleteRecord", {"Domain": zone_id, "RecordId": record_id}) + return {"ok": True, "message": "记录已删除", "raw": data} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"删除失败:{e}"} diff --git a/app/adapters/vultr.py b/app/adapters/vultr.py index 177b13d..829a338 100644 --- a/app/adapters/vultr.py +++ b/app/adapters/vultr.py @@ -2,7 +2,9 @@ API 文档:https://www.vultr.com/api/ 所需配置:{"api_key": "..."} -支持功能:实例列表、订阅账单、区域查询 +支持功能:实例列表、订阅账单、区域查询、实例启停/重启、DNS 记录管理 +DNS 的 zone_id 即域名本身(Vultr 用域名字符串定位,非 uuid)。 +Vultr 为按小时计费的订阅制,无「续费」概念,故不实现 renew_instance。 """ import httpx @@ -10,6 +12,7 @@ import httpx from app.adapters.base import ( AccountInfo, BaseAdapter, + NormalizedDNSRecord, NormalizedDomain, NormalizedVPS, parse_iso_date, @@ -25,11 +28,15 @@ class VultrAdapter(BaseAdapter): def _headers(self) -> dict: return {"Authorization": f"Bearer {self.config.get('api_key', '')}"} - def _get(self, path: str) -> dict: + def _request(self, method: str, path: str, json_body: dict = None) -> dict: with httpx.Client(timeout=30) as client: - resp = client.get(self.BASE + path, headers=self._headers()) + resp = client.request(method, self.BASE + path, headers=self._headers(), json=json_body) resp.raise_for_status() - return resp.json() + # Vultr 的动作类接口成功时返回 204 无 body + return resp.json() if resp.content else {} + + def _get(self, path: str) -> dict: + return self._request("GET", path) def test_connection(self) -> dict: try: @@ -73,6 +80,8 @@ class VultrAdapter(BaseAdapter): status="active" if inst.get("status") == "active" else (inst.get("status") or "unknown"), monthly_cost=inst.get("monthly_cost"), currency="USD", + # Vultr 按小时计费、无到期概念,标 postpaid 让前端不去催续费 + charge_type="postpaid", raw=inst, ) ) @@ -104,3 +113,99 @@ class VultrAdapter(BaseAdapter): if not cursor: break return result + + # ---------------- 实例管理动作 ---------------- + + def _instance_action(self, external_id: str, verb: str, label: str) -> dict: + try: + self._request("POST", f"/instances/{external_id}/{verb}") + return {"ok": True, "message": f"{label}指令已下发"} + except httpx.HTTPStatusError as e: + return {"ok": False, "message": f"HTTP {e.response.status_code}:{label}失败"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": f"{label}失败:{e}"} + + def start_instance(self, external_id: str) -> dict: + return self._instance_action(external_id, "start", "开机") + + def stop_instance(self, external_id: str) -> dict: + return self._instance_action(external_id, "halt", "关机") + + def reboot_instance(self, external_id: str) -> dict: + return self._instance_action(external_id, "reboot", "重启") + + # ---------------- DNS 记录管理 ---------------- + + def list_dns_records(self, zone_id: str) -> list: + result = [] + cursor = "" + while True: + path = f"/domains/{zone_id}/records?per_page=100" + if cursor: + path += f"&cursor={cursor}" + data = self._get(path) + for r in data.get("records", []): + result.append( + NormalizedDNSRecord( + record_id=str(r.get("id")), + zone_id=zone_id, + name=r.get("name") or zone_id, + type=r.get("type"), + content=r.get("data"), + ttl=r.get("ttl"), + priority=r.get("priority"), + raw=r, + ) + ) + cursor = data.get("meta", {}).get("links", {}).get("next") or "" + if not cursor: + break + return result + + @staticmethod + def _record_body(record: dict) -> dict: + body = { + "name": record.get("name", ""), + "type": record.get("type", "A"), + "data": record.get("content", ""), + "ttl": record.get("ttl") or 300, + } + if record.get("priority") is not None: + body["priority"] = record["priority"] + return body + + def create_dns_record(self, zone_id: str, record: dict) -> dict: + try: + data = self._request( + "POST", f"/domains/{zone_id}/records", self._record_body(record) + ) + return { + "ok": True, + "message": "记录已创建", + "record_id": str(data.get("record", {}).get("id", "")), + "raw": data, + } + 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": f"创建失败:{e}"} + + def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict: + try: + self._request( + "PATCH", f"/domains/{zone_id}/records/{record_id}", self._record_body(record) + ) + return {"ok": True, "message": "记录已更新"} + 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": f"更新失败:{e}"} + + def delete_dns_record(self, zone_id: str, record_id: str) -> dict: + try: + self._request("DELETE", f"/domains/{zone_id}/records/{record_id}") + return {"ok": True, "message": "记录已删除"} + 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": f"删除失败:{e}"} diff --git a/app/core/seed.py b/app/core/seed.py index 4bced3f..4e8a735 100644 --- a/app/core/seed.py +++ b/app/core/seed.py @@ -23,21 +23,27 @@ PRESET_PROVIDERS = [ {"slug": "tencent-intl", "name": "腾讯国际", "name_en": "Tencent Cloud Intl", "category": "vps", "services": "vps,domain,ssl_cert,cdn,dns", "website": "https://intl.cloud.tencent.com", "console_url": "https://console.intl.cloud.tencent.com", "sdk_type": "tencent-intl-sdk"}, + {"slug": "aws", "name": "AWS", "name_en": "Amazon Web Services", "category": "vps", + "services": "vps,domain,dns,cdn,ssl_cert", + "website": "https://aws.amazon.com", "console_url": "https://console.aws.amazon.com/ec2/", "sdk_type": "aws-api"}, {"slug": "vultr", "name": "Vultr", "name_en": "Vultr", "category": "vps", - "services": "vps", + "services": "vps,domain,dns", "website": "https://vultr.com", "console_url": "https://my.vultr.com", "sdk_type": "vultr-api"}, {"slug": "digitalocean", "name": "DigitalOcean", "name_en": "DigitalOcean", "category": "vps", - "services": "vps,domain", + "services": "vps,domain,dns", "website": "https://digitalocean.com", "console_url": "https://cloud.digitalocean.com", "sdk_type": "do-api"}, {"slug": "linode", "name": "Linode", "name_en": "Akamai Linode", "category": "vps", - "services": "vps", + "services": "vps,domain,dns", "website": "https://linode.com", "console_url": "https://cloud.linode.com", "sdk_type": "linode-api"}, + # 以下平台暂无适配器:sdk_type 必须留空。 + # 填了不存在的 sdk_type 会让平台卡片显示「API 对接: xxx」,用户建账号点同步才撞 + # 「暂不支持的 SDK 类型」——声明支持却不支持,比明说不支持更糟。 {"slug": "cloudcone", "name": "CloudCone", "name_en": "CloudCone", "category": "vps", "services": "vps", - "website": "https://cloudcone.com", "console_url": "https://app.cloudcone.com", "sdk_type": "cloudcone-api"}, + "website": "https://cloudcone.com", "console_url": "https://app.cloudcone.com", "sdk_type": None}, {"slug": "zeabur", "name": "Zeabur", "name_en": "Zeabur", "category": "vps", "services": "vps,domain", - "website": "https://zeabur.com", "console_url": "https://dash.zeabur.com", "sdk_type": "zeabur-api"}, + "website": "https://zeabur.com", "console_url": "https://dash.zeabur.com", "sdk_type": None}, # ---- 域名注册商 ---- {"slug": "namesilo", "name": "Namesilo", "name_en": "Namesilo", "category": "domain", "services": "domain,dns", @@ -47,7 +53,7 @@ PRESET_PROVIDERS = [ "website": "https://xinnet.com", "console_url": "https://www.xinnet.com", "sdk_type": None}, {"slug": "dynadot", "name": "Dynadot", "name_en": "Dynadot", "category": "domain", "services": "domain,dns", - "website": "https://dynadot.com", "console_url": "https://www.dynadot.com/account/domains", "sdk_type": "dynadot-api"}, + "website": "https://dynadot.com", "console_url": "https://www.dynadot.com/account/domains", "sdk_type": None}, # ---- AI 服务商 ---- {"slug": "openai", "name": "OpenAI", "name_en": "OpenAI", "category": "ai_agent", "services": "ai_agent", diff --git a/app/database.py b/app/database.py index 3c6c7a9..a32e8ec 100644 --- a/app/database.py +++ b/app/database.py @@ -98,8 +98,8 @@ def _migrate_assets_db() -> None: conn.execute(sa.text("ALTER TABLE providers ADD COLUMN last_synced_at DATETIME")) if "services" not in cols: conn.execute(sa.text("ALTER TABLE providers ADD COLUMN services VARCHAR DEFAULT ''")) - # 为已有预设平台补充 services(仅补空值,用户自定义行不动) - _backfill_provider_services(conn) + # 预设平台的 sdk_type / services 对齐当前代码能力(幂等,用户自建行不动) + _align_preset_providers(conn) if insp.has_table("ai_accounts"): cols = {c["name"] for c in insp.get_columns("ai_accounts")} if "api_key_encrypted" not in cols: @@ -182,22 +182,54 @@ def _migrate_plaintext_api_keys(conn) -> None: ) -def _backfill_provider_services(conn) -> None: - """为已有预设平台补充 services 服务列表(只更新 services 为空的预设 slug)""" +def _align_preset_providers(conn) -> None: + """把预设平台的 sdk_type / services 对齐到当前代码能力(幂等) + + seed_providers 只插入缺失 slug、不更新已有行,所以代码侧的能力变化 + (新增适配器、废弃从未实现的 sdk_type)必须在这里落地,否则线上会长期 + 停留在旧声明上。 + + - sdk_type:库里声明的值不在适配器注册表内就清空。留着会让平台卡片显示 + 「API 对接: xxx」,用户建好账号点同步才撞「暂不支持的 SDK 类型」—— + 声明支持却不支持,比明说不支持更糟。 + - services:只增量补标签,不删除。用户可能手动加过标签(线上 aliyun-intl + 就多了 ai_agent/other),按种子值整体覆盖会丢用户配置。 + + 只处理 slug 命中 PRESET_PROVIDERS 的行,用户自建平台(如 tothost)不动。 + """ import sqlalchemy as sa + from app.adapters import registry from app.core.seed import PRESET_PROVIDERS - for data in PRESET_PROVIDERS: - services = data.get("services") - if not services: + supported = set(registry.supported_types()) + preset = {d["slug"]: d for d in PRESET_PROVIDERS} + + rows = conn.execute( + sa.text("SELECT id, slug, sdk_type, services FROM providers") + ).fetchall() + + for row in rows: + data = preset.get(row[1]) + if not data: continue - conn.execute( - sa.text( - "UPDATE providers SET services = :services " - "WHERE slug = :slug AND (services IS NULL OR services = '')" - ), - {"services": services, "slug": data["slug"]}, - ) + + declared = row[2] + if declared and declared not in supported: + conn.execute( + sa.text("UPDATE providers SET sdk_type = NULL WHERE id = :id"), + {"id": row[0]}, + ) + + seed_services = [s.strip() for s in (data.get("services") or "").split(",") if s.strip()] + if not seed_services: + continue + current = [s.strip() for s in (row[3] or "").split(",") if s.strip()] + missing = [s for s in seed_services if s not in current] + if missing: + conn.execute( + sa.text("UPDATE providers SET services = :services WHERE id = :id"), + {"services": ",".join(current + missing), "id": row[0]}, + ) def _dedupe_accounts(conn) -> None: diff --git a/app/main.py b/app/main.py index 4b5bd7c..5257415 100644 --- a/app/main.py +++ b/app/main.py @@ -23,7 +23,7 @@ from sqlmodel import Session from app.core.config import settings from app.core.seed import seed_providers from app.database import assets_engine, init_db -from app.routers import accounts, agent, assets, credentials, monitor, notify, providers, ssl, stats, sync +from app.routers import accounts, actions, agent, assets, credentials, monitor, notify, providers, ssl, stats, sync from app.services import cleanup_service BASE_DIR = Path(__file__).resolve().parent.parent @@ -138,6 +138,7 @@ app.include_router(stats.router) app.include_router(agent.router) app.include_router(monitor.router) app.include_router(sync.router) +app.include_router(actions.router) app.include_router(notify.router) app.include_router(ssl.router) diff --git a/app/routers/actions.py b/app/routers/actions.py new file mode 100644 index 0000000..6dcc4e2 --- /dev/null +++ b/app/routers/actions.py @@ -0,0 +1,87 @@ +"""云资源管理动作路由 + +统一入口 /api/actions/{asset_id},把资产解析成对应平台适配器后下发真实云端指令。 + +安全约束: +- 所有写操作(实例动作、DNS 增删改)强制 require_api_key,与项目其余写端点一致 +- 每次动作由 action_service 落 EventLog 审计(source="action"),成功失败都记 +- 能力探测端点 GET /capabilities 无副作用,供前端预先禁用不支持的按钮 +""" + +from fastapi import APIRouter, Depends +from sqlmodel import Session + +from app.core.security import require_api_key +from app.database import get_session +from app.schemas.action import DNSRecordIn, InstanceActionRequest +from app.services import action_service + +router = APIRouter(prefix="/api/actions", tags=["actions"]) + + +@router.get( + "/{asset_id}/capabilities", + summary="查询该资产可执行的动作(前端据此渲染/禁用按钮)", +) +def capabilities(asset_id: int, session: Session = Depends(get_session)) -> dict: + return action_service.asset_actions(session, asset_id) + + +@router.post( + "/{asset_id}/instance", + summary="实例动作:开机 / 关机 / 重启 / 续费(真实操作云端资源)", + dependencies=[Depends(require_api_key)], +) +def instance_action( + asset_id: int, data: InstanceActionRequest, session: Session = Depends(get_session) +) -> dict: + return action_service.perform_instance_action( + session, asset_id, data.action, data.period_months + ) + + +@router.get( + "/{asset_id}/dns", + summary="列出该域名的 DNS 解析记录", + dependencies=[Depends(require_api_key)], +) +def list_dns(asset_id: int, session: Session = Depends(get_session)) -> dict: + return action_service.list_dns_records(session, asset_id) + + +@router.post( + "/{asset_id}/dns", + summary="新增 DNS 解析记录", + dependencies=[Depends(require_api_key)], +) +def create_dns( + asset_id: int, record: DNSRecordIn, session: Session = Depends(get_session) +) -> dict: + return action_service.create_dns_record(session, asset_id, record.model_dump()) + + +@router.put( + "/{asset_id}/dns/{record_id}", + summary="修改 DNS 解析记录", + dependencies=[Depends(require_api_key)], +) +def update_dns( + asset_id: int, + record_id: str, + record: DNSRecordIn, + session: Session = Depends(get_session), +) -> dict: + return action_service.update_dns_record( + session, asset_id, record_id, record.model_dump() + ) + + +@router.delete( + "/{asset_id}/dns/{record_id}", + summary="删除 DNS 解析记录", + dependencies=[Depends(require_api_key)], +) +def delete_dns( + asset_id: int, record_id: str, session: Session = Depends(get_session) +) -> dict: + return action_service.delete_dns_record(session, asset_id, record_id) diff --git a/app/routers/sync.py b/app/routers/sync.py index 13aaa27..7f54d66 100644 --- a/app/routers/sync.py +++ b/app/routers/sync.py @@ -17,3 +17,12 @@ router = APIRouter(prefix="/api/sync", tags=["sync"]) ) def sync_ai_balances(session: Session = Depends(get_session)) -> dict: return sync_service.sync_all_ai_balances(session) + + +@router.post( + "/assets", + summary="批量同步所有已配凭证账号的云端资产(VPS / 域名)", + dependencies=[Depends(require_api_key)], +) +def sync_assets(session: Session = Depends(get_session)) -> dict: + return sync_service.sync_all_assets(session) diff --git a/app/schemas/action.py b/app/schemas/action.py new file mode 100644 index 0000000..8bb00b2 --- /dev/null +++ b/app/schemas/action.py @@ -0,0 +1,56 @@ +"""云资源管理动作相关 Schema + +动作请求体单独建模而非直接透传 dict:period_months 必须有上界, +否则一次误传 9999 就会真实扣掉一笔巨额续费账单。 +""" + +from typing import Optional + +from pydantic import Field, field_validator +from sqlmodel import SQLModel + +#: 允许的实例动作 +VALID_ACTIONS = ("start", "stop", "reboot", "renew") + +#: 允许的 DNS 记录类型(各厂商交集,避免写入后被 API 拒绝) +VALID_RECORD_TYPES = ( + "A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA", "LOC", "URL", +) + + +class InstanceActionRequest(SQLModel): + """实例动作请求""" + + action: str = Field(description="start/stop/reboot/renew") + period_months: int = Field(default=1, ge=1, le=60, description="续费周期(月),仅 renew 用") + + @field_validator("action") + @classmethod + def _check_action(cls, v: str) -> str: + if v not in VALID_ACTIONS: + raise ValueError(f"action 必须是 {'/'.join(VALID_ACTIONS)} 之一,收到:{v}") + return v + + +class DNSRecordIn(SQLModel): + """DNS 记录输入(create/update 共用) + + name 传完整域名(如 www.example.com)或子域(如 www)均可, + 各适配器按自身约定拆分;content 即记录值。 + """ + + name: str = Field(min_length=1, description="记录名,完整域名或子域") + type: str = Field(default="A", description="记录类型") + content: str = Field(min_length=1, description="记录值") + ttl: Optional[int] = Field(default=None, ge=60, le=86400, description="TTL 秒,留空用厂商默认") + proxied: Optional[bool] = Field(default=None, description="Cloudflare 专用:是否走代理") + priority: Optional[int] = Field(default=None, ge=0, le=65535, description="MX/SRV 优先级") + comment: Optional[str] = Field(default=None, max_length=255, description="备注") + + @field_validator("type") + @classmethod + def _check_type(cls, v: str) -> str: + upper = (v or "").upper() + if upper not in VALID_RECORD_TYPES: + raise ValueError(f"不支持的记录类型:{v}(可选 {'/'.join(VALID_RECORD_TYPES)})") + return upper diff --git a/app/schemas/asset.py b/app/schemas/asset.py index 35bbc41..4c65665 100644 --- a/app/schemas/asset.py +++ b/app/schemas/asset.py @@ -186,6 +186,10 @@ class AssetRead(AssetBase): days_to_expiry: Optional[int] = None provider_name: Optional[str] = None account_name: Optional[str] = None # 账号标识(展示用,由 service 按 account_id 填充) + # 云端实例 ID:只出不进(AssetCreate/AssetUpdate 不收,由 sync_service 写入)。 + # 前端据此判断「云操作」「DNS」按钮是否渲染——没有它按钮永远出不来, + # 手工登记的资产则会渲染出点了必 400 的按钮。 + external_id: Optional[str] = None vps_detail: Optional[VPSDetailRead] = None domain_detail: Optional[DomainDetailRead] = None ai_detail: Optional[AIAccountRead] = None diff --git a/app/services/action_service.py b/app/services/action_service.py new file mode 100644 index 0000000..d0c892c --- /dev/null +++ b/app/services/action_service.py @@ -0,0 +1,282 @@ +"""云资源管理动作服务 + +把「资产 → 账号 → 平台适配器」解析成一次真实的云端写操作。 + +与只读同步的关键区别:这里的每个动作都会改变云端真实资源——关机会中断线上服务、 +续费会真实扣款。因此三条硬约束: +1. 路由层强制 require_api_key(外部流量无 key 一律 401) +2. 每次动作都写 EventLog(source="action"),成功与失败都记,且用独立 Session 提交, + 避免主流程回滚时把审计记录一起丢掉 +3. 适配器不支持该动作时如实抛 400,绝不静默 no-op 让调用方以为成功了 +""" + +import logging + +from fastapi import HTTPException, status +from sqlmodel import Session, select + +from app.adapters import registry +from app.adapters.base import BaseAdapter, cloud_error_text +from app.core.timeutils import utcnow +from app.models.asset import Asset, AssetType, DomainDetail +from app.services.sync_service import _build_adapter_for_account, _missing_config + +logger = logging.getLogger("vps-manager.action") + +#: 动作名 -> 适配器方法名 +INSTANCE_ACTIONS = { + "start": "start_instance", + "stop": "stop_instance", + "reboot": "reboot_instance", + "renew": "renew_instance", +} + +#: 各动作的中文标签,用于审计日志与前端提示 +ACTION_LABELS = { + "start": "开机", + "stop": "关机", + "reboot": "重启", + "renew": "续费", +} + + +def audit(asset_id: int | None, message: str, level: str = "info") -> None: + """写审计日志(独立 Session,主流程回滚也不丢) + + 审计失败绝不能让业务动作失败——云端指令可能已经下发成功, + 此时抛错会让调用方误判为「没执行」,比丢一条日志危险得多。 + """ + from app.database import metrics_engine + from app.models.monitor import EventLog + + try: + with Session(metrics_engine) as s: + s.add(EventLog(level=level, source="action", asset_id=asset_id, message=message)) + s.commit() + except Exception as e: # noqa: BLE001 + logger.warning("审计日志写入失败 asset_id=%s err=%s", asset_id, e) + + +def _resolve(session: Session, asset_id: int) -> tuple[Asset, BaseAdapter, str]: + """解析资产对应的适配器,返回 (asset, adapter, provider_label)""" + asset = session.get(Asset, asset_id) + if not asset: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在") + if not asset.account_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="该资产未关联账号,无法确定使用哪套云凭证执行动作", + ) + from app.models.asset import Account + + account = session.get(Account, asset.account_id) + if not account: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="资产关联的账号已不存在" + ) + provider, adapter = _build_adapter_for_account(session, account) + missing = _missing_config(adapter) + if missing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"缺少凭证配置:{', '.join(missing)}(请先在账号编辑里填写 API 配置)", + ) + return asset, adapter, provider.slug + + +def _require_capability(adapter: BaseAdapter, method_name: str, label: str) -> None: + if not adapter.capabilities().get(method_name): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"{type(adapter).__name__} 不支持{label}", + ) + + +def perform_instance_action( + session: Session, asset_id: int, action: str, period_months: int = 1 +) -> dict: + """对 VPS 资产执行 开机/关机/重启/续费""" + if action not in INSTANCE_ACTIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"未知动作:{action}(可选 {'/'.join(INSTANCE_ACTIONS)})", + ) + asset, adapter, provider_slug = _resolve(session, asset_id) + if asset.asset_type != AssetType.VPS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"仅 VPS 资产支持该动作(当前类型:{asset.asset_type.value})", + ) + if not asset.external_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="该资产没有云端实例 ID(external_id),可能是手工登记的,无法下发指令", + ) + + method_name = INSTANCE_ACTIONS[action] + label = ACTION_LABELS[action] + _require_capability(adapter, method_name, label) + + target = f"{provider_slug}/{asset.name}({asset.external_id})" + audit(asset_id, f"{label}动作下发:{target}" + (f" 周期={period_months}月" if action == "renew" else "")) + try: + method = getattr(adapter, method_name) + result = method(asset.external_id, period_months) if action == "renew" else method(asset.external_id) + except NotImplementedError: + audit(asset_id, f"{label}失败:适配器未实现 {target}", level="warning") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=f"{provider_slug} 暂不支持{label}" + ) from None + except Exception as e: # noqa: BLE001 + msg = cloud_error_text(e) + audit(asset_id, f"{label}异常:{target} err={msg}", level="error") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=f"{label}失败:{msg}" + ) from e + + ok = bool(result.get("ok")) + audit( + asset_id, + f"{label}{'成功' if ok else '被拒'}:{target} msg={result.get('message')}", + level="info" if ok else "error", + ) + return { + "ok": ok, + "action": action, + "asset_id": asset_id, + "provider": provider_slug, + "external_id": asset.external_id, + "message": result.get("message", ""), + "at": utcnow().isoformat(), + } + + +def _resolve_zone(session: Session, asset_id: int) -> tuple[Asset, BaseAdapter, str, str]: + """解析域名资产的 DNS zone 标识,返回 (asset, adapter, provider_slug, zone_id) + + zone_id 取 Asset.external_id:Cloudflare 是 zone uuid,阿里云/腾讯云/Vultr/DO 是域名字符串, + 两者的 list_dns_records 都已按各自约定实现。 + """ + asset, adapter, provider_slug = _resolve(session, asset_id) + if asset.asset_type != AssetType.DOMAIN: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"仅域名资产支持 DNS 记录管理(当前类型:{asset.asset_type.value})", + ) + zone_id = asset.external_id + if not zone_id: + detail = session.exec( + select(DomainDetail).where(DomainDetail.asset_id == asset_id) + ).first() + zone_id = (detail.domain_name if detail else None) or asset.name + if not zone_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="无法确定该域名的 zone 标识(external_id 与 domain_name 均为空)", + ) + return asset, adapter, provider_slug, zone_id + + +def list_dns_records(session: Session, asset_id: int) -> dict: + asset, adapter, provider_slug, zone_id = _resolve_zone(session, asset_id) + _require_capability(adapter, "list_dns_records", "DNS 记录查询") + try: + records = [r.to_dict() for r in adapter.list_dns_records(zone_id)] + except NotImplementedError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=f"{provider_slug} 暂不支持 DNS 记录查询" + ) from None + except Exception as e: # noqa: BLE001 + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=f"查询 DNS 记录失败:{cloud_error_text(e)}" + ) from e + return {"asset_id": asset_id, "provider": provider_slug, "zone_id": zone_id, "records": records} + + +def _dns_write( + session: Session, + asset_id: int, + method_name: str, + label: str, + args: tuple, +) -> dict: + asset, adapter, provider_slug, zone_id = _resolve_zone(session, asset_id) + _require_capability(adapter, method_name, label) + target = f"{provider_slug}/{zone_id}" + audit(asset_id, f"DNS {label}下发:{target} args={args[1:]}") + try: + result = getattr(adapter, method_name)(*args) + except NotImplementedError: + audit(asset_id, f"DNS {label}失败:适配器未实现 {target}", level="warning") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=f"{provider_slug} 暂不支持{label}" + ) from None + except Exception as e: # noqa: BLE001 + msg = cloud_error_text(e) + audit(asset_id, f"DNS {label}异常:{target} err={msg}", level="error") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=f"{label}失败:{msg}" + ) from e + ok = bool(result.get("ok")) + audit( + asset_id, + f"DNS {label}{'成功' if ok else '被拒'}:{target} msg={result.get('message')}", + level="info" if ok else "error", + ) + return { + "ok": ok, + "asset_id": asset_id, + "provider": provider_slug, + "zone_id": zone_id, + "record_id": result.get("record_id"), + "message": result.get("message", ""), + } + + +def create_dns_record(session: Session, asset_id: int, record: dict) -> dict: + _, _, _, zone_id = _resolve_zone(session, asset_id) + return _dns_write(session, asset_id, "create_dns_record", "记录创建", (zone_id, record)) + + +def update_dns_record(session: Session, asset_id: int, record_id: str, record: dict) -> dict: + _, _, _, zone_id = _resolve_zone(session, asset_id) + return _dns_write( + session, asset_id, "update_dns_record", "记录更新", (zone_id, record_id, record) + ) + + +def delete_dns_record(session: Session, asset_id: int, record_id: str) -> dict: + _, _, _, zone_id = _resolve_zone(session, asset_id) + return _dns_write( + session, asset_id, "delete_dns_record", "记录删除", (zone_id, record_id) + ) + + +def asset_actions(session: Session, asset_id: int) -> dict: + """返回某资产当前可执行的动作(前端据此渲染/禁用按钮) + + 手工登记、无 external_id、或适配器不支持的,一律标 False, + 避免用户点了才收到 400。 + """ + try: + asset, adapter, provider_slug = _resolve(session, asset_id) + except HTTPException as e: + return {"asset_id": asset_id, "available": False, "reason": e.detail, "actions": {}} + + caps = adapter.capabilities() + is_vps = asset.asset_type == AssetType.VPS + is_domain = asset.asset_type == AssetType.DOMAIN + has_external = bool(asset.external_id) + actions = { + name: bool(is_vps and has_external and caps.get(method)) + for name, method in INSTANCE_ACTIONS.items() + } + actions["dns_records"] = bool(is_domain and caps.get("list_dns_records")) + actions["dns_write"] = bool(is_domain and caps.get("create_dns_record")) + return { + "asset_id": asset_id, + "provider": provider_slug, + "sdk_type": getattr(type(adapter), "sdk_types", []), + "available": any(actions.values()), + "actions": actions, + } diff --git a/app/services/asset_service.py b/app/services/asset_service.py index d0c00f9..b62fb0d 100644 --- a/app/services/asset_service.py +++ b/app/services/asset_service.py @@ -377,11 +377,15 @@ def import_assets(session: Session, assets_data: list) -> dict: created = 0 updated = 0 errors = [] - skip_fields = {"id", "created_at", "updated_at", "days_to_expiry", "provider_name"} + skip_fields = {"id", "created_at", "updated_at", "days_to_expiry", "provider_name", "account_name"} try: for item in assets_data: try: payload = {k: v for k, v in item.items() if k not in skip_fields} + # external_id 是 sync_service 的写入字段,AssetCreate/AssetUpdate 不收, + # 留在 payload 里会被 pydantic 静默丢弃;导出既然带了它(迁移用), + # 这里就必须落到行上,否则迁移后首次同步会把所有云端资产当新增再建一份。 + external_id = payload.pop("external_id", None) existing = session.exec( select(Asset).where( Asset.name == payload.get("name"), @@ -390,11 +394,14 @@ def import_assets(session: Session, assets_data: list) -> dict: ) ).first() if existing: - _update_asset_no_commit(session, existing.id, AssetUpdate(**payload)) + asset = _update_asset_no_commit(session, existing.id, AssetUpdate(**payload)) updated += 1 else: - _create_asset_no_commit(session, AssetCreate(**payload)) + asset = _create_asset_no_commit(session, AssetCreate(**payload)) created += 1 + if external_id and not asset.external_id: + asset.external_id = external_id + session.add(asset) except Exception as e: # noqa: BLE001 errors.append(f"{item.get('name', '?')}: {e}") if errors: @@ -462,6 +469,9 @@ def get_overview(session: Session) -> dict: overdue = 0 year_cost = 0.0 month_cost = 0.0 + # 同窗口内没填费用的资产数。合计只加得到填了的那部分,缺一项就少算一项, + # 界面必须把这个缺口说出来,否则「本年支出」看着像个准数、实际是下限。 + year_cost_missing = 0 for asset_type, status_val, expiry_date, cost in rows: tval = asset_type.value if hasattr(asset_type, "value") else asset_type @@ -480,7 +490,10 @@ def get_overview(session: Session) -> dict: elif days <= 30: expiring_30 += 1 if expiry_date.year == today.year: - year_cost += cost + if cost: + year_cost += cost + else: + year_cost_missing += 1 if expiry_date.month == today.month: month_cost += cost @@ -493,6 +506,7 @@ def get_overview(session: Session) -> dict: "overdue_count": overdue, "year_cost": round(year_cost, 2), "month_cost": round(month_cost, 2), + "year_cost_missing": year_cost_missing, } diff --git a/app/services/sync_service.py b/app/services/sync_service.py index aa793dd..a3192cd 100644 --- a/app/services/sync_service.py +++ b/app/services/sync_service.py @@ -19,7 +19,7 @@ from sqlmodel import Session, select logger = logging.getLogger("vps-manager.sync") from app.adapters import registry -from app.adapters.base import BaseAdapter, parse_iso_date +from app.adapters.base import BaseAdapter, cloud_error_text, parse_iso_date from app.core import crypto from app.core.timeutils import utcnow from app.models.asset import ( @@ -161,6 +161,9 @@ def _find_asset(session: Session, provider_id: int, external_id: str, asset_type def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, account_id: Optional[int] = None) -> dict: created = updated = 0 for vps in adapter.list_vps(): + # 预付费实例(阿里云/腾讯云包年包月)带到期日,后付费平台为 None。 + # 漏了这一步,云端同步进来的 VPS 永远进不了续费提醒——而那是台账的核心用途。 + expiry = parse_iso_date(vps.expiry_date) existing = _find_asset(session, provider.id, vps.external_id, AssetType.VPS) if existing: existing.name = vps.name or existing.name @@ -168,6 +171,8 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, accoun if vps.monthly_cost is not None: existing.cost = vps.monthly_cost existing.currency = vps.currency + if expiry: + existing.expiry_date = expiry if account_id: existing.account_id = account_id session.add(existing) @@ -194,6 +199,7 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, accoun status=_norm_status(vps.status), cost=vps.monthly_cost or 0, currency=vps.currency, + expiry_date=expiry, ) session.add(asset) session.flush() # 获取 asset.id,统一在循环外提交 @@ -292,18 +298,18 @@ def sync_account(session: Session, account_id: int) -> dict: # 写库失败会让 Session 进入 pending-rollback 状态,必须先回滚才能继续, # 否则下面更新 last_synced_at 的 commit 会抛 PendingRollbackError → 接口 500 session.rollback() - result["vps_error"] = str(e) + result["vps_error"] = cloud_error_text(e) if caps["list_domains"]: try: result["domains"] = _sync_domains(session, provider, adapter, account.id) except Exception as e: # noqa: BLE001 session.rollback() - result["domains_error"] = str(e) + result["domains_error"] = cloud_error_text(e) if caps["get_account"]: try: result["account_info"] = adapter.get_account().to_dict() except Exception as e: # noqa: BLE001 - result["account_info_error"] = str(e) + result["account_info_error"] = cloud_error_text(e) account.last_synced_at = utcnow() session.add(account) @@ -380,7 +386,7 @@ def refresh_ai_balance(session: Session, asset_id: int) -> dict: raise except Exception as e: # noqa: BLE001 raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, detail=f"查询余额失败:{e}" + status_code=status.HTTP_502_BAD_GATEWAY, detail=f"查询余额失败:{cloud_error_text(e)}" ) from e if acc.balance is not None: @@ -436,7 +442,7 @@ def sync_all_ai_balances(session: Session, max_workers: int = 5) -> dict: errors.append(f"{name}: {e.detail}") except Exception as e: # noqa: BLE001 failed += 1 - errors.append(f"{name}: {e}") + errors.append(f"{name}: {cloud_error_text(e)}") return { "total": len(asset_ids), "success": success, @@ -445,3 +451,86 @@ def sync_all_ai_balances(session: Session, max_workers: int = 5) -> dict: "errors": errors, "synced_at": utcnow().isoformat(), } + + +def sync_all_assets(session: Session, max_workers: int = 5) -> dict: + """并发同步所有已配凭证账号的云端资产(VPS / 域名) + + 与 sync_all_ai_balances 同构:单个账号失败不中断整体,每个 worker 用独立 + Session(SQLite Session 不能跨线程共享,WAL 模式下多连接读写安全)。 + + 未配凭证或平台无适配器的账号计入 skipped 而非 failed——否则每次定时同步 + 都会把手工登记的账号报成一堆错误。 + """ + from concurrent.futures import ThreadPoolExecutor + + from app.database import assets_engine + + accounts = session.exec(select(Account)).all() + targets = [] + skipped_no_config = [] + skipped_no_adapter = [] + for acc in accounts: + label = f"{acc.platform}/{acc.name}" + if not acc.api_config_encrypted: + skipped_no_config.append(label) + continue + try: + provider = _find_provider_by_platform(session, acc.platform) + except HTTPException: + skipped_no_adapter.append(f"{label}(平台不存在)") + continue + if not provider.sdk_type or not registry.is_supported(provider.sdk_type): + skipped_no_adapter.append(f"{label}({provider.sdk_type or '未配 sdk_type'})") + continue + targets.append((acc.id, label)) + + def _sync_one(account_id: int) -> dict: + with Session(assets_engine) as worker_session: + return sync_account(worker_session, account_id) + + success = 0 + failed = 0 + errors = [] + created = updated = 0 + with ThreadPoolExecutor(max_workers=max_workers) as pool: + futures = {pool.submit(_sync_one, aid): label for aid, label in targets} + for future in futures: + label = futures[future] + try: + result = future.result() + created += (result.get("vps", {}) or {}).get("created", 0) + created += (result.get("domains", {}) or {}).get("created", 0) + updated += (result.get("vps", {}) or {}).get("updated", 0) + updated += (result.get("domains", {}) or {}).get("updated", 0) + # 局部失败(某一类资产同步抛错)不整体算失败,但必须上报 + sub_error_keys = [ + k for k in ("vps_error", "domains_error", "account_info_error") if result.get(k) + ] + for key in sub_error_keys: + errors.append(f"{label} {key}: {result[key]}") + # 但「每一项尝试都失败」不能算成功:凭证过期时三类全 401, + # 若仍计成功,汇总行会显示「成功 1」,用户以为拉到了数据、实际一条没有。 + attempted_any = any(result.get(k) is not None for k in ("vps", "domains", "account_info")) + if sub_error_keys and not attempted_any: + failed += 1 + else: + success += 1 + except HTTPException as e: + failed += 1 + errors.append(f"{label}: {e.detail}") + except Exception as e: # noqa: BLE001 + failed += 1 + errors.append(f"{label}: {cloud_error_text(e)}") + + return { + "total": len(targets), + "success": success, + "failed": failed, + "created": created, + "updated": updated, + "skipped_no_config": len(skipped_no_config), + "skipped_no_adapter": len(skipped_no_adapter), + "errors": errors, + "synced_at": utcnow().isoformat(), + } diff --git a/deploy/setup.sh b/deploy/setup.sh index b014297..b196c80 100755 --- a/deploy/setup.sh +++ b/deploy/setup.sh @@ -80,13 +80,15 @@ cp deploy/vps-manager-update.service deploy/vps-manager-update.timer /etc/system cp deploy/vps-backup-assets.service deploy/vps-backup-assets.timer /etc/systemd/system/ cp deploy/vps-backup-metrics.service deploy/vps-backup-metrics.timer /etc/systemd/system/ cp deploy/vps-sync-ai.service deploy/vps-sync-ai.timer /etc/systemd/system/ +cp deploy/vps-sync-assets.service deploy/vps-sync-assets.timer /etc/systemd/system/ cp deploy/vps-renewal-check.service deploy/vps-renewal-check.timer /etc/systemd/system/ cp deploy/vps-cert-check.service deploy/vps-cert-check.timer /etc/systemd/system/ systemctl daemon-reload systemctl enable --now vps-manager systemctl enable --now vps-manager-update.timer systemctl enable --now vps-backup-assets.timer vps-backup-metrics.timer -systemctl enable --now vps-sync-ai.timer vps-renewal-check.timer vps-cert-check.timer +# vps-sync-assets 必须 enable:它 08:30 跑,早于 09:00 的续费检查,漏了会让提醒读到旧到期日 +systemctl enable --now vps-sync-ai.timer vps-sync-assets.timer vps-renewal-check.timer vps-cert-check.timer echo "==> [6/6] 验证服务状态" sleep 3 diff --git a/deploy/vps-sync-assets.service b/deploy/vps-sync-assets.service new file mode 100644 index 0000000..45cdb4b --- /dev/null +++ b/deploy/vps-sync-assets.service @@ -0,0 +1,13 @@ +# 云资产同步服务(oneshot,由 timer 触发) +# 部署位置:/etc/systemd/system/vps-sync-assets.service + +[Unit] +Description=Sync all cloud assets (VPS / domains) from configured accounts +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=/opt/vps-manager +EnvironmentFile=-/opt/vps-manager/.env +ExecStart=/opt/vps-manager/.venv/bin/python /opt/vps-manager/scripts/sync_all_assets.py diff --git a/deploy/vps-sync-assets.timer b/deploy/vps-sync-assets.timer new file mode 100644 index 0000000..c672041 --- /dev/null +++ b/deploy/vps-sync-assets.timer @@ -0,0 +1,16 @@ +# 云资产每日同步定时器 +# 部署位置:/etc/systemd/system/vps-sync-assets.timer +# +# 08:30 而非 daily 随机时刻:必须早于 vps-renewal-check(09:00), +# 否则续费提醒会读到上一天的旧到期日。 + +[Unit] +Description=Daily sync of cloud assets (VPS / domains) + +[Timer] +OnCalendar=*-*-* 08:30:00 +Persistent=true +Unit=vps-sync-assets.service + +[Install] +WantedBy=timers.target diff --git a/scripts/sync_all_assets.py b/scripts/sync_all_assets.py new file mode 100644 index 0000000..d78c141 --- /dev/null +++ b/scripts/sync_all_assets.py @@ -0,0 +1,38 @@ +"""定时同步所有已配凭证账号的云端资产(供 systemd timer 调用) + +用法:python scripts/sync_all_assets.py +依赖 .env 中的 MASTER_KEY(解密各账号 API 配置)。 + +排程须早于 vps-renewal-check(09:00):续费提醒读的是本地库的 expiry_date, +若同步在其之后跑,提醒就会用上一天的旧到期日。 +""" + +import sys +from pathlib import Path + +# 确保能导入 app 包(脚本位于 scripts/ 子目录) +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from sqlmodel import Session + +from app.database import assets_engine, init_db +from app.services import sync_service + + +def main() -> int: + init_db() + with Session(assets_engine) as session: + result = sync_service.sync_all_assets(session) + print( + f"[云资产同步] 账号 {result['total']} | 成功 {result['success']} | 失败 {result['failed']} " + f"| 新增 {result['created']} | 更新 {result['updated']} " + f"| 跳过(无凭证) {result['skipped_no_config']} | 跳过(无适配器) {result['skipped_no_adapter']}" + ) + for err in result["errors"]: + print(f" - {err}") + # 有账号同步失败时返回非 0,让 systemd 记为 failed 便于告警 + return 1 if result["failed"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/static/js/api.js b/static/js/api.js index 0c79dd0..33d5ec0 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -134,6 +134,15 @@ window.VpsFmt = { if (d === 0) return '今天到期'; return d + (opts.unit === '' ? ' 天' : ' 天后'); }, + /* 费用文案:cost 为 0 一律显示「未填」,不显示「0 USD」 + 0 有两种来源——用户没填、或云端接口压根不返回价格(阿里云/腾讯云/AWS 的 + DescribeInstances 都没有价格字段)。显示成「0 USD」会被读成「这台不花钱」, + 而汇总处(订阅页筛选、月度合计)又把 0 当作「无费用」排除掉,两处口径不一致。 + 统一走这里,让「没数据」和「真的免费」在界面上不再混为一谈。 */ + costText(cost, currency) { + if (!cost) return '未填'; + return currency ? cost + ' ' + currency : String(cost); + }, checkBadge(s) { return { pass: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', fail: 'bg-red-500/10 text-red-600 dark:text-red-400', unknown: 'bg-slate-500/10 text-slate-500' }[s] || 'bg-slate-500/10 text-slate-500'; }, diff --git a/static/js/app.js b/static/js/app.js index a1594ff..e2fd3d1 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -87,6 +87,8 @@ const AppRoot = { + +
{{ toast.text }}
@@ -136,6 +138,8 @@ app.component('provider-modal', ProviderModal); app.component('account-modal', AccountModal); app.component('accounts-view-modal', AccountsViewModal); app.component('credential-modal', CredentialModal); +app.component('action-modal', ActionModal); +app.component('dns-modal', DnsModal); applyDark(); initRouter(); loadAll(); diff --git a/static/js/modals.js b/static/js/modals.js index 8f7e5b9..dd0a9c2 100644 --- a/static/js/modals.js +++ b/static/js/modals.js @@ -594,3 +594,206 @@ const CredentialModal = { return { store, Fmt, f, oauthGroups, oauthUnmatched, pickOauth, dropOauth: dropOauthProvider, save }; }, }; + +/* ================= 云资源操作弹窗(启停/重启/续费) ================= + * 每个按钮都会真实改变云端资源,所以: + * 1. 可用性完全由后端 /actions/{id}/capabilities 决定,前端不自作主张放开 + * 2. 点击后先出二次确认条,写清后果(续费会扣款、关机会中断服务) + * 3. 不支持时如实说明原因,而不是给一个点了才报错的按钮 */ +const ActionModal = { + template: ` +
+
+
+

⚡ 云资源操作

+ +
+
+
{{ m.asset && m.asset.name }}
+
+ {{ m.asset.provider }} · {{ m.asset.external_id }} +
+ +
正在查询该平台支持的操作…
+ +
{{ m.error }}
+ + +
+
该平台暂不支持远程操作
+
{{ m.caps.reason || '适配器未实现对应能力,请到云厂商控制台操作。' }}
+
+ + +
+
+
`, + setup() { + const m = actionModal; + const actions = [ + { key: 'start', label: '开机', icon: '▶', cls: 'border-emerald-300 dark:border-emerald-800 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20' }, + { key: 'stop', label: '关机', icon: '⏻', cls: 'border-red-300 dark:border-red-800 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20' }, + { key: 'reboot', label: '重启', icon: '⟳', cls: 'border-amber-300 dark:border-amber-800 text-amber-700 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20' }, + { key: 'renew', label: '续费', icon: '💳', cls: 'border-blue-300 dark:border-blue-800 text-blue-700 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20' }, + ]; + const capsText = Vue.computed(() => { + if (!m.caps || !m.caps.sdk_type || !m.caps.sdk_type.length) return '未知'; + return m.caps.sdk_type.join(' / '); + }); + return { + m, actions, capsText, + label: (k) => ACTION_LABELS[k] || k, + warn: (k) => ACTION_WARNS[k] || '', + ask: askAction, run: runAction, close: closeActions, + }; + }, +}; + +/* ================= DNS 解析记录管理弹窗 ================= */ +const DnsModal = { + template: ` +
+
+
+

🌐 DNS 解析 · {{ m.zoneId }}

+
+ + +
+
+ +
+
正在读取解析记录…
+ +
{{ m.error }}
+ + +
+
+
`, + setup() { + const m = dnsModal; + const isCloudflare = Vue.computed(() => m.provider === 'cloudflare' || m.provider === 'cloudflare-api'); + // 显式判 null:MX 优先级 0 是合法值且为 falsy,用 r.priority 直接当真值会漏显示 + const hasPriority = (r) => r.priority !== null && r.priority !== undefined && r.priority !== ''; + return { + m, recordTypes: RECORD_TYPES, isCloudflare, hasPriority, + newRecord: newDnsRecord, editRecord: editDnsRecord, cancelEdit: cancelDnsEdit, + save: saveDnsRecord, del: deleteDnsRecord, close: closeDns, + }; + }, +}; diff --git a/static/js/store.js b/static/js/store.js index a9f5a42..d69b9dc 100644 --- a/static/js/store.js +++ b/static/js/store.js @@ -28,6 +28,10 @@ const store = reactive({ assets: [], // 列表口径:按当前筛选条件(类型/状态/平台/搜索)的结果 allAssets: [], // 统计口径:不带筛选的全量资产(总览/监控卡片/平台计数用) providers: [], + // 适配器能力矩阵(/providers/adapters):{adapters, supported_types, capabilities} + // 前端据此如实展示「这个平台到底能不能同步/能不能启停/能不能管 DNS」, + // 而不是只看 Provider.sdk_type 有没有值就宣称支持 + adapters: { adapters: [], supported_types: [], capabilities: {} }, accounts: [], subdomains: [], siteCerts: [], @@ -134,6 +138,7 @@ async function loadAssets() { store.assets = await Api.get('/assets?' + params.toString()); } async function loadProviders() { store.providers = await Api.get('/providers'); } +async function loadAdapters() { store.adapters = await Api.get('/providers/adapters'); } async function loadAccounts() { store.accounts = await Api.get('/accounts'); } async function loadCredentials() { const params = new URLSearchParams(); @@ -147,7 +152,7 @@ async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs'); async function loadOverview() { store.overview = await Api.get('/stats/overview'); } async function loadAll() { store.loading = true; store.error = ''; - try { await Promise.all([loadAssets(), loadProviders(), loadAccounts(), loadCredentials(), loadSubdomains(), loadSiteCerts(), loadOverview()]); } + try { await Promise.all([loadAssets(), loadProviders(), loadAdapters(), loadAccounts(), loadCredentials(), loadSubdomains(), loadSiteCerts(), loadOverview()]); } catch (e) { store.error = e.message; } finally { store.loading = false; } } @@ -523,6 +528,178 @@ async function viewServer(a) { } } +/* ---------------- 云端能力查询 ---------------- + * 能力矩阵来自后端 /providers/adapters(registry.sdk_capabilities()), + * 是「适配器真的实现了这个方法」的事实源。前端一律以它为准, + * 不再拿 Provider.sdk_type 有没有值当支持依据——填了不存在的 sdk_type + * 会让按钮可点,用户点了才收到 400。 */ +function sdkCaps(sdkType) { + if (!sdkType) return {}; + return (store.adapters.capabilities || {})[sdkType] || {}; +} +function providerSdkType(slug) { + const p = store.providers.find(x => x.slug === slug || x.name === slug); + return p ? (p.sdk_type || null) : null; +} +/** 某平台是否声明了可用适配器(如实展示「未对接」而不是显示一个假的 sdk_type) */ +function providerIsSupported(slug) { + const t = providerSdkType(slug); + return !!t && (store.adapters.supported_types || []).includes(t); +} +/** 平台在某能力上是否可用,如 capsOf('aliyun','list_vps') */ +function capsOf(slug, cap) { + return !!sdkCaps(providerSdkType(slug))[cap]; +} +/** 资产 -> 所属平台的 sdk_type(优先 provider_id,回退平台名/slug) */ +function assetSdkType(a) { + if (!a) return null; + const p = a.provider_id + ? store.providers.find(x => x.id === a.provider_id) + : store.providers.find(x => x.slug === a.provider || x.name === a.provider || x.slug === a.provider_name); + return p ? (p.sdk_type || null) : null; +} +/** 资产在某能力上是否可用;没配适配器的平台一律 false,按钮不渲染 */ +function assetCan(a, cap) { + return !!sdkCaps(assetSdkType(a))[cap]; +} +const INSTANCE_CAPS = ['start_instance', 'stop_instance', 'reboot_instance', 'renew_instance']; +/** VPS 是否可远程操作(任一实例动作可用即可) */ +function assetCanControl(a) { + return a && a.asset_type === 'vps' && !!a.external_id && INSTANCE_CAPS.some(c => assetCan(a, c)); +} +/** 域名是否可管 DNS 解析 */ +function assetCanDns(a) { + return a && a.asset_type === 'domain' && assetCan(a, 'list_dns_records'); +} + +/* ---------------- 云资源动作(启停/重启/续费) ---------------- + * 这些动作会真实改变云端资源:关机会中断线上服务、续费会真实扣款。 + * 因此一律走二次确认弹窗,且按钮可用性由后端 /actions/{id}/capabilities 决定。 */ +const ACTION_LABELS = { start: '开机', stop: '关机', reboot: '重启', renew: '续费' }; +const ACTION_WARNS = { + start: '将向云端下发开机指令。', + stop: '将向云端下发关机指令,机器上运行的服务会立即中断。', + reboot: '将向云端下发重启指令,机器会短暂不可用。', + renew: '将向云端下发续费指令,会产生真实扣款。', +}; + +const actionModal = reactive({ + show: false, asset: null, caps: null, loading: true, busy: false, + confirm: null, // 待二次确认的动作名 + periodMonths: 1, + error: '', +}); + +function openActions(asset) { + actionModal.asset = asset; + actionModal.caps = null; + actionModal.loading = true; + actionModal.confirm = null; + actionModal.periodMonths = 1; + actionModal.error = ''; + actionModal.show = true; + Api.get('/actions/' + asset.id + '/capabilities') + .then(r => { actionModal.caps = r; }) + .catch(e => { actionModal.error = e.message; }) + .finally(() => { actionModal.loading = false; }); +} +function closeActions() { actionModal.show = false; actionModal.confirm = null; } +function askAction(name) { actionModal.confirm = name; } +async function runAction(name) { + if (actionModal.busy) return; + actionModal.busy = true; actionModal.error = ''; + try { + const body = { action: name }; + if (name === 'renew') body.period_months = actionModal.periodMonths; + const r = await Api.post('/actions/' + actionModal.asset.id + '/instance', body); + if (r.ok) { + showToast('✓ ' + ACTION_LABELS[name] + '指令已下发'); + actionModal.confirm = null; + await loadAllAssets(); + if (store.view === 'assets') await loadAssets(); + } else { + actionModal.error = r.message || '云端拒绝了该操作'; + } + } catch (e) { actionModal.error = e.message; } + finally { actionModal.busy = false; } +} + +/* ---------------- DNS 记录管理 ---------------- */ +const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA']; +const dnsModal = reactive({ + show: false, asset: null, zoneId: '', provider: '', + records: [], loading: true, busy: false, error: '', + editing: null, // null=关闭表单 / 'new'=新建 / record 对象=编辑 + form: null, +}); + +function openDns(asset) { + dnsModal.asset = asset; + dnsModal.records = []; + dnsModal.loading = true; + dnsModal.error = ''; + dnsModal.editing = null; + // 先用资产自带的平台 slug 兜底:列表请求失败时 provider 拿不到后端返回值, + // 而 isCloudflare 靠它决定是否显示「走 Cloudflare 代理」勾选框。 + dnsModal.provider = asset.provider || ''; + dnsModal.show = true; + Api.get('/actions/' + asset.id + '/dns') + .then(r => { dnsModal.records = r.records || []; dnsModal.zoneId = r.zone_id; dnsModal.provider = r.provider; }) + .catch(e => { dnsModal.error = e.message; }) + .finally(() => { dnsModal.loading = false; }); +} +function closeDns() { dnsModal.show = false; dnsModal.editing = null; } +function reloadDns() { + dnsModal.loading = true; dnsModal.error = ''; + Api.get('/actions/' + dnsModal.asset.id + '/dns') + .then(r => { dnsModal.records = r.records || []; }) + .catch(e => { dnsModal.error = e.message; }) + .finally(() => { dnsModal.loading = false; }); +} +function newDnsRecord() { + dnsModal.form = { name: '', type: 'A', content: '', ttl: null, priority: null, proxied: null, comment: '' }; + dnsModal.editing = 'new'; +} +function editDnsRecord(r) { + dnsModal.form = { + name: r.name || '', type: r.type || 'A', content: r.content || '', + ttl: r.ttl || null, priority: r.priority ?? null, proxied: r.proxied ?? null, comment: r.comment || '', + }; + dnsModal.editing = r; +} +function cancelDnsEdit() { dnsModal.editing = null; dnsModal.form = null; } +async function saveDnsRecord() { + if (dnsModal.busy) return; + dnsModal.busy = true; dnsModal.error = ''; + const isNew = dnsModal.editing === 'new'; + const body = { ...dnsModal.form }; + // 空字符串会被后端的 ge/le 校验拒掉,未填的可选字段一律不发 + if (body.ttl === '' || body.ttl === null) delete body.ttl; + if (body.priority === '' || body.priority === null) delete body.priority; + if (body.proxied === null || body.proxied === '') delete body.proxied; + if (!body.comment) delete body.comment; + try { + const r = isNew + ? await Api.post('/actions/' + dnsModal.asset.id + '/dns', body) + : await Api.put('/actions/' + dnsModal.asset.id + '/dns/' + encodeURIComponent(dnsModal.editing.record_id), body); + if (r.ok) { showToast('✓ 记录已' + (isNew ? '创建' : '更新')); cancelDnsEdit(); reloadDns(); } + else dnsModal.error = r.message || '操作被拒绝'; + } catch (e) { dnsModal.error = e.message; } + finally { dnsModal.busy = false; } +} +async function deleteDnsRecord(r) { + // 删解析会立刻影响线上访问,必须二次确认而不是直接删 + if (!window.confirm('确认删除解析记录?\n\n' + r.name + ' ' + r.type + ' ' + r.content + '\n\n删除后该记录立即失效,可能中断线上访问。')) return; + if (dnsModal.busy) return; + dnsModal.busy = true; dnsModal.error = ''; + try { + const res = await Api.del('/actions/' + dnsModal.asset.id + '/dns/' + encodeURIComponent(r.record_id)); + if (res.ok) { showToast('✓ 记录已删除'); reloadDns(); } + else dnsModal.error = res.message || '删除被拒绝'; + } catch (e) { dnsModal.error = e.message; } + finally { dnsModal.busy = false; } +} + /* ---------------- 设置 ---------------- */ const settings = reactive({ apiKey: Api.getApiKey(), saved: false }); function saveSettings() { Api.setApiKey(settings.apiKey); settings.saved = true; setTimeout(() => settings.saved = false, 2000); } diff --git a/static/js/views/assets.js b/static/js/views/assets.js index d796d2b..0ef79c2 100644 --- a/static/js/views/assets.js +++ b/static/js/views/assets.js @@ -31,6 +31,7 @@ const AssetsView = { + 共 {{ list.length }} 条 @@ -55,10 +56,11 @@ const AssetsView = { {{ Fmt.TYPE_LABELS[a.asset_type] }} {{ a.provider_name || a.provider }} {{ a.expiry_date || '—' }}({{ Fmt.countdownText(a.days_to_expiry, { unit: '' }) }}) - {{ a.cost }} {{ a.currency }}/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }} + {{ Fmt.costText(a.cost, a.currency) }}/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }} {{ Fmt.STATUS_LABELS[a.status] }} + @@ -81,11 +83,12 @@ const AssetsView = {
{{ a.expiry_date || '无到期' }} ({{ Fmt.countdownText(a.days_to_expiry, { unit: '' }) }}) - {{ a.cost }}{{ a.currency }} + {{ Fmt.costText(a.cost, a.currency) }} {{ Fmt.STATUS_LABELS[a.status] }}
+ @@ -156,6 +159,23 @@ const AssetsView = { await loadAssets(); } catch (e) { alert('同步失败:' + e.message); } } - return { store, Fmt, tabs, tabView, list, switchTab, providerName, reload: loadAssets, clearProvider: clearProviderFilter, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI }; + async function syncAllAssets() { + if (!confirm('从云端拉取所有已配 API 凭证账号下的 VPS 与域名?\n\n会按 (平台, 云端实例ID) 去重:已存在则更新状态/到期日,不存在则新建。')) return; + try { + const r = await Api.post('/sync/assets', {}); + const lines = [ + '同步完成:成功 ' + r.success + ',失败 ' + r.failed, + '新增 ' + r.created + ',更新 ' + r.updated, + ]; + // 未配凭证 / 平台无适配器属于「跳过」而非失败,但必须如实报出来, + // 否则用户以为同步过了、实际一条都没拉 + if (r.skipped_no_config) lines.push('跳过(账号未配 API 凭证):' + r.skipped_no_config); + if (r.skipped_no_adapter) lines.push('跳过(平台无可用适配器):' + r.skipped_no_adapter); + if (r.errors && r.errors.length) lines.push('\n' + r.errors.join('\n')); + alert(lines.join('\n')); + await Promise.all([loadAssets(), loadOverview()]); + } catch (e) { alert('同步失败:' + e.message); } + } + return { store, Fmt, tabs, tabView, list, switchTab, providerName, reload: loadAssets, clearProvider: clearProviderFilter, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI, syncAllAssets, canControl: assetCanControl, openAct: openActions }; }, }; diff --git a/static/js/views/dashboard.js b/static/js/views/dashboard.js index fa30ae7..8808ef4 100644 --- a/static/js/views/dashboard.js +++ b/static/js/views/dashboard.js @@ -17,6 +17,8 @@ const DashboardView = { + @@ -164,6 +165,6 @@ const DomainsView = { if (d <= 90) return 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'; return 'bg-slate-500/10 text-slate-500'; } - return { store, Fmt, domains, subsByAsset, sslByAsset, expanded, forms, domainName, fullHost, toggle, resetForm, startEdit, saveSub, delSub, probeSsl, certText, certBadge, edit: openAssetEdit }; + return { store, Fmt, domains, subsByAsset, sslByAsset, expanded, forms, domainName, fullHost, toggle, resetForm, startEdit, saveSub, delSub, probeSsl, certText, certBadge, edit: openAssetEdit, canDns: assetCanDns, openDnsPanel: openDns }; }, }; diff --git a/static/js/views/monitor.js b/static/js/views/monitor.js index b668d31..dea49f6 100644 --- a/static/js/views/monitor.js +++ b/static/js/views/monitor.js @@ -83,7 +83,7 @@ const MonitorView = { {{ a.provider_name || a.provider }} {{ a.expiry_date }} {{ countdownText(a.days_to_expiry) }} - {{ a.cost }} {{ a.currency }}/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }} + {{ Fmt.costText(a.cost, a.currency) }}/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }} 续费 @@ -107,7 +107,7 @@ const MonitorView = {
{{ a.expiry_date }} - {{ a.cost }}{{ a.currency }} + {{ Fmt.costText(a.cost, a.currency) }} {{ Fmt.TYPE_LABELS[a.asset_type] }}
diff --git a/static/js/views/providers.js b/static/js/views/providers.js index 3341844..9ba0224 100644 --- a/static/js/views/providers.js +++ b/static/js/views/providers.js @@ -25,7 +25,16 @@ const ProvidersView = { {{ Fmt.CATEGORY_LABELS[p.category] }}
-
API 对接: {{ p.sdk_type }}
+ +
+ ✓ API 已对接 + {{ c }} +
+
+ ⚠ 已声明 {{ p.sdk_type }},但无对应适配器 +
+
未对接 API(仅台账登记)
管理面板 ↗
@@ -79,10 +88,29 @@ const ProvidersView = { function serviceList(p) { return (p.services || '').split(',').map(s => s.trim()).filter(Boolean); } + // API 对接状态:以适配器注册表为准,不看 sdk_type 有没有值 + function isSupported(p) { + return !!p.sdk_type && (store.adapters.supported_types || []).includes(p.sdk_type); + } + // 能力摘要:把 capabilities 里为 true 的方法归并成用户看得懂的标签 + const CAPS_LABELS = [ + ['list_vps', 'VPS同步'], + ['list_domains', '域名同步'], + ['get_account', '余额'], + ['start_instance', '开关机'], + ['reboot_instance', '重启'], + ['renew_instance', '续费'], + ['list_dns_records', 'DNS查询'], + ['create_dns_record', 'DNS写入'], + ]; + function capsSummary(p) { + const caps = sdkCaps(p.sdk_type); + return CAPS_LABELS.filter(([k]) => caps[k]).map(([, label]) => label); + } function fmtTime(iso) { if (!iso) return ''; return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } - return { store, Fmt, countOf, accountsCountOf, serviceList, showAll, toggleShowAll, visibleProviders, hiddenCount, fmtTime, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider }; + return { store, Fmt, countOf, accountsCountOf, serviceList, isSupported, capsSummary, showAll, toggleShowAll, visibleProviders, hiddenCount, fmtTime, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider }; }, }; diff --git a/static/js/views/subscriptions.js b/static/js/views/subscriptions.js index 4643d01..72d2415 100644 --- a/static/js/views/subscriptions.js +++ b/static/js/views/subscriptions.js @@ -37,7 +37,7 @@ const SubscriptionsView = { {{ a.name }} {{ a.provider_name || a.provider }} - {{ a.cost }} {{ a.currency }}/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }} + {{ Fmt.costText(a.cost, a.currency) }}/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }} {{ a.expiry_date || '—' }}({{ Fmt.countdownText(a.days_to_expiry, { unit: '' }) }}) 续费 diff --git a/tests/test_actions.py b/tests/test_actions.py new file mode 100644 index 0000000..556db9e --- /dev/null +++ b/tests/test_actions.py @@ -0,0 +1,718 @@ +"""云资源管理动作(action_service)与请求 Schema 测试 + +这层的每个动作都会改变云端真实资源:关机会中断线上服务、续费会真实扣款。 +所以测试重点是「该拒绝的一律拒绝,且拒绝理由如实」,而不是「能不能跑通」: + +- 能力不足 / 手工登记无 external_id / 资产类型不符 → 400,绝不静默 no-op +- 适配器明确返回 ok=False → 原样带回,不能包装成成功 +- 云端调用抛异常 → 502(区别于 400 的「参数/能力问题」) +- 每个动作都落 EventLog 审计,且审计失败不能拖垮业务动作 +- 路由层写操作必须挂 require_api_key(fail-closed) + +运行:.venv/bin/pytest tests/test_actions.py -v +""" + +import httpx +import pytest +from fastapi import HTTPException +from sqlmodel import Session, SQLModel, create_engine, select + +import app.models.asset # noqa: F401 +import app.models.credential # noqa: F401 +import app.models.monitor # noqa: F401 +import app.models.provider # noqa: F401 +import app.models.ssl # noqa: F401 +from app.adapters.base import cloud_error_text, http_error_message +from app.core.security import require_api_key +from app.models.asset import Account, Asset, AssetType, DomainDetail +from app.models.monitor import EventLog +from app.models.provider import Provider +from app.routers import actions as actions_router +from app.schemas.action import DNSRecordIn, InstanceActionRequest +from app.services import action_service + + +@pytest.fixture() +def engine(tmp_path, monkeypatch): + eng = create_engine( + f"sqlite:///{tmp_path / 'actions.db'}", connect_args={"check_same_thread": False} + ) + SQLModel.metadata.create_all(eng) + # audit() 在函数内部 import metrics_engine,必须指到临时库,否则污染本地 data/metrics.db + monkeypatch.setattr("app.database.metrics_engine", eng) + yield eng + eng.dispose() + + +class _FakeAdapter: + """可控能力的假适配器""" + + required_config: list = [] + sdk_types = ["fake-sdk"] + + def __init__(self, caps=None, result=None, raises=None): + self.config = {} + self._caps = caps if caps is not None else { + "list_vps": True, "list_domains": True, "get_account": False, + "start_instance": True, "stop_instance": True, + "reboot_instance": True, "renew_instance": True, + "list_dns_records": True, "create_dns_record": True, + "update_dns_record": True, "delete_dns_record": True, + } + self._result = result if result is not None else {"ok": True, "message": "done"} + self._raises = raises + self.calls = [] + + def capabilities(self): + return dict(self._caps) + + def _record(self, name, args): + self.calls.append((name, args)) + if self._raises: + raise self._raises + return self._result + + def start_instance(self, external_id): + return self._record("start_instance", (external_id,)) + + def stop_instance(self, external_id): + return self._record("stop_instance", (external_id,)) + + def reboot_instance(self, external_id): + return self._record("reboot_instance", (external_id,)) + + def renew_instance(self, external_id, period_months=1): + return self._record("renew_instance", (external_id, period_months)) + + def list_dns_records(self, zone_id): + from app.adapters.base import NormalizedDNSRecord + self.calls.append(("list_dns_records", (zone_id,))) + if self._raises: + raise self._raises + return [NormalizedDNSRecord(record_id="r1", zone_id=zone_id, name="a.com", type="A", content="1.2.3.4")] + + def create_dns_record(self, zone_id, record): + return self._record("create_dns_record", (zone_id, record)) + + def update_dns_record(self, zone_id, record_id, record): + return self._record("update_dns_record", (zone_id, record_id, record)) + + def delete_dns_record(self, zone_id, record_id): + return self._record("delete_dns_record", (zone_id, record_id)) + + +def _seed_asset(engine, asset_type=AssetType.VPS, external_id="i-1", with_account=True, + provider_slug="fake", sdk_type="fake-sdk", name="web-01"): + """建 provider + account + asset,并把适配器构造过程替换成 _FakeAdapter 工厂""" + with Session(engine) as s: + provider = Provider(slug=provider_slug, name=provider_slug, category="vps", sdk_type=sdk_type) + s.add(provider) + s.commit() + s.refresh(provider) + + account_id = None + if with_account: + account = Account(name="acc", platform=provider_slug, api_config_encrypted="enc") + s.add(account) + s.commit() + s.refresh(account) + account_id = account.id + + asset = Asset( + name=name, asset_type=asset_type, provider=provider_slug, + provider_id=provider.id, external_id=external_id, account_id=account_id, + ) + s.add(asset) + s.commit() + s.refresh(asset) + return provider.id, account_id, asset.id + + +def _use_fake(monkeypatch, engine, provider_id, adapter): + monkeypatch.setattr( + action_service, "_build_adapter_for_account", + lambda session, account: (session.get(Provider, provider_id), adapter), + ) + return adapter + + +def _events(engine) -> list: + with Session(engine) as s: + return s.exec(select(EventLog)).all() + + +# ----------------------------- 解析与鉴权 ----------------------------- # +def test_resolve_rejects_missing_asset(engine): + _seed_asset(engine) + with Session(engine) as s: + with pytest.raises(HTTPException) as ei: + action_service._resolve(s, 99999) + assert ei.value.status_code == 404 + + +def test_resolve_rejects_asset_without_account(engine): + """手工登记、没挂账号的资产无从确定用哪套凭证——必须明确拒绝""" + _, _, asset_id = _seed_asset(engine, with_account=False) + with Session(engine) as s: + with pytest.raises(HTTPException) as ei: + action_service._resolve(s, asset_id) + assert ei.value.status_code == 400 + assert "未关联账号" in ei.value.detail + + +def test_resolve_rejects_missing_credentials(engine, monkeypatch): + """required_config 未填齐时如实报缺哪个字段,别让用户猜""" + provider_id, _, asset_id = _seed_asset(engine) + adapter = _FakeAdapter() + adapter.required_config = ["api_key", "region"] + adapter.config = {"api_key": "k"} + _use_fake(monkeypatch, engine, provider_id, adapter) + with Session(engine) as s: + with pytest.raises(HTTPException) as ei: + action_service._resolve(s, asset_id) + assert ei.value.status_code == 400 + assert "region" in ei.value.detail + + +def test_resolve_rejects_dangling_account_reference(engine, monkeypatch): + provider_id, account_id, asset_id = _seed_asset(engine) + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter()) + with Session(engine) as s: + s.delete(s.get(Account, account_id)) + s.commit() + with Session(engine) as s: + with pytest.raises(HTTPException) as ei: + action_service._resolve(s, asset_id) + assert ei.value.status_code == 400 + assert "账号已不存在" in ei.value.detail + + +# ----------------------------- 实例动作 ----------------------------- # +def test_unknown_action_rejected(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine) + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter()) + with Session(engine) as s: + with pytest.raises(HTTPException) as ei: + action_service.perform_instance_action(s, asset_id, "destroy") + assert ei.value.status_code == 400 + assert "未知动作" in ei.value.detail + + +def test_start_success(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine) + adapter = _use_fake(monkeypatch, engine, provider_id, _FakeAdapter()) + with Session(engine) as s: + res = action_service.perform_instance_action(s, asset_id, "start") + assert res["ok"] is True + assert res["action"] == "start" + assert res["external_id"] == "i-1" + assert res["provider"] == "fake" + assert adapter.calls == [("start_instance", ("i-1",))] + + +def test_renew_passes_period_months(engine, monkeypatch): + """续费周期必须透传,否则默认 1 个月与用户选择不符""" + provider_id, _, asset_id = _seed_asset(engine) + adapter = _use_fake(monkeypatch, engine, provider_id, _FakeAdapter()) + with Session(engine) as s: + action_service.perform_instance_action(s, asset_id, "renew", period_months=12) + assert adapter.calls == [("renew_instance", ("i-1", 12))] + + +def test_non_vps_asset_rejected(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="example.com") + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter()) + with Session(engine) as s: + with pytest.raises(HTTPException) as ei: + action_service.perform_instance_action(s, asset_id, "start") + assert ei.value.status_code == 400 + assert "仅 VPS" in ei.value.detail + + +def test_manual_asset_without_external_id_rejected(engine, monkeypatch): + """手工登记的资产没有云端 ID,无法下发指令""" + provider_id, _, asset_id = _seed_asset(engine, external_id=None) + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter()) + with Session(engine) as s: + with pytest.raises(HTTPException) as ei: + action_service.perform_instance_action(s, asset_id, "start") + assert ei.value.status_code == 400 + assert "external_id" in ei.value.detail + + +def test_unsupported_capability_rejected(engine, monkeypatch): + """适配器没实现该动作 → 400,绝不能静默 no-op 让调用方以为成功了""" + provider_id, _, asset_id = _seed_asset(engine) + caps = {"renew_instance": False, "start_instance": True} + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter(caps=caps)) + with Session(engine) as s: + with pytest.raises(HTTPException) as ei: + action_service.perform_instance_action(s, asset_id, "renew") + assert ei.value.status_code == 400 + assert "不支持" in ei.value.detail + + +def test_adapter_returning_ok_false_is_passed_through(engine, monkeypatch): + """云端明确拒绝(如余额不足)时如实带回 ok=False + 原因,不能包装成成功""" + provider_id, _, asset_id = _seed_asset(engine) + _use_fake(monkeypatch, engine, provider_id, + _FakeAdapter(result={"ok": False, "message": "余额不足"})) + with Session(engine) as s: + res = action_service.perform_instance_action(s, asset_id, "start") + assert res["ok"] is False + assert res["message"] == "余额不足" + + +def test_not_implemented_error_becomes_400(engine, monkeypatch): + """capabilities() 与真实实现不一致时的兜底:按能力问题(400)而非网关问题""" + provider_id, _, asset_id = _seed_asset(engine) + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=NotImplementedError())) + with Session(engine) as s: + with pytest.raises(HTTPException) as ei: + action_service.perform_instance_action(s, asset_id, "start") + assert ei.value.status_code == 400 + + +def test_cloud_error_becomes_502(engine, monkeypatch): + """云端调用异常是上游问题,用 502 与 400(参数/能力问题)区分开""" + provider_id, _, asset_id = _seed_asset(engine) + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=RuntimeError("connection reset"))) + with Session(engine) as s: + with pytest.raises(HTTPException) as ei: + action_service.perform_instance_action(s, asset_id, "start") + assert ei.value.status_code == 502 + assert "connection reset" in ei.value.detail + + +# ----------------------------- 审计 ----------------------------- # +def test_every_action_is_audited(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine) + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter()) + with Session(engine) as s: + action_service.perform_instance_action(s, asset_id, "stop") + events = _events(engine) + # 下发前 + 结果,各一条 + assert len(events) == 2 + assert all(e.source == "action" for e in events) + assert all(e.asset_id == asset_id for e in events) + assert "关机动作下发" in events[0].message + assert "关机成功" in events[1].message + + +def test_failed_action_audited_as_error(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine) + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=RuntimeError("boom"))) + with Session(engine) as s: + with pytest.raises(HTTPException): + action_service.perform_instance_action(s, asset_id, "start") + events = _events(engine) + assert any(e.level == "error" for e in events), "异常必须留下 error 级审计" + + +def test_rejected_action_audited_as_error(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine) + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter(result={"ok": False, "message": "被拒"})) + with Session(engine) as s: + action_service.perform_instance_action(s, asset_id, "start") + assert any(e.level == "error" for e in _events(engine)) + + +def test_audit_failure_does_not_break_action(engine, monkeypatch): + """审计写失败绝不能让业务动作失败——云端指令可能已经下发成功, + 此时抛错会让调用方误判为「没执行」,比丢一条日志危险得多""" + provider_id, _, asset_id = _seed_asset(engine) + adapter = _use_fake(monkeypatch, engine, provider_id, _FakeAdapter()) + + # 让 metrics_engine 指向不可用值,触发 audit 内部 except 分支 + monkeypatch.setattr("app.database.metrics_engine", None) + with Session(engine) as s: + res = action_service.perform_instance_action(s, asset_id, "start") + assert res["ok"] is True + assert adapter.calls == [("start_instance", ("i-1",))] + + +def test_audit_uses_own_session(engine, monkeypatch): + """审计走独立 Session 提交,主流程回滚时记录不能一起丢""" + provider_id, _, asset_id = _seed_asset(engine) + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter()) + with Session(engine) as s: + action_service.perform_instance_action(s, asset_id, "start") + s.rollback() # 主 Session 回滚 + assert len(_events(engine)) == 2, "审计已独立提交,不应受主流程回滚影响" + + +# ----------------------------- 能力探测 ----------------------------- # +def test_asset_actions_for_cloud_vps(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine) + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter()) + with Session(engine) as s: + res = action_service.asset_actions(s, asset_id) + assert res["available"] is True + assert res["provider"] == "fake" + assert res["actions"]["start"] is True + assert res["actions"]["renew"] is True + assert res["actions"]["dns_records"] is False # VPS 不该出现 DNS 按钮 + + +def test_asset_actions_for_manual_vps(engine, monkeypatch): + """手工登记(无 external_id)的 VPS 所有实例动作都必须是 False, + 否则用户点了才收到 400""" + provider_id, _, asset_id = _seed_asset(engine, external_id=None) + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter()) + with Session(engine) as s: + res = action_service.asset_actions(s, asset_id) + assert all(v is False for v in res["actions"].values()), res["actions"] + assert res["available"] is False + + +def test_asset_actions_for_unsupported_provider(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine) + caps = {"start_instance": True, "stop_instance": False, "reboot_instance": False, "renew_instance": False} + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter(caps=caps)) + with Session(engine) as s: + actions = action_service.asset_actions(s, asset_id)["actions"] + assert actions == {"start": True, "stop": False, "reboot": False, "renew": False, + "dns_records": False, "dns_write": False} + + +def test_asset_actions_unresolvable_returns_reason_not_raise(engine): + """探测端点不能抛 500:前端要拿它决定按钮是否渲染""" + _seed_asset(engine) + with Session(engine) as s: + res = action_service.asset_actions(s, 99999) + assert res["available"] is False + assert res["reason"] + assert res["actions"] == {} + + +def test_asset_actions_for_domain(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-uuid") + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter()) + with Session(engine) as s: + res = action_service.asset_actions(s, asset_id) + assert res["actions"]["dns_records"] is True + assert res["actions"]["dns_write"] is True + assert res["actions"]["start"] is False # 域名不该出现启停按钮 + + +# ----------------------------- DNS ----------------------------- # +def test_list_dns_records(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1") + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter()) + with Session(engine) as s: + res = action_service.list_dns_records(s, asset_id) + assert res["zone_id"] == "z-1" + assert res["records"][0]["content"] == "1.2.3.4" + assert isinstance(res["records"][0], dict) # 必须是可 JSON 序列化的 dict + + +def test_zone_id_falls_back_to_domain_detail(engine, monkeypatch): + """external_id 为空时回退 DomainDetail.domain_name——手工登记的域名也能管 DNS""" + provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id=None, name="fallback") + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter()) + with Session(engine) as s: + s.add(DomainDetail(asset_id=asset_id, domain_name="example.com", registrar="x")) + s.commit() + with Session(engine) as s: + assert action_service.list_dns_records(s, asset_id)["zone_id"] == "example.com" + + +def test_zone_id_falls_back_to_asset_name(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id=None, name="named.com") + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter()) + with Session(engine) as s: + assert action_service.list_dns_records(s, asset_id)["zone_id"] == "named.com" + + +def test_dns_on_vps_asset_rejected(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.VPS) + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter()) + with Session(engine) as s: + with pytest.raises(HTTPException) as ei: + action_service.list_dns_records(s, asset_id) + assert ei.value.status_code == 400 + assert "仅域名资产" in ei.value.detail + + +def test_create_dns_record(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1") + adapter = _use_fake(monkeypatch, engine, provider_id, + _FakeAdapter(result={"ok": True, "message": "created", "record_id": "r9"})) + with Session(engine) as s: + res = action_service.create_dns_record(s, asset_id, {"type": "A", "name": "a.com", "content": "1.2.3.4"}) + assert res["ok"] is True + assert res["record_id"] == "r9" + assert adapter.calls[-1][0] == "create_dns_record" + assert adapter.calls[-1][1][0] == "z-1" # zone_id 必须是第一个参数 + + +def test_update_and_delete_dns_record(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1") + adapter = _use_fake(monkeypatch, engine, provider_id, _FakeAdapter()) + with Session(engine) as s: + assert action_service.update_dns_record(s, asset_id, "r1", {"type": "A", "name": "a", "content": "2.2.2.2"})["ok"] + assert action_service.delete_dns_record(s, asset_id, "r1")["ok"] + names = [c[0] for c in adapter.calls] + assert "update_dns_record" in names + assert "delete_dns_record" in names + + +def test_dns_write_audited(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1") + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter()) + with Session(engine) as s: + action_service.delete_dns_record(s, asset_id, "r1") + events = _events(engine) + assert any("记录删除" in e.message and e.source == "action" for e in events) + + +def test_dns_capability_missing_rejected(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1") + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter(caps={"list_dns_records": False})) + with Session(engine) as s: + with pytest.raises(HTTPException) as ei: + action_service.list_dns_records(s, asset_id) + assert ei.value.status_code == 400 + + +def test_dns_cloud_error_becomes_502(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1") + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=RuntimeError("timeout"))) + with Session(engine) as s: + with pytest.raises(HTTPException) as ei: + action_service.list_dns_records(s, asset_id) + assert ei.value.status_code == 502 + + +# ----------------------------- 路由层安全 ----------------------------- # +def _route(path_suffix: str, method: str): + for r in actions_router.router.routes: + if r.path.endswith(path_suffix) and method in getattr(r, "methods", set()): + return r + raise AssertionError(f"路由未注册:{method} {path_suffix}") + + +def test_all_write_routes_require_api_key(): + """动作会真实改云端资源,写端点必须 fail-closed""" + protected = [ + ("/instance", "POST"), + ("/dns", "GET"), + ("/dns", "POST"), + ("/dns/{record_id}", "PUT"), + ("/dns/{record_id}", "DELETE"), + ] + for suffix, method in protected: + route = _route(suffix, method) + deps = [d.dependency for d in route.dependencies] + assert require_api_key in deps, f"{method} {suffix} 缺少 require_api_key" + + +def test_capabilities_route_is_read_only(): + """能力探测无副作用,前端要在渲染按钮前调用,不该被鉴权挡住""" + route = _route("/capabilities", "GET") + deps = [d.dependency for d in route.dependencies] + assert require_api_key not in deps + + +def test_router_prefix_and_registration(): + assert actions_router.router.prefix == "/api/actions" + from app.main import app + paths = app.openapi()["paths"] + assert "/api/actions/{asset_id}/instance" in paths + assert "/api/actions/{asset_id}/dns" in paths + assert "/api/actions/{asset_id}/capabilities" in paths + + +# ----------------------------- 请求 Schema ----------------------------- # +@pytest.mark.parametrize("action", ["start", "stop", "reboot", "renew"]) +def test_valid_actions_accepted(action): + assert InstanceActionRequest(action=action).action == action + + +def test_invalid_action_rejected(): + with pytest.raises(Exception): + InstanceActionRequest(action="terminate") + + +def test_period_months_defaults_to_one(): + assert InstanceActionRequest(action="renew").period_months == 1 + + +@pytest.mark.parametrize("months", [0, -1, 61, 9999]) +def test_period_months_out_of_range_rejected(months): + """必须有上界,否则一次误传 9999 就会真实扣掉一笔巨额续费账单""" + with pytest.raises(Exception): + InstanceActionRequest(action="renew", period_months=months) + + +@pytest.mark.parametrize("months", [1, 3, 12, 60]) +def test_period_months_in_range_accepted(months): + assert InstanceActionRequest(action="renew", period_months=months).period_months == months + + +def test_dns_record_type_uppercased(): + assert DNSRecordIn(name="a.com", type="cname", content="b.com").type == "CNAME" + + +def test_dns_record_invalid_type_rejected(): + with pytest.raises(Exception): + DNSRecordIn(name="a.com", type="HINFO", content="x") + + +def test_dns_record_requires_name_and_content(): + with pytest.raises(Exception): + DNSRecordIn(name="", type="A", content="1.2.3.4") + with pytest.raises(Exception): + DNSRecordIn(name="a.com", type="A", content="") + + +@pytest.mark.parametrize("ttl", [0, 59, 86401]) +def test_dns_ttl_out_of_range_rejected(ttl): + with pytest.raises(Exception): + DNSRecordIn(name="a.com", type="A", content="1.2.3.4", ttl=ttl) + + +@pytest.mark.parametrize("priority", [-1, 65536]) +def test_dns_priority_out_of_range_rejected(priority): + with pytest.raises(Exception): + DNSRecordIn(name="a.com", type="MX", content="mx.x.com", priority=priority) + + +def test_dns_record_optional_fields_default_none(): + r = DNSRecordIn(name="a.com", type="A", content="1.2.3.4") + assert r.ttl is None and r.priority is None and r.proxied is None and r.comment is None + + +# ----------------------------- 云端错误可读化 ----------------------------- # +# 动机:裸 str(httpx.HTTPStatusError) 是多行文本,末尾还挂一段 MDN 链接 +# ("For more information check: https://developer.mozilla.org/..."), +# 直接透到前端弹窗里既难看也没法排查。各厂商把原因放的位置还都不一样。 + + +def _http_error(status_code, *, json_body=None, text=None): + req = httpx.Request("GET", "https://api.example.com/v2/x") + if text is not None: + resp = httpx.Response(status_code, request=req, text=text) + elif json_body is not None: + resp = httpx.Response(status_code, request=req, json=json_body) + else: + resp = httpx.Response(status_code, request=req, content=b"") + return httpx.HTTPStatusError("boom", request=req, response=resp) + + +def test_linode_errors_reason_extracted(): + msg = http_error_message(_http_error(401, json_body={"errors": [{"reason": "Invalid token"}]})) + assert "HTTP 401" in msg + assert "Invalid token" in msg + assert "凭证无效或已过期" in msg + + +def test_cloudflare_errors_message_extracted(): + msg = http_error_message(_http_error(403, json_body={"errors": [{"code": 10000, "message": "Invalid zone"}]})) + assert "Invalid zone" in msg and "HTTP 403" in msg + + +def test_tencent_nested_response_error_extracted(): + body = {"Response": {"Error": {"Code": "AuthFailure", "Message": "签名失败"}, "RequestId": "x"}} + msg = http_error_message(_http_error(400, json_body=body)) + assert "签名失败" in msg and "HTTP 400" in msg + + +def test_aws_xml_message_extracted(): + body = ( + '' + "UnauthorizedOperationYou are not authorized" + "" + ) + msg = http_error_message(_http_error(403, text=body)) + assert "You are not authorized" in msg + assert "<" not in msg # 不能把 XML 标签漏给用户 + + +def test_vultr_bare_401_falls_back_to_hint(): + """Vultr 401 常常没有 body,此时至少给方向性提示""" + msg = http_error_message(_http_error(401)) + assert msg == "HTTP 401:凭证无效或已过期" + + +@pytest.mark.parametrize("code,hint", [(403, "凭证权限不足"), (404, "资源不存在"), (429, "触发平台限流")]) +def test_status_hints(code, hint): + assert hint in http_error_message(_http_error(code)) + + +def test_500_without_body_has_no_hint(): + assert http_error_message(_http_error(500)) == "HTTP 500" + + +def test_long_message_truncated(): + msg = http_error_message(_http_error(400, json_body={"message": "x" * 5000})) + assert len(msg) < 300 + + +def test_hint_not_duplicated_when_body_already_says_it(): + msg = http_error_message(_http_error(401, json_body={"message": "凭证无效或已过期,请重新生成"})) + assert msg.count("凭证无效或已过期") == 1 + + +def test_deeply_nested_body_does_not_recurse_forever(): + node = {"leaf": "too deep"} + for _ in range(10): + node = {"nested": node} + msg = http_error_message(_http_error(400, json_body=node)) + assert msg.startswith("HTTP 400") + + +def test_cloud_error_passes_non_http_exceptions_through(): + """超时/连接错误本来就很短,不需要加工""" + assert cloud_error_text(RuntimeError("connection reset")) == "connection reset" + + +def test_http_status_error_502_detail_is_readable(engine, monkeypatch): + """回归:浏览器里实测到的原始 httpx 文本 + MDN 链接不能透到前端""" + provider_id, _, asset_id = _seed_asset(engine) + err = _http_error(401, json_body={"error": {"msg": "Invalid API token"}}) + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=err)) + with Session(engine) as s: + with pytest.raises(HTTPException) as ei: + action_service.perform_instance_action(s, asset_id, "start") + assert ei.value.status_code == 502 + assert "Invalid API token" in ei.value.detail + assert "developer.mozilla.org" not in ei.value.detail + assert "\n" not in ei.value.detail + + +def test_dns_http_error_detail_is_readable(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1") + err = _http_error(404, json_body={"error": {"msg": "Domain not found"}}) + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=err)) + with Session(engine) as s: + with pytest.raises(HTTPException) as ei: + action_service.list_dns_records(s, asset_id) + assert ei.value.status_code == 502 + assert "Domain not found" in ei.value.detail + assert "developer.mozilla.org" not in ei.value.detail + + +def test_dns_write_http_error_detail_is_readable(engine, monkeypatch): + provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1") + err = _http_error(429, json_body={"message": "rate limit exceeded"}) + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=err)) + with Session(engine) as s: + with pytest.raises(HTTPException) as ei: + action_service.create_dns_record(s, asset_id, {"name": "a", "type": "A", "content": "1.2.3.4"}) + assert ei.value.status_code == 502 + assert "rate limit exceeded" in ei.value.detail + assert "触发平台限流" in ei.value.detail + + +def test_http_error_message_is_audited_not_raw(engine, monkeypatch): + """审计日志里也要是可读原因,否则事后排查只看到一串 httpx repr""" + provider_id, _, asset_id = _seed_asset(engine) + err = _http_error(403, json_body={"errors": [{"reason": "Forbidden by policy"}]}) + _use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=err)) + with Session(engine) as s: + with pytest.raises(HTTPException): + action_service.perform_instance_action(s, asset_id, "reboot") + text = " ".join(e.message for e in _events(engine)) + assert "Forbidden by policy" in text + assert "developer.mozilla.org" not in text diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 2f998c4..ee41a64 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -13,7 +13,13 @@ from app.adapters.ai import DeepSeekAdapter, KimiAdapter, OpenAIAdapter from app.adapters.aliyun import AliyunAdapter from app.adapters.cloudflare import CloudflareAdapter from app.adapters.digitalocean import DigitalOceanAdapter -from app.adapters.tencent import TencentAdapter +from app.adapters.tencent import ( + BILLING_HOST, + CVM_HOST, + DNSPOD_HOST, + TencentAdapter, + _service_of, +) from app.adapters.vultr import VultrAdapter @@ -132,7 +138,9 @@ def test_aliyun_sign_sensitive_to_secret(): # --------------------------- 腾讯云签名 --------------------------- # def test_tencent_authorization_format(): adapter = TencentAdapter({"secret_id": "SID", "secret_key": "SKEY"}) - auth = adapter._build_authorization("DescribeInstances", "{}", 1704067200, "2024-01-01") + auth = adapter._build_authorization( + "DescribeInstances", "{}", 1704067200, "2024-01-01", CVM_HOST, "cvm" + ) assert auth.startswith("TC3-HMAC-SHA256 Credential=SID/") assert "2024-01-01/cvm/tc3_request" in auth assert "SignedHeaders=content-type;host;x-tc-action" in auth @@ -145,11 +153,38 @@ def test_tencent_authorization_format(): def test_tencent_sign_sensitive_to_payload(): adapter = TencentAdapter({"secret_id": "SID", "secret_key": "SKEY"}) - a1 = adapter._build_authorization("DescribeInstances", '{"Limit":1}', 1704067200, "2024-01-01") - a2 = adapter._build_authorization("DescribeInstances", '{"Limit":2}', 1704067200, "2024-01-01") + a1 = adapter._build_authorization( + "DescribeInstances", '{"Limit":1}', 1704067200, "2024-01-01", CVM_HOST, "cvm" + ) + a2 = adapter._build_authorization( + "DescribeInstances", '{"Limit":2}', 1704067200, "2024-01-01", CVM_HOST, "cvm" + ) assert a1 != a2 +def test_tencent_credential_scope_follows_service(): + """多产品复用同一套签名:credential scope 与 host 中的 canonical host 必须随 service 变化 + + 若 scope 写死成 cvm,调用 billing/domain/dnspod 会全部返回 AuthFailure.SignatureFailure。 + """ + adapter = TencentAdapter({"secret_id": "SID", "secret_key": "SKEY"}) + cvm = adapter._build_authorization( + "DescribeInstances", "{}", 1704067200, "2024-01-01", CVM_HOST, "cvm" + ) + billing = adapter._build_authorization( + "DescribeAccountBalance", "{}", 1704067200, "2024-01-01", BILLING_HOST, "billing" + ) + assert "2024-01-01/cvm/tc3_request" in cvm + assert "2024-01-01/billing/tc3_request" in billing + assert cvm != billing + + +def test_tencent_service_of_host(): + assert _service_of(CVM_HOST) == "cvm" + assert _service_of(BILLING_HOST) == "billing" + assert _service_of(DNSPOD_HOST) == "dnspod" + + # --------------------------- 注册表路由 --------------------------- # def test_registry_supported_types(): for t in [ diff --git a/tests/test_asset_export_import.py b/tests/test_asset_export_import.py new file mode 100644 index 0000000..3e33ddb --- /dev/null +++ b/tests/test_asset_export_import.py @@ -0,0 +1,204 @@ +"""资产导出 / 导入的往返一致性测试 + +export_assets 自称「用于数据迁移/备份」,那它导出的字段就必须在 import_assets +里真的落库。external_id 是 sync_service 的去重键 (provider_id, external_id, asset_type), +AssetCreate/AssetUpdate 不收这个字段——导入时若被 pydantic 静默丢弃, +迁移后首次同步会把所有云端资产当成新增再建一份,台账直接翻倍。 + +运行:.venv/bin/pytest tests/test_asset_export_import.py -v +""" + +import pytest +from sqlmodel import Session, SQLModel, create_engine, select + +import app.models.asset # noqa: F401 +import app.models.credential # noqa: F401 +import app.models.monitor # noqa: F401 +import app.models.provider # noqa: F401 +import app.models.ssl # noqa: F401 +from app.models.asset import Asset, AssetType, DomainDetail, VPSDetail +from app.services.asset_service import export_assets, import_assets + + +@pytest.fixture() +def engine(tmp_path, monkeypatch): + eng = create_engine( + f"sqlite:///{tmp_path / 'assets.db'}", connect_args={"check_same_thread": False} + ) + SQLModel.metadata.create_all(eng) + # _cleanup_metrics_for_asset 在函数内部取 metrics_engine,指到临时库避免污染本地 data/ + monkeypatch.setattr("app.database.metrics_engine", eng) + yield eng + eng.dispose() + + +def _seed_vps(engine, name="web-01", external_id="i-abc123"): + with Session(engine) as s: + a = Asset( + name=name, asset_type=AssetType.VPS, provider="vultr", + external_id=external_id, cost=5.0, currency="USD", + ) + s.add(a) + s.commit() + s.refresh(a) + s.add(VPSDetail(asset_id=a.id, ip_address="1.2.3.4")) + s.commit() + return a.id + + +def _seed_domain(engine, name="example.com", external_id="example.com"): + with Session(engine) as s: + a = Asset(name=name, asset_type=AssetType.DOMAIN, provider="namesilo", external_id=external_id) + s.add(a) + s.commit() + s.refresh(a) + s.add(DomainDetail(asset_id=a.id, domain_name=name)) + s.commit() + return a.id + + +def _external_ids(engine): + with Session(engine) as s: + return {a.name: a.external_id for a in s.exec(select(Asset)).all()} + + +def _wipe(engine): + """清空资产与详情行 + + 必须连详情一起删:SQLite 默认不强制外键,只删 Asset 会留下孤儿详情行; + 而 assets.id 是 INTEGER PRIMARY KEY(无 AUTOINCREMENT),删掉后新建资产 + 必然复用同一个 id,撞上孤儿行的 UNIQUE(asset_id)。 + """ + with Session(engine) as s: + for model in (VPSDetail, DomainDetail): + for row in s.exec(select(model)).all(): + s.delete(row) + for a in s.exec(select(Asset)).all(): + s.delete(a) + s.commit() + + +# ----------------------------- 导出 ----------------------------- # +def test_export_includes_external_id(engine): + _seed_vps(engine) + with Session(engine) as s: + data = export_assets(s) + assert data["count"] == 1 + assert data["assets"][0]["external_id"] == "i-abc123" + + +def test_export_carries_detail_and_count(engine): + _seed_vps(engine) + _seed_domain(engine) + with Session(engine) as s: + data = export_assets(s) + assert data["count"] == 2 + by_name = {a["name"]: a for a in data["assets"]} + assert by_name["web-01"]["vps_detail"]["ip_address"] == "1.2.3.4" + assert by_name["example.com"]["domain_detail"]["domain_name"] == "example.com" + + +# ----------------------------- 导入落库 ----------------------------- # +def test_import_creates_asset_with_external_id(engine): + """空库导入:external_id 必须落到行上,不能被 AssetCreate 静默吞掉""" + _seed_vps(engine) + with Session(engine) as s: + payload = export_assets(s)["assets"] + _wipe(engine) + with Session(engine) as s: + result = import_assets(s, payload) + assert result["errors"] == [] + assert result["created"] == 1 + assert _external_ids(engine)["web-01"] == "i-abc123" + + +def test_import_updates_existing_and_keeps_external_id(engine): + """同名同类型同平台 -> 走更新分支,external_id 不能丢""" + asset_id = _seed_vps(engine) + with Session(engine) as s: + payload = export_assets(s)["assets"] + with Session(engine) as s: + a = s.get(Asset, asset_id) + a.external_id = None # 模拟历史数据没有 external_id + s.add(a) + s.commit() + with Session(engine) as s: + result = import_assets(s, payload) + assert result["updated"] == 1 and result["created"] == 0 + assert _external_ids(engine)["web-01"] == "i-abc123" + + +def test_import_does_not_clobber_a_fresher_external_id(engine): + """已有值时不覆盖:导入包可能比线上同步到的旧,覆盖会让去重键指向已销毁的实例""" + asset_id = _seed_vps(engine, external_id="i-old") + with Session(engine) as s: + payload = export_assets(s)["assets"] + with Session(engine) as s: + a = s.get(Asset, asset_id) + a.external_id = "i-new" + s.add(a) + s.commit() + with Session(engine) as s: + import_assets(s, payload) + assert _external_ids(engine)["web-01"] == "i-new" + + +def test_import_ignores_computed_fields(engine): + """days_to_expiry / provider_name / account_name 是派生值,导入时必须跳过而不是写库""" + _seed_vps(engine) + with Session(engine) as s: + payload = export_assets(s)["assets"] + payload[0]["days_to_expiry"] = 999 + payload[0]["provider_name"] = "不存在的平台" + payload[0]["account_name"] = "不存在的账号" + _wipe(engine) + with Session(engine) as s: + result = import_assets(s, payload) + assert result["errors"] == [] + assert result["created"] == 1 + + +def test_import_without_external_id_key_still_works(engine): + """老导出包没有这个字段,不能因此报错""" + payload = [{ + "name": "legacy", "asset_type": "vps", "provider": "vultr", + "vps_detail": {"ip_address": "9.9.9.9"}, + }] + with Session(engine) as s: + result = import_assets(s, payload) + assert result["created"] == 1 and result["errors"] == [] + assert _external_ids(engine)["legacy"] is None + + +def test_import_all_or_nothing_on_failure(engine): + """单事务语义:一条失败则整体回滚,不能留下半截数据""" + _seed_vps(engine, name="good") + payload = [ + {"name": "ok-1", "asset_type": "vps", "provider": "vultr", + "external_id": "i-1", "vps_detail": {"ip_address": "1.1.1.1"}}, + {"name": "bad-1", "asset_type": "vps", "provider": "vultr"}, # 缺 vps_detail -> 400 + ] + with Session(engine) as s: + result = import_assets(s, payload) + assert result["created"] == 0 and result["updated"] == 0 + assert len(result["errors"]) == 1 + names = _external_ids(engine) + assert "ok-1" not in names # 成功那条也回滚了 + assert set(names) == {"good"} + + +def test_round_trip_preserves_everything(engine): + """完整往返:导出 -> 清空 -> 导入,external_id 与详情都在""" + _seed_vps(engine, name="web-01", external_id="i-111") + _seed_domain(engine, name="example.com", external_id="example.com") + with Session(engine) as s: + payload = export_assets(s)["assets"] + _wipe(engine) + with Session(engine) as s: + result = import_assets(s, payload) + assert result["errors"] == [] and result["created"] == 2 + assert _external_ids(engine) == {"web-01": "i-111", "example.com": "example.com"} + with Session(engine) as s: + vps = s.exec(select(Asset).where(Asset.name == "web-01")).one() + detail = s.exec(select(VPSDetail).where(VPSDetail.asset_id == vps.id)).one() + assert detail.ip_address == "1.2.3.4" diff --git a/tests/test_new_adapters.py b/tests/test_new_adapters.py new file mode 100644 index 0000000..a8020f2 --- /dev/null +++ b/tests/test_new_adapters.py @@ -0,0 +1,716 @@ +"""新增适配器测试:AWS(SigV4)/ Linode / Namesilo + +重点覆盖三类真实会炸的问题: + +1. AWS SigV4 的派生签名密钥必须与官方测试向量逐字节一致——手写签名没有 SDK 兜底, + 错一步就是满屏 SignatureDoesNotMatch,且无法从错误信息反推是哪一步错了。 +2. credential scope / canonical host 必须随 service 变化(ec2 / route53 / route53domains + 共用一套签名代码),写死成 ec2 会让 Route53 全线鉴权失败。 +3. EC2 查询协议转 JSON 后的驼峰包装:单元素时 AWS 返回 dict 而非 list, + 不做归一会直接 TypeError。 + +运行:.venv/bin/pytest tests/test_new_adapters.py -v +""" + +from datetime import date +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from app.adapters.aws import AWSAdapter, _signing_key +from app.adapters.linode import LinodeAdapter +from app.adapters.namesilo import NamesiloAdapter + + +# ----------------------------- AWS SigV4 ----------------------------- # +def test_aws_signing_key_matches_official_vector(): + """AWS 官方文档测试向量(Signature Version 4 派生签名密钥示例) + + 这是手写 SigV4 唯一可靠的正确性锚点:向量对得上,说明 + AWS4 前缀、四级 HMAC 链、aws4_request 终止符全部正确。 + """ + key = _signing_key( + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + "20120215", + "us-east-1", + "iam", + ) + assert key.hex() == "f4780e2d9f65fa895f9c67b32ce1baf0b0d8a43505a000a1a9e090d414db404d" + + +def test_aws_signing_key_sensitive_to_every_input(): + """任一分量变化都必须产出不同密钥(漏拼 region/service 是常见错法)""" + base = _signing_key("secret", "20240101", "us-east-1", "ec2") + assert base != _signing_key("secret2", "20240101", "us-east-1", "ec2") + assert base != _signing_key("secret", "20240102", "us-east-1", "ec2") + assert base != _signing_key("secret", "20240101", "eu-west-1", "ec2") + assert base != _signing_key("secret", "20240101", "us-east-1", "route53") + + +def _capture_signed_headers(adapter: AWSAdapter, **kwargs) -> dict: + """拦掉 httpx,捕获 _signed_request 实际发出的请求头与 body""" + captured = {} + + def fake_request(method, url, content=None, headers=None): + captured.update(method=method, url=url, content=content, headers=headers) + resp = MagicMock() + resp.content = b"{}" + resp.json.return_value = {} + resp.raise_for_status.return_value = None + return resp + + client = MagicMock() + client.request.side_effect = fake_request + cm = MagicMock() + cm.__enter__.return_value = client + with patch("httpx.Client", return_value=cm): + adapter._signed_request(**kwargs) + return captured + + +def test_aws_authorization_header_format(): + adapter = AWSAdapter({"access_key_id": "AKIDEXAMPLE", "secret_access_key": "SECRET"}) + cap = _capture_signed_headers( + adapter, service="ec2", host="ec2.us-east-1.amazonaws.com", + region="us-east-1", method="POST", path="/", body="Action=DescribeRegions", + ) + auth = cap["headers"]["Authorization"] + assert auth.startswith("AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/") + assert "/us-east-1/ec2/aws4_request" in auth + assert "SignedHeaders=content-type;host;x-amz-date" in auth + signature = auth.split("Signature=")[1] + assert len(signature) == 64 + int(signature, 16) + + +def test_aws_credential_scope_follows_service(): + """ec2 / route53 / route53domains 共用签名代码,scope 必须随 service 变 + + 写死成 ec2 的话 Route53 全部返回 SignatureDoesNotMatch。 + """ + adapter = AWSAdapter({"access_key_id": "AKID", "secret_access_key": "SECRET"}) + ec2 = _capture_signed_headers( + adapter, service="ec2", host="ec2.us-east-1.amazonaws.com", + region="us-east-1", method="POST", path="/", body="", + )["headers"]["Authorization"] + r53 = _capture_signed_headers( + adapter, service="route53", host="route53.amazonaws.com", + region="us-east-1", method="GET", path="/2013-04-01/hostedzone/Z1/rrset", body="", + )["headers"]["Authorization"] + assert "/us-east-1/ec2/aws4_request" in ec2 + assert "/us-east-1/route53/aws4_request" in r53 + assert ec2 != r53 + + +def test_aws_route53_uses_rest_json_content_type(): + """Route53 的 /2013-04-01/* 是 REST-JSON(按路径路由,无 X-Amz-Target), + 用 x-amz-json-1.1 会被当成 JSON 协议服务而报 MissingAuthenticationToken""" + adapter = AWSAdapter({"access_key_id": "AKID", "secret_access_key": "SECRET"}) + with patch.object(AWSAdapter, "_signed_request", return_value={"ResourceRecordSets": []}) as m: + adapter.list_dns_records("Z123") + assert m.call_args.kwargs["content_type"] == "application/json" + assert m.call_args.kwargs["service"] == "route53" + + +def test_aws_host_header_excluded_from_send(): + """host 参与签名但不能手工发送,否则与 httpx 自动设置的值冲突""" + adapter = AWSAdapter({"access_key_id": "AKID", "secret_access_key": "SECRET"}) + cap = _capture_signed_headers( + adapter, service="ec2", host="ec2.us-east-1.amazonaws.com", + region="us-east-1", method="POST", path="/", body="", + ) + assert "host" not in cap["headers"] + # 但签名串里含 host(SignedHeaders 列出了它) + assert "host" in cap["headers"]["Authorization"] + + +def test_aws_signature_sensitive_to_payload(): + adapter = AWSAdapter({"access_key_id": "AKID", "secret_access_key": "SECRET"}) + a = _capture_signed_headers( + adapter, service="ec2", host="h", region="us-east-1", + method="POST", path="/", body="Action=A", + )["headers"]["Authorization"] + b = _capture_signed_headers( + adapter, service="ec2", host="h", region="us-east-1", + method="POST", path="/", body="Action=B", + )["headers"]["Authorization"] + assert a != b + + +def test_aws_region_defaults_and_override(): + assert AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})._region() == "us-east-1" + assert AWSAdapter({"region": "ap-southeast-1"})._region() == "ap-southeast-1" + + +# ----------------------------- AWS EC2 响应解析 ----------------------------- # +def _ec2_response(instances, token=None): + return { + "reservationSet": {"item": [{"instancesSet": {"item": instances}}]}, + **({"nextToken": token} if token else {}), + } + + +def test_aws_list_vps_unwraps_camelcase(): + adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"}) + payload = _ec2_response([ + { + "instanceId": "i-123", + "instanceState": {"name": "running"}, + "ipAddress": "1.2.3.4", + "privateIpAddress": "10.0.0.5", + "placement": {"availabilityZone": "us-east-1a"}, + "instanceLifecycle": "spot", + "tagSet": {"item": [{"key": "Name", "value": "web-01"}]}, + } + ]) + with patch.object(AWSAdapter, "_ec2_call", return_value=payload): + vps = adapter.list_vps() + assert len(vps) == 1 + v = vps[0] + assert v.external_id == "i-123" + assert v.name == "web-01" # 取 tagSet 的 Name,不是 instanceId + assert v.ip_address == "1.2.3.4" # 公网优先 + assert v.region == "us-east-1a" + assert v.status == "active" + assert v.charge_type == "spot" + assert v.currency == "USD" + + +def test_aws_list_vps_single_reservation_is_dict(): + """EC2 查询协议转 JSON 后,单元素返回 dict 而不是 list——不归一会 TypeError""" + adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"}) + payload = { + "reservationSet": { + "item": { # 注意:单个 reservation 时是 dict + "instancesSet": { + "item": { # 单个 instance 时也是 dict + "instanceId": "i-only", + "instanceState": {"name": "stopped"}, + } + } + } + } + } + with patch.object(AWSAdapter, "_ec2_call", return_value=payload): + vps = adapter.list_vps() + assert len(vps) == 1 + assert vps[0].external_id == "i-only" + assert vps[0].status == "stopped" + # 无 tagSet 时回退到 instanceId + assert vps[0].name == "i-only" + # 无公网 IP 时回退到私网 + assert vps[0].ip_address is None + + +def test_aws_list_vps_filters_terminated(): + """terminated/shutting-down 不入台账,否则台账会越堆越多已销毁实例""" + adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"}) + payload = _ec2_response([ + {"instanceId": "i-live", "instanceState": {"name": "running"}}, + {"instanceId": "i-dead", "instanceState": {"name": "terminated"}}, + {"instanceId": "i-dying", "instanceState": {"name": "shutting-down"}}, + {"instanceId": "i-pending", "instanceState": {"name": "pending"}}, + ]) + with patch.object(AWSAdapter, "_ec2_call", return_value=payload): + vps = adapter.list_vps() + ids = {v.external_id for v in vps} + assert ids == {"i-live", "i-pending"} + + +def test_aws_list_vps_pagination(): + adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"}) + pages = [ + _ec2_response([{"instanceId": "i-1", "instanceState": {"name": "running"}}], token="tok2"), + _ec2_response([{"instanceId": "i-2", "instanceState": {"name": "running"}}]), + ] + with patch.object(AWSAdapter, "_ec2_call", side_effect=pages) as m: + vps = adapter.list_vps() + assert [v.external_id for v in vps] == ["i-1", "i-2"] + assert m.call_count == 2 + # 第二页必须带上 NextToken + assert m.call_args_list[1].args[0]["NextToken"] == "tok2" + + +def test_aws_instance_action_sends_instance_id_list(): + """EC2 的启停接口参数是 InstanceId.1(列表编码),不是 InstanceId""" + adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"}) + with patch.object(AWSAdapter, "_ec2_call", return_value={}) as m: + assert adapter.start_instance("i-9")["ok"] is True + params = m.call_args.args[0] + assert params["Action"] == "StartInstances" + assert params["InstanceId.1"] == "i-9" + + +def test_aws_action_failure_returns_not_raises(): + """动作失败要回 ok=False + 原因,不能抛异常(上层按结果渲染提示)""" + adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"}) + with patch.object(AWSAdapter, "_ec2_call", side_effect=RuntimeError("boom")): + res = adapter.reboot_instance("i-1") + assert res["ok"] is False + assert "boom" in res["message"] + + +def test_aws_has_no_account_balance(): + """刻意不实现 get_account:AWS 没有「账户余额」概念,硬凑数字比报不支持更误导""" + caps = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"}).capabilities() + assert caps["get_account"] is False + assert caps["list_vps"] is True + assert caps["renew_instance"] is False # 按需计费无续费概念 + + +# ----------------------------- AWS Route53 ----------------------------- # +def test_aws_hosted_zone_id_strips_prefix(): + assert AWSAdapter._hosted_zone_id("/hostedzone/Z123ABC") == "Z123ABC" + assert AWSAdapter._hosted_zone_id("Z123ABC") == "Z123ABC" + + +def test_aws_epoch_to_date(): + # 2027-01-01T00:00:00Z == 1798761600 + assert AWSAdapter._epoch_to_date(1798761600) == date(2027, 1, 1) + assert AWSAdapter._epoch_to_date("1798761600") == date(2027, 1, 1) + assert AWSAdapter._epoch_to_date(None) is None + assert AWSAdapter._epoch_to_date("garbage") is None + + +def test_aws_list_domains_from_route53domains(): + adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"}) + payload = {"Domains": [{"DomainName": "example.com", "Expiry": 1798761600}]} + with patch.object(AWSAdapter, "_r53domains_call", return_value=payload): + domains = adapter.list_domains() + assert len(domains) == 1 + d = domains[0] + assert d.domain_name == "example.com" + assert isinstance(d.expiry_date, date) # 必须是 date,否则写库抛 TypeError 整批回滚 + assert d.expiry_date == date(2027, 1, 1) + assert d.registrar == "aws-route53" + + +def test_aws_list_dns_records_normalization(): + adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"}) + payload = { + "ResourceRecordSets": [ + { + "Name": "www.example.com.", # Route53 总是带尾点 + "Type": "A", + "TTL": 300, + "ResourceRecords": [{"Value": "1.2.3.4"}, {"Value": "5.6.7.8"}], + } + ] + } + with patch.object(AWSAdapter, "_signed_request", return_value=payload): + records = adapter.list_dns_records("Z1") + assert len(records) == 1 + r = records[0] + assert r.name == "www.example.com" # 尾点被剥掉 + assert r.record_id == "www.example.com.|A" # record_id 用原始带尾点的 Name + assert r.content == "1.2.3.4, 5.6.7.8" # 多值合并 + assert r.ttl == 300 + + +def test_aws_dns_write_wraps_values_in_array(): + """Route53 的 ResourceRecords 必须是数组,单值也要包一层""" + adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"}) + with patch.object(AWSAdapter, "_signed_request", return_value={}) as m: + res = adapter.create_dns_record("Z1", {"name": "a.example.com", "type": "A", "content": "1.2.3.4", "ttl": 600}) + assert res["ok"] is True + import json as _json + body = _json.loads(m.call_args.kwargs["body"]) + rrs = body["Changes"][0]["ResourceRecordSet"] + assert rrs["ResourceRecords"] == [{"Value": "1.2.3.4"}] + assert rrs["TTL"] == 600 + assert body["Changes"][0]["Action"] == "CREATE" + + +def test_aws_delete_requires_name_type_record_id(): + """Route53 删除必须提交完整记录集,record_id 缺 TYPE 无法构造""" + adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"}) + res = adapter.delete_dns_record("Z1", "no-pipe-here") + assert res["ok"] is False + + +def test_aws_delete_reads_existing_record_first(): + """DELETE 需要带上现存的值与 TTL,必须先读回记录""" + adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"}) + payload = { + "ResourceRecordSets": [ + {"Name": "a.example.com.", "Type": "A", "TTL": 300, "ResourceRecords": [{"Value": "1.2.3.4"}]} + ] + } + with patch.object(AWSAdapter, "_signed_request", side_effect=[payload, {}]) as m: + res = adapter.delete_dns_record("Z1", "a.example.com.|A") + assert res["ok"] is True + import json as _json + body = _json.loads(m.call_args_list[1].kwargs["body"]) + assert body["Changes"][0]["Action"] == "DELETE" + assert body["Changes"][0]["ResourceRecordSet"]["ResourceRecords"] == [{"Value": "1.2.3.4"}] + + +def test_aws_delete_missing_record(): + adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"}) + with patch.object(AWSAdapter, "_signed_request", return_value={"ResourceRecordSets": []}): + res = adapter.delete_dns_record("Z1", "gone.example.com.|A") + assert res["ok"] is False + assert "不存在" in res["message"] + + +def test_aws_test_connection_reports_region_count(): + adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"}) + payload = {"regionInfo": {"item": [{"regionName": "us-east-1"}, {"regionName": "eu-west-1"}]}} + with patch.object(AWSAdapter, "_ec2_call", return_value=payload): + res = adapter.test_connection() + assert res["ok"] is True + assert "2" in res["message"] + + +def test_aws_test_connection_failure_is_graceful(): + adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"}) + resp = httpx.Response(403, request=httpx.Request("POST", "https://ec2.us-east-1.amazonaws.com/")) + with patch.object(AWSAdapter, "_ec2_call", side_effect=httpx.HTTPStatusError("denied", request=resp.request, response=resp)): + res = adapter.test_connection() + assert res["ok"] is False + assert "403" in res["message"] + + +# ----------------------------- Linode ----------------------------- # +def test_linode_list_vps_normalization(): + adapter = LinodeAdapter({"api_key": "x"}) + payload = { + "pages": 1, + "data": [ + { + "id": 12345, + "label": "prod-web", + "status": "running", + "region": "us-east", + "image": "linode/ubuntu22.04", + "ipv4": ["1.2.3.4", "10.0.0.1"], + "specs": {"memory": 4096, "vcpus": 2, "disk": 81920}, + } + ], + } + with patch.object(LinodeAdapter, "_get", return_value=payload): + vps = adapter.list_vps() + assert len(vps) == 1 + v = vps[0] + assert v.external_id == "12345" + assert v.ip_address == "1.2.3.4" # 取第一个 IPv4 + assert v.memory_gb == 4.0 # MB -> GB + assert v.cpu_cores == 2 + assert v.disk_gb == 81920 + assert v.status == "active" + assert v.charge_type == "postpaid" # 按小时计费,不会有到期日 + assert v.expiry_date is None + + +def test_linode_status_mapping(): + adapter = LinodeAdapter({"api_key": "x"}) + for raw, expected in (("offline", "stopped"), ("shutting_down", "stopped"), ("booting", "active")): + payload = {"pages": 1, "data": [{"id": 1, "label": "n", "status": raw, "specs": {}}]} + with patch.object(LinodeAdapter, "_get", return_value=payload): + assert adapter.list_vps()[0].status == expected + + +def test_linode_negative_balance_is_credit(): + """Linode 的 balance 为负表示账户有预付余额,正数才是欠款——不要取绝对值""" + adapter = LinodeAdapter({"api_key": "x"}) + with patch.object(LinodeAdapter, "_get", return_value={"balance": -25.5, "email": "me@x.com"}): + acc = adapter.get_account() + assert acc.balance == -25.5 + assert acc.currency == "USD" + + +def test_linode_pagination_uses_pages(): + adapter = LinodeAdapter({"api_key": "x"}) + pages = [ + {"pages": 2, "data": [{"id": 1, "label": "a", "status": "running", "specs": {}}]}, + {"pages": 2, "data": [{"id": 2, "label": "b", "status": "running", "specs": {}}]}, + ] + with patch.object(LinodeAdapter, "_get", side_effect=pages) as m: + vps = adapter.list_vps() + assert [v.external_id for v in vps] == ["1", "2"] + assert "page=2" in m.call_args_list[1].args[0] + + +def test_linode_domains_expiry_nullable(): + """仅 Linode 注册的域名有 expiry;纯 DNS 托管的为 null,不能炸""" + adapter = LinodeAdapter({"api_key": "x"}) + payload = { + "pages": 1, + "data": [ + {"id": 1, "domain": "reg.com", "status": "active", "expiry": "2027-05-06T04:00:00"}, + {"id": 2, "domain": "dns-only.com", "status": "active", "expiry": None}, + ], + } + with patch.object(LinodeAdapter, "_get", return_value=payload): + domains = adapter.list_domains() + assert domains[0].expiry_date == date(2027, 5, 6) + assert domains[1].expiry_date is None + + +def test_linode_error_message_reads_body_reason(): + """Linode 把原因放在 body.errors[0].reason,裸状态码对用户没用""" + adapter = LinodeAdapter({"api_key": "x"}) + resp = httpx.Response( + 403, + request=httpx.Request("GET", "https://api.linode.com/v4/account"), + json={"errors": [{"reason": "Invalid token"}]}, + ) + err = httpx.HTTPStatusError("denied", request=resp.request, response=resp) + assert "Invalid token" in adapter._error_message(err) + + +def test_linode_error_message_survives_unparseable_body(): + adapter = LinodeAdapter({"api_key": "x"}) + resp = httpx.Response( + 502, + request=httpx.Request("GET", "https://api.linode.com/v4/account"), + content=b"bad gateway", + ) + err = httpx.HTTPStatusError("boom", request=resp.request, response=resp) + assert adapter._error_message(err) == "HTTP 502" + + +def test_linode_capabilities_no_renew(): + """按小时计费无「续费」概念,不能声明 renew_instance""" + caps = LinodeAdapter({"api_key": "x"}).capabilities() + assert caps["renew_instance"] is False + assert caps["start_instance"] is True + assert caps["list_dns_records"] is True + assert caps["get_account"] is True + + +def test_linode_instance_actions_hit_right_verbs(): + adapter = LinodeAdapter({"api_key": "x"}) + for fn, verb in (("start_instance", "boot"), ("stop_instance", "shutdown"), ("reboot_instance", "reboot")): + with patch.object(LinodeAdapter, "_request", return_value={}) as m: + assert getattr(adapter, fn)("999")["ok"] is True + assert m.call_args.args[1] == f"/linode/instances/999/{verb}" + + +def test_linode_record_body_mapping(): + """Linode 用 target/ttl_sec,不是 content/ttl""" + body = LinodeAdapter._record_body({"type": "MX", "name": "mail", "content": "mx.example.com", "ttl": 600, "priority": 10}) + assert body == {"type": "MX", "name": "mail", "target": "mx.example.com", "ttl_sec": 600, "priority": 10} + # priority 为 None 时不应出现在 body 里(Linode 会校验类型) + assert "priority" not in LinodeAdapter._record_body({"type": "A", "name": "a", "content": "1.2.3.4"}) + + +def test_linode_dns_crud_paths(): + adapter = LinodeAdapter({"api_key": "x"}) + with patch.object(LinodeAdapter, "_request", return_value={"id": 555}) as m: + assert adapter.create_dns_record("7", {"type": "A", "name": "a", "content": "1.2.3.4"})["record_id"] == "555" + assert m.call_args.args[0] == "POST" + assert m.call_args.args[1] == "/domains/7/records" + + assert adapter.update_dns_record("7", "555", {"type": "A", "name": "a", "content": "1.2.3.5"})["ok"] is True + assert m.call_args.args[0] == "PUT" + assert m.call_args.args[1] == "/domains/7/records/555" + + assert adapter.delete_dns_record("7", "555")["ok"] is True + assert m.call_args.args[0] == "DELETE" + + +def test_linode_list_dns_records_maps_target(): + adapter = LinodeAdapter({"api_key": "x"}) + payload = {"pages": 1, "data": [{"id": 1, "type": "A", "name": "www", "target": "1.2.3.4", "ttl_sec": 300}]} + with patch.object(LinodeAdapter, "_get", return_value=payload): + records = adapter.list_dns_records("7") + assert records[0].content == "1.2.3.4" + assert records[0].ttl == 300 + assert records[0].zone_id == "7" + + +def test_linode_empty_body_handled(): + """POST 动作类接口返回 200 + 空 body,直接 .json() 会炸""" + adapter = LinodeAdapter({"api_key": "x"}) + resp = MagicMock() + resp.content = b"" + resp.raise_for_status.return_value = None + client = MagicMock() + client.request.return_value = resp + cm = MagicMock() + cm.__enter__.return_value = client + with patch("httpx.Client", return_value=cm): + assert adapter.start_instance("1")["ok"] is True + + +# ----------------------------- Namesilo ----------------------------- # +def _namesilo_reply(reply: dict) -> dict: + return {"request": {"command": "x"}, "reply": reply} + + +def test_namesilo_success_code_300(): + adapter = NamesiloAdapter({"api_key": "k"}) + payload = _namesilo_reply({"code": 300, "detail": "success", "domains": []}) + resp = MagicMock() + resp.json.return_value = payload + resp.raise_for_status.return_value = None + client = MagicMock() + client.get.return_value = resp + cm = MagicMock() + cm.__enter__.return_value = client + with patch("httpx.Client", return_value=cm): + assert adapter._call("listDomains")["code"] == 300 + + +def test_namesilo_string_code_accepted(): + """Namesilo 的 code 可能是 str,直接 != 300 会把成功误判成失败""" + adapter = NamesiloAdapter({"api_key": "k"}) + resp = MagicMock() + resp.json.return_value = _namesilo_reply({"code": "300", "detail": "success"}) + resp.raise_for_status.return_value = None + client = MagicMock() + client.get.return_value = resp + cm = MagicMock() + cm.__enter__.return_value = client + with patch("httpx.Client", return_value=cm): + assert adapter._call("checkApiKey")["code"] == "300" + + +def test_namesilo_failure_raises_with_detail(): + """301 未授权 / 400 参数错必须抛出并带上 detail,否则用户只看到一个空失败""" + adapter = NamesiloAdapter({"api_key": "k"}) + resp = MagicMock() + resp.json.return_value = _namesilo_reply({"code": 301, "detail": "Invalid or missing API key"}) + resp.raise_for_status.return_value = None + client = MagicMock() + client.get.return_value = resp + cm = MagicMock() + cm.__enter__.return_value = client + with patch("httpx.Client", return_value=cm): + with pytest.raises(RuntimeError) as ei: + adapter._call("listDomains") + assert "301" in str(ei.value) + assert "Invalid or missing API key" in str(ei.value) + + +def test_namesilo_api_key_sent_as_query_param(): + adapter = NamesiloAdapter({"api_key": "SECRETKEY"}) + resp = MagicMock() + resp.json.return_value = _namesilo_reply({"code": 300, "username": "me"}) + resp.raise_for_status.return_value = None + client = MagicMock() + client.get.return_value = resp + cm = MagicMock() + cm.__enter__.return_value = client + with patch("httpx.Client", return_value=cm): + adapter.test_connection() + query = client.get.call_args.kwargs["params"] + assert query["key"] == "SECRETKEY" + assert query["version"] == "1" + assert query["type"] == "json" + + +def test_namesilo_test_connection_reports_username(): + adapter = NamesiloAdapter({"api_key": "k"}) + with patch.object(NamesiloAdapter, "_call", return_value={"code": 300, "username": "neatstudio"}): + res = adapter.test_connection() + assert res["ok"] is True + assert "neatstudio" in res["message"] + + +def test_namesilo_list_domains_expiry(): + adapter = NamesiloAdapter({"api_key": "k"}) + reply = {"code": 300, "domains": [{"domain": "example.com", "expires": "2027-01-01"}]} + with patch.object(NamesiloAdapter, "_call", return_value=reply): + domains = adapter.list_domains() + assert len(domains) == 1 + assert isinstance(domains[0].expiry_date, date) + assert domains[0].expiry_date == date(2027, 1, 1) + assert domains[0].registrar == "namesilo" + + +def test_namesilo_unparseable_expiry_is_none(): + adapter = NamesiloAdapter({"api_key": "k"}) + reply = {"code": 300, "domains": [{"domain": "x.com", "expires": "not-a-date"}]} + with patch.object(NamesiloAdapter, "_call", return_value=reply): + assert adapter.list_domains()[0].expiry_date is None + + +def test_namesilo_fqdn_host_roundtrip(): + """Namesilo 存子域(空/@ 表示裸域),台账展示需要完整域名""" + assert NamesiloAdapter._fqdn("", "example.com") == "example.com" + assert NamesiloAdapter._fqdn("@", "example.com") == "example.com" + assert NamesiloAdapter._fqdn("www", "example.com") == "www.example.com" + + assert NamesiloAdapter._host("example.com", "example.com") == "" + assert NamesiloAdapter._host("www.example.com", "example.com") == "www" + assert NamesiloAdapter._host("", "example.com") == "" + + +def test_namesilo_dns_record_normalization(): + adapter = NamesiloAdapter({"api_key": "k"}) + reply = { + "code": 300, + "resource_record": [ + {"record_id": 111, "host": "www", "type": "A", "value": "1.2.3.4", "ttl": 3600}, + {"record_id": 222, "host": "", "type": "MX", "value": "mail.example.com", "ttl": 3600, "distance": 10}, + ], + } + with patch.object(NamesiloAdapter, "_call", return_value=reply): + records = adapter.list_dns_records("example.com") + assert records[0].name == "www.example.com" + assert records[0].record_id == "111" + assert records[0].ttl == 3600 + assert records[1].name == "example.com" # 裸域 + assert records[1].priority == 10 + + +def test_namesilo_create_omits_rr_id(): + """dnsRecordUpdate 同时承担新增与修改:新增绝不能带 rr_id,否则改到别的记录上""" + adapter = NamesiloAdapter({"api_key": "k"}) + with patch.object(NamesiloAdapter, "_call", return_value={"code": 300}) as m: + res = adapter.create_dns_record("example.com", {"type": "A", "name": "www.example.com", "content": "1.2.3.4", "ttl": 600}) + assert res["ok"] is True + assert m.call_args.args[0] == "dnsRecordUpdate" + params = m.call_args.args[1] + assert "rr_id" not in params + assert params["rr_host"] == "www" + assert params["rr_type"] == "A" + assert params["rr_value"] == "1.2.3.4" + assert params["rr_ttl"] == 600 + + +def test_namesilo_update_sends_rr_id(): + adapter = NamesiloAdapter({"api_key": "k"}) + with patch.object(NamesiloAdapter, "_call", return_value={"code": 300}) as m: + res = adapter.update_dns_record("example.com", "111", {"type": "A", "name": "example.com", "content": "9.9.9.9"}) + assert res["ok"] is True + params = m.call_args.args[1] + assert params["rr_id"] == "111" + assert params["rr_host"] == "" # 裸域 -> 空串 + assert params["rr_ttl"] == 3600 # 未给 ttl 时的默认值 + + +def test_namesilo_mx_sends_distance(): + adapter = NamesiloAdapter({"api_key": "k"}) + with patch.object(NamesiloAdapter, "_call", return_value={"code": 300}) as m: + adapter.create_dns_record("example.com", {"type": "MX", "name": "example.com", "content": "mx.x.com", "priority": 10}) + assert m.call_args.args[1]["rr_distance"] == 10 + + +def test_namesilo_delete_passes_rr_id(): + adapter = NamesiloAdapter({"api_key": "k"}) + with patch.object(NamesiloAdapter, "_call", return_value={"code": 300}) as m: + assert adapter.delete_dns_record("example.com", "111")["ok"] is True + assert m.call_args.args[0] == "dnsRecordDelete" + assert m.call_args.args[1] == {"domain": "example.com", "rr_id": "111"} + + +def test_namesilo_write_failure_returns_not_raises(): + adapter = NamesiloAdapter({"api_key": "k"}) + with patch.object(NamesiloAdapter, "_call", side_effect=RuntimeError("code=301: bad key")): + assert adapter.create_dns_record("x.com", {"type": "A", "name": "x.com", "content": "1.1.1.1"})["ok"] is False + assert adapter.update_dns_record("x.com", "1", {"type": "A", "name": "x.com", "content": "1.1.1.1"})["ok"] is False + assert adapter.delete_dns_record("x.com", "1")["ok"] is False + + +def test_namesilo_capabilities_domain_only(): + """纯域名注册商:无 VPS、无实例动作、无余额""" + caps = NamesiloAdapter({"api_key": "k"}).capabilities() + assert caps["list_domains"] is True + assert caps["list_dns_records"] is True + assert caps["list_vps"] is False + assert caps["get_account"] is False + assert caps["start_instance"] is False diff --git a/tests/test_provider_alignment.py b/tests/test_provider_alignment.py new file mode 100644 index 0000000..b7405f7 --- /dev/null +++ b/tests/test_provider_alignment.py @@ -0,0 +1,196 @@ +"""平台注册表对齐测试 + +覆盖两处回归: + +1. `_align_preset_providers`:seed_providers 只插入缺失 slug、不更新已有行, + 所以代码侧的能力变化必须靠迁移落地。迁移必须是「增量」的——线上 aliyun-intl + 的 services 被用户手工加过 ai_agent/other,按种子值整体覆盖会静默丢用户配置。 +2. 种子与注册表的一致性:任何预设平台都不得声明未实现的 sdk_type。 + 声明了前端就会显示「API 对接: xxx」,用户建好账号点同步才撞 + 「暂不支持的 SDK 类型」——声明支持却不支持,比明说不支持更糟。 + +运行:.venv/bin/pytest tests/test_provider_alignment.py -v +""" + +import sqlalchemy as sa +import pytest +from sqlmodel import Session, SQLModel, create_engine, select + +import app.models.asset # noqa: F401 +import app.models.credential # noqa: F401 +import app.models.monitor # noqa: F401 +import app.models.provider # noqa: F401 +import app.models.ssl # noqa: F401 +from app.adapters import registry +from app.core.seed import PRESET_PROVIDERS, seed_providers +from app.database import _align_preset_providers +from app.models.provider import Provider + +#: 线上 GET /api/providers 抓下来的真实状态(2026-10-11), +#: 用它做 fixture 才能保证迁移验证的是生产形态而不是理想形态 +LIVE_PROVIDERS = [ + ("agnes", None, "ai_agent"), + ("deepseek", "deepseek-api", "ai_agent"), + ("kimi", "moonshot-api", "ai_agent"), + ("minimax", "minimax-api", "ai_agent"), + ("openai", "openai-api", "ai_agent"), + ("cloudflare", "cloudflare-api", "cloudflare,dns,ssl_cert,cdn"), + ("dynadot", "dynadot-api", "domain,dns"), + ("namesilo", "namesilo-api", "domain,dns"), + ("xinwang", None, "domain,dns"), + ("aliyun", "aliyun-sdk", "vps,domain,ssl_cert,cdn,dns"), + ("aliyun-intl", "alibabacloud-sdk", "vps,domain,ssl_cert,cdn,dns,ai_agent,other"), + ("cloudcone", "cloudcone-api", "vps"), + ("digitalocean", "do-api", "vps,domain"), + ("linode", "linode-api", "vps"), + ("tencent", "tencent-sdk", "vps,domain,ssl_cert,cdn,dns"), + ("tencent-intl", "tencent-intl-sdk", "vps,domain,ssl_cert,cdn,dns"), + ("tothost", None, "vps"), + ("vultr", "vultr-api", "vps"), + ("zeabur", "zeabur-api", "vps,domain"), +] + + +@pytest.fixture() +def engine(tmp_path): + eng = create_engine( + f"sqlite:///{tmp_path / 'providers.db'}", connect_args={"check_same_thread": False} + ) + SQLModel.metadata.create_all(eng) + yield eng + eng.dispose() + + +@pytest.fixture() +def live_db(engine): + """复刻线上 providers 表""" + with Session(engine) as s: + for slug, sdk, svc in LIVE_PROVIDERS: + s.add(Provider(slug=slug, name=slug, category="vps", services=svc, sdk_type=sdk)) + s.commit() + return engine + + +def _snapshot(engine) -> dict: + with engine.begin() as conn: + rows = conn.execute(sa.text("SELECT slug, sdk_type, services FROM providers")).fetchall() + return {r[0]: (r[1], r[2]) for r in rows} + + +def _run_migration(engine) -> None: + with engine.begin() as conn: + _align_preset_providers(conn) + + +# ----------------------------- 种子 / 注册表一致性 ----------------------------- # +def test_no_preset_declares_unimplemented_sdk_type(): + """预设平台不得声明未实现的 sdk_type(AWS/Linode/Namesilo 落地后的不变量)""" + supported = set(registry.supported_types()) + bogus = [(p["slug"], p["sdk_type"]) for p in PRESET_PROVIDERS + if p.get("sdk_type") and p["sdk_type"] not in supported] + assert bogus == [], f"这些平台声明了未实现的适配器:{bogus}" + + +def test_new_adapters_are_registered(): + """本轮新增的三家必须有对应注册项""" + for sdk_type in ("aws-api", "linode-api", "namesilo-api"): + assert registry.is_supported(sdk_type), f"{sdk_type} 未注册" + + +def test_seed_providers_inserts_missing_slug(live_db): + """线上没有 aws 行,seed_providers 必须补上""" + assert "aws" not in _snapshot(live_db) + with Session(live_db) as s: + added = seed_providers(s) + snap = _snapshot(live_db) + assert added >= 1 + assert snap["aws"][0] == "aws-api" + assert "vps" in snap["aws"][1] + + +def test_seed_providers_is_idempotent(live_db): + with Session(live_db) as s: + first = seed_providers(s) + second = seed_providers(s) + assert second == 0 + assert len(_snapshot(live_db)) == len(LIVE_PROVIDERS) + first + + +# ----------------------------- 迁移行为 ----------------------------- # +def test_migration_clears_bogus_sdk_type(live_db): + """cloudcone/zeabur/dynadot 声明的适配器从未实现,必须清空""" + _run_migration(live_db) + snap = _snapshot(live_db) + for slug in ("cloudcone", "zeabur", "dynadot"): + assert snap[slug][0] is None, f"{slug}.sdk_type 未清空:{snap[slug][0]!r}" + + +def test_migration_keeps_valid_sdk_type(live_db): + """有效 sdk_type 不能被误伤,否则同步能力全丢""" + _run_migration(live_db) + snap = _snapshot(live_db) + for slug, expected in ( + ("aliyun", "aliyun-sdk"), ("tencent", "tencent-sdk"), ("vultr", "vultr-api"), + ("digitalocean", "do-api"), ("linode", "linode-api"), ("namesilo", "namesilo-api"), + ("cloudflare", "cloudflare-api"), ("openai", "openai-api"), + ): + assert snap[slug][0] == expected + + +def test_migration_preserves_user_customized_services(live_db): + """aliyun-intl 被用户手工加过 ai_agent/other,覆盖式迁移会静默丢配置""" + _run_migration(live_db) + tags = set(_snapshot(live_db)["aliyun-intl"][1].split(",")) + assert {"ai_agent", "other"} <= tags + + +def test_migration_adds_missing_service_tags(live_db): + """适配器新增了域名/DNS 能力,services 必须跟上, + 否则 modals.js 按 services 过滤时选不到这些平台""" + _run_migration(live_db) + snap = _snapshot(live_db) + assert {"domain", "dns"} <= set(snap["vultr"][1].split(",")) + assert {"domain", "dns"} <= set(snap["linode"][1].split(",")) + assert "dns" in snap["digitalocean"][1].split(",") + + +def test_migration_ignores_user_created_providers(live_db): + """tothost 不在种子里,一个字都不能动""" + before = _snapshot(live_db)["tothost"] + _run_migration(live_db) + assert _snapshot(live_db)["tothost"] == before + + +def test_migration_is_idempotent(live_db): + """迁移跑两次结果必须一致(update.sh 每分钟可能触发重启)""" + _run_migration(live_db) + once = _snapshot(live_db) + _run_migration(live_db) + _run_migration(live_db) + assert _snapshot(live_db) == once + + +def test_migration_leaves_no_unimplemented_declaration(live_db): + """终态不变量:迁移 + seed 后,库里不存在「声明但未实现」的 sdk_type""" + _run_migration(live_db) + with Session(live_db) as s: + seed_providers(s) + _run_migration(live_db) + + supported = set(registry.supported_types()) + snap = _snapshot(live_db) + bad = [(slug, v[0]) for slug, v in snap.items() if v[0] and v[0] not in supported] + assert bad == [] + + +def test_migration_result_covers_all_seed_services(live_db): + """终态:每个预设平台的 services 至少包含种子声明的标签""" + _run_migration(live_db) + snap = _snapshot(live_db) + for data in PRESET_PROVIDERS: + slug = data["slug"] + if slug not in snap: + continue + seed_tags = {t.strip() for t in (data.get("services") or "").split(",") if t.strip()} + cur_tags = {t.strip() for t in (snap[slug][1] or "").split(",") if t.strip()} + assert seed_tags <= cur_tags, f"{slug} 缺标签 {sorted(seed_tags - cur_tags)}" diff --git a/tests/test_stats_overview.py b/tests/test_stats_overview.py new file mode 100644 index 0000000..0171818 --- /dev/null +++ b/tests/test_stats_overview.py @@ -0,0 +1,183 @@ +"""资产总览统计测试(get_overview) + +覆盖的核心问题:「本年支出」是只把填了费用的资产加起来的和。cost 默认 0, +所以没填费用和真的免费在数据上无法区分——合计因此天然是个下限,而不是准数。 +前端必须能拿到缺口大小(year_cost_missing)才能如实说明,否则用户会把下限 +当成实际支出。 + +顺带回归到期口径:已过期(overdue)与 30 天内到期(expiring_30)分开计数, +已注销资产两边都不进。 + +运行:.venv/bin/pytest tests/test_stats_overview.py -v +""" + +from datetime import date, timedelta + +import pytest +from sqlmodel import Session, SQLModel, create_engine + +import app.models.asset # noqa: F401 +import app.models.credential # noqa: F401 +import app.models.monitor # noqa: F401 +import app.models.provider # noqa: F401 +import app.models.ssl # noqa: F401 +from app.models.asset import Asset, AssetStatus, AssetType +from app.services.asset_service import get_overview + + +@pytest.fixture() +def engine(tmp_path): + eng = create_engine( + f"sqlite:///{tmp_path / 'stats.db'}", connect_args={"check_same_thread": False} + ) + SQLModel.metadata.create_all(eng) + yield eng + eng.dispose() + + +def _add(engine, name, *, expiry=None, cost=0.0, status=AssetStatus.ACTIVE, + asset_type=AssetType.VPS): + with Session(engine) as s: + s.add(Asset( + name=name, asset_type=asset_type, provider="vultr", + expiry_date=expiry, cost=cost, status=status, + )) + s.commit() + + +def _overview(engine): + with Session(engine) as s: + return get_overview(s) + + +def test_empty_db_returns_all_contract_keys(engine): + """空库也必须给出完整结构:/api/stats/overview 是前端总览页的数据源, + 缺键会让 dashboard.js 渲染出 undefined""" + got = _overview(engine) + assert got == { + "total": 0, + "by_type": {}, + "by_status": {}, + "abnormal_count": 0, + "expiring_30": 0, + "overdue_count": 0, + "year_cost": 0, + "month_cost": 0, + "year_cost_missing": 0, + } + + +def test_year_cost_sums_only_filled_costs(engine): + today = date.today() + _add(engine, "paid", expiry=today, cost=12.5) + _add(engine, "also-paid", expiry=today + timedelta(days=3), cost=7.25) + got = _overview(engine) + assert got["year_cost"] == 19.75 + assert got["year_cost_missing"] == 0 + + +def test_unfilled_cost_is_counted_as_gap_not_zero_spend(engine): + """cost 默认 0,没填的资产既不进合计、也不能悄悄消失""" + today = date.today() + _add(engine, "paid", expiry=today, cost=10.0) + _add(engine, "unfilled-a", expiry=today + timedelta(days=1)) + _add(engine, "unfilled-b", expiry=today + timedelta(days=2)) + got = _overview(engine) + assert got["year_cost"] == 10.0 + assert got["year_cost_missing"] == 2 + assert got["total"] == 3 + + +def test_assets_without_expiry_do_not_enter_cost_window(engine): + """没有到期日的资产不属于任何年度窗口:既不该加进合计,也不该算作缺口, + 否则「另有 N 项未填费用」会把一批跟今年无关的资产算进去""" + _add(engine, "no-expiry-unfilled") + _add(engine, "no-expiry-paid", cost=99.0) + got = _overview(engine) + assert got["year_cost"] == 0 + assert got["year_cost_missing"] == 0 + assert got["total"] == 2 + + +def test_other_year_expiry_excluded_from_cost(engine): + """跨年资产不进本年合计,也不进本年缺口""" + this_year = date.today() + other_year = date(this_year.year + 1, 6, 15) + _add(engine, "this-year", expiry=this_year, cost=5.0) + _add(engine, "next-year-unfilled", expiry=other_year) + got = _overview(engine) + assert got["year_cost"] == 5.0 + assert got["year_cost_missing"] == 0 + + +def test_month_cost_only_current_month(engine): + today = date.today() + # 取本月内的另一天:若今天是 1 号则往后挪,避免算出上/下月的日期 + same_month = today.replace(day=1) if today.day > 1 else today + next_month = (today.replace(day=1) + timedelta(days=32)).replace(day=2) + _add(engine, "this-month", expiry=same_month, cost=20.0) + _add(engine, "next-month", expiry=next_month, cost=30.0) + got = _overview(engine) + assert got["month_cost"] == 20.0 + # 下月那笔仍属于本年 + assert got["year_cost"] == 50.0 + + +def test_year_cost_rounded_to_two_decimals(engine): + """币种最小单位是分,浮点累加的 0.1+0.2 尾巴不能漏到界面上""" + today = date.today() + _add(engine, "a", expiry=today, cost=0.1) + _add(engine, "b", expiry=today, cost=0.2) + assert _overview(engine)["year_cost"] == 0.3 + + +def test_overdue_and_expiring_counted_separately(engine): + """已过期比 30 天内到期更该被看见,不能因为不在未来窗口里就从统计消失""" + today = date.today() + _add(engine, "overdue", expiry=today - timedelta(days=1)) + _add(engine, "soon", expiry=today + timedelta(days=10)) + _add(engine, "far", expiry=today + timedelta(days=90)) + got = _overview(engine) + assert got["overdue_count"] == 1 + assert got["expiring_30"] == 1 + assert got["total"] == 3 + + +def test_today_counts_as_expiring_not_overdue(engine): + """到期日就是今天:还能续费,属于倒计时而不是已错过""" + _add(engine, "due-today", expiry=date.today()) + got = _overview(engine) + assert got["expiring_30"] == 1 + assert got["overdue_count"] == 0 + + +def test_cancelled_assets_excluded_from_expiry_counts(engine): + """已注销资产不该再触发续费提醒""" + today = date.today() + _add(engine, "cancelled-overdue", expiry=today - timedelta(days=5), + status=AssetStatus.CANCELLED) + _add(engine, "cancelled-soon", expiry=today + timedelta(days=5), + status=AssetStatus.CANCELLED) + got = _overview(engine) + assert got["overdue_count"] == 0 + assert got["expiring_30"] == 0 + # 但仍计入总数:台账要能查到已注销的历史资产 + assert got["total"] == 2 + + +def test_abnormal_counts_stopped_and_expired(engine): + _add(engine, "stopped", status=AssetStatus.STOPPED) + _add(engine, "expired", status=AssetStatus.EXPIRED) + _add(engine, "active", status=AssetStatus.ACTIVE) + got = _overview(engine) + assert got["abnormal_count"] == 2 + assert got["by_status"] == {"stopped": 1, "expired": 1, "active": 1} + + +def test_by_type_uses_enum_values_not_names(engine): + """前端按 'vps'/'domain' 匹配图标与文案,给出 'VPS' 会全部落到兜底分支""" + _add(engine, "v1", asset_type=AssetType.VPS) + _add(engine, "d1", asset_type=AssetType.DOMAIN) + _add(engine, "d2", asset_type=AssetType.DOMAIN) + got = _overview(engine) + assert got["by_type"] == {"vps": 1, "domain": 2} diff --git a/tests/test_sync_chain.py b/tests/test_sync_chain.py new file mode 100644 index 0000000..1bb429a --- /dev/null +++ b/tests/test_sync_chain.py @@ -0,0 +1,228 @@ +"""云资产同步链路测试:路由注册 + 定时脚本 + systemd 单元接线 + +这条链有三个环节,任一环断了都不会报错,只会「夜间同步悄悄不跑了」: + 1. /api/sync/assets 没注册 -> 手动触发按钮 404 + 2. scripts/sync_all_assets.py 退出码恒为 0 -> systemd 永远记成功,失败无告警 + 3. deploy/*.timer 没被 setup.sh enable -> 全新部署后定时器根本不存在 + +运行:.venv/bin/pytest tests/test_sync_chain.py -v +""" + +import importlib.util +import re +from datetime import time +from pathlib import Path + +import pytest +from sqlmodel import SQLModel, create_engine + +import app.models.asset # noqa: F401 +import app.models.credential # noqa: F401 +import app.models.monitor # noqa: F401 +import app.models.provider # noqa: F401 +import app.models.ssl # noqa: F401 + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPTS_DIR = REPO_ROOT / "scripts" +DEPLOY_DIR = REPO_ROOT / "deploy" + + +# --------------------------------------------------------------------------- # +# 1. 路由注册 +# --------------------------------------------------------------------------- # +def test_sync_assets_endpoint_is_registered(): + """/api/sync/assets 必须出现在 OpenAPI 里 + + 这个 FastAPI 版本把 include_router 进来的路由包在 _IncludedRouter 里延迟展开, + 遍历 app.routes 看不到,只能查 app.openapi()['paths']。 + """ + from app.main import app + + paths = app.openapi()["paths"] + assert "/api/sync/assets" in paths, f"未注册;现有 sync 路由:{[p for p in paths if 'sync' in p]}" + assert "post" in paths["/api/sync/assets"] + + +def test_sync_assets_endpoint_is_key_protected(): + """批量同步会解密所有账号凭证并打云端接口,不能匿名可调""" + from app.core.security import require_api_key + from app.routers.sync import router + + route = next(r for r in router.routes if r.path == "/api/sync/assets") + calls = [d.call for d in route.dependant.dependencies] + assert require_api_key in calls, "缺少 require_api_key 依赖,端点对外裸奔" + + +# --------------------------------------------------------------------------- # +# 2. 定时脚本 +# --------------------------------------------------------------------------- # +def _load_script(): + """按文件路径加载 scripts/sync_all_assets.py(scripts/ 不是包)""" + path = SCRIPTS_DIR / "sync_all_assets.py" + spec = importlib.util.spec_from_file_location("_sync_all_assets_under_test", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture() +def script(tmp_path, monkeypatch): + eng = create_engine( + f"sqlite:///{tmp_path / 'chain.db'}", connect_args={"check_same_thread": False} + ) + SQLModel.metadata.create_all(eng) + # sync_service 内部是「函数里 from app.database import assets_engine」,所以 + # 补丁打在 app.database 上才生效;而脚本自己在模块级 import 了同名对象, + # 那份绑定必须单独换掉,否则 init_db() 会去建真的 data/*.db。 + monkeypatch.setattr("app.database.assets_engine", eng) + mod = _load_script() + monkeypatch.setattr(mod, "init_db", lambda: None) + monkeypatch.setattr(mod, "assets_engine", eng) + yield mod + eng.dispose() + + +def test_script_summary_prints_every_key_the_service_returns(script, capsys): + """端到端跑一次真实 sync_all_assets(空库),确保摘要行不因缺键 KeyError + + 脚本直接下标访问 result['skipped_no_config'] 等键;服务层返回结构一改, + 这里就会在夜间定时任务里抛 KeyError,而 timer 的失败没人看。 + """ + rc = script.main() + out = capsys.readouterr().out + assert rc == 0 + assert "[云资产同步]" in out + for fragment in ("账号 0", "成功 0", "失败 0", "新增 0", "更新 0", + "跳过(无凭证) 0", "跳过(无适配器) 0"): + assert fragment in out, f"摘要缺少 {fragment}:{out!r}" + + +def test_script_exits_zero_when_nothing_failed(script, monkeypatch): + fake = { + "total": 2, "success": 2, "failed": 0, "created": 3, "updated": 1, + "skipped_no_config": 0, "skipped_no_adapter": 0, "errors": [], + "synced_at": "2026-01-01T00:00:00", + } + monkeypatch.setattr(script.sync_service, "sync_all_assets", lambda s, **kw: fake) + assert script.main() == 0 + + +def test_script_exits_nonzero_when_any_account_failed(script, capsys, monkeypatch): + """退出码是唯一的告警通道:返回 0 的话 systemd 记成功,凭证过期也没人知道""" + fake = { + "total": 2, "success": 0, "failed": 2, "created": 0, "updated": 0, + "skipped_no_config": 0, "skipped_no_adapter": 0, + "errors": ["vultr/main: HTTP 401:凭证无效或已过期", + "aliyun/backup: HTTP 403:凭证权限不足"], + "synced_at": "2026-01-01T00:00:00", + } + monkeypatch.setattr(script.sync_service, "sync_all_assets", lambda s, **kw: fake) + assert script.main() == 1 + out = capsys.readouterr().out + assert "失败 2" in out + for err in fake["errors"]: + assert err in out, f"错误明细没打出来,journalctl 里查不到原因:{err}" + + +def test_script_uses_session_bound_to_assets_engine(script, monkeypatch): + """main() 传给服务层的 Session 必须开在 assets_engine 上 + + 写错成 metrics_engine 的话,账号表查不到 -> total=0 -> 退出码 0, + 表现是「同步一切正常,但一条数据都没进来」。 + """ + seen = {} + + def _fake(session, **kwargs): + seen["bind"] = session.get_bind() + return { + "total": 0, "success": 0, "failed": 0, "created": 0, "updated": 0, + "skipped_no_config": 0, "skipped_no_adapter": 0, "errors": [], + "synced_at": "2026-01-01T00:00:00", + } + + monkeypatch.setattr(script.sync_service, "sync_all_assets", _fake) + script.main() + assert seen["bind"] is script.assets_engine + + +# --------------------------------------------------------------------------- # +# 3. systemd 单元接线 +# --------------------------------------------------------------------------- # +def _read(name): + return (DEPLOY_DIR / name).read_text(encoding="utf-8") + + +def test_sync_assets_service_points_at_an_existing_script(): + """ExecStart 指向的脚本必须真实存在 + + unit 里是部署机绝对路径 /opt/vps-manager/...,本地按仓库相对路径校验。 + 脚本改名后 timer 每晚会失败,但因为是 oneshot + 无人看日志,等同于静默停摆。 + """ + m = re.search(r"^ExecStart=(?P\S+)\s+(?P