"""DigitalOcean 适配器(REST API v2,Bearer Token) API 文档:https://docs.digitalocean.com/reference/api/ 所需配置:{"api_key": "..."} 支持功能:Droplet 清单、账户余额、域名清单、启停/重启、DNS 记录管理 DNS 的 zone_id 即域名本身(DO 用域名字符串定位)。 DO 为按小时计费的订阅制,无「续费」概念,故不实现 renew_instance。 """ import httpx from app.adapters.base import ( AccountInfo, BaseAdapter, NormalizedDNSRecord, NormalizedDomain, NormalizedVPS, parse_iso_date, ) from app.adapters.registry import register @register("do-api") class DigitalOceanAdapter(BaseAdapter): required_config = ["api_key"] BASE = "https://api.digitalocean.com/v2" 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) def test_connection(self) -> dict: try: data = self._get("/account") balance = data.get("account", {}).get("balance") return {"ok": True, "message": f"连接成功,账户余额 {balance} USD"} except httpx.HTTPStatusError as e: return {"ok": False, "message": f"HTTP {e.response.status_code}:Token 无效或权限不足"} except Exception as e: # noqa: BLE001 return {"ok": False, "message": str(e)} def get_account(self) -> AccountInfo: acc = self._get("/account").get("account", {}) return AccountInfo(balance=acc.get("balance"), currency="USD", raw=acc) @staticmethod def _main_ip(droplet: dict) -> str: try: v4 = droplet.get("networks", {}).get("v4", []) return v4[0].get("ip_address") if v4 else None except Exception: # noqa: BLE001 return None def list_vps(self) -> list: result = [] page = 1 while True: data = self._get(f"/droplets?per_page=100&page={page}") for d in data.get("droplets", []): image = d.get("image", {}) os_name = f"{image.get('distribution', '')} {image.get('name', '')}".strip() mem_mb = d.get("memory") or 0 region = d.get("region", {}) result.append( NormalizedVPS( external_id=str(d.get("id")), name=d.get("name"), ip_address=self._main_ip(d), region=region.get("slug") or region.get("name"), os=os_name or None, cpu_cores=d.get("vcpus"), 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, ) ) if not data.get("links", {}).get("pages", {}).get("next"): break page += 1 return result def list_domains(self) -> list: result = [] page = 1 while True: data = self._get(f"/domains?per_page=100&page={page}") for d in data.get("domains", []): result.append( NormalizedDomain( external_id=d.get("name"), domain_name=d.get("name"), registrar="digitalocean", # /domains 用 expiry_date,部分账号下会返回 expires_at,两者都兼容 expiry_date=parse_iso_date(d.get("expiry_date") or d.get("expires_at")), raw=d, ) ) if not data.get("links", {}).get("pages", {}).get("next"): 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}"}