"""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}"}