from __future__ import annotations import json import time from collections.abc import Mapping, Sequence from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import urlencode from urllib.request import Request, urlopen from .models import ( AvailabilityZone, ListVMsResponse, RemoteConsole, RemoteConsoleRequest, VMCreateRequest, VMDeletePasswordRequest, VMDeleteRequest, VMPasswordRequest, VMPowerRequest, VMRebuildRequest, VMStateRequest, VMUpdateRequest, VM, ListSubnetsResponse, Subnet, SubnetCreateRequest, ) class VMAPIError(RuntimeError): """Cloud.ru Virtual Machines API request failed.""" def __init__( self, status: int | None, message: str, details: Any = None, *, content_type: str | None = None, ) -> None: super().__init__(message) self.status = status self.details = details self.content_type = content_type class VMManager: """Client for documented Cloud.ru Evolution Virtual Machines API methods.""" DEFAULT_BASE_URL = "https://compute.api.cloud.ru" def __init__(self, token: str, base_url: str = DEFAULT_BASE_URL, timeout: float = 30) -> None: if not token: raise ValueError("token must not be empty") if timeout <= 0: raise ValueError("timeout must be positive") self._token = token self._base_url = base_url.rstrip("/") self._timeout = timeout def list_vms( self, project_id: str, *, vm_ids: Sequence[str] | None = None, statuses: Sequence[str] | None = None, offset: int | None = None, limit: int | None = None, ) -> ListVMsResponse: return ListVMsResponse.model_validate(self._request( "GET", "/api/v1/vms", query=self._without_none( project_id=project_id, vm_ids=vm_ids, statuses=statuses, offset=offset, limit=limit, ), )) def get_vm(self, vm_id: str) -> VM: return VM.model_validate(self._request("GET", f"/api/v1/vms/{vm_id}")) def create_vm(self, vm: VMCreateRequest) -> list[VM]: """Create VM from documented `/api/v1/vms` request payload.""" response = self._request( "POST", "/api/v1/vms", body=[vm.model_dump(exclude_none=True)] ) return [VM.model_validate(item) for item in self._require_list(response)] def create_vms(self, vms: Sequence[VMCreateRequest]) -> list[VM]: """Create one or more VMs using the documented batch endpoint.""" response = self._request( "POST", "/api/v1/vms", body=[vm.model_dump(exclude_none=True) for vm in vms], ) return [VM.model_validate(item) for item in self._require_list(response)] def wait_for_vms_ready( self, vms: Sequence[VM], *, timeout: float = 600, poll_interval: float = 5, ) -> list[VM]: if timeout <= 0 or poll_interval <= 0: raise ValueError("timeout and poll_interval must be positive") pending = {vm.id for vm in vms} latest = {vm.id: vm for vm in vms} deadline = time.monotonic() + timeout while pending: for vm_id in tuple(pending): vm = self.get_vm(vm_id) latest[vm_id] = vm if vm.state == "running": pending.remove(vm_id) elif vm.state in {"error", "error_creating", "deleting", "deleted"}: raise VMAPIError(None, f"VM {vm_id} entered terminal state {vm.state!r}") if pending: if time.monotonic() >= deadline: raise TimeoutError(f"timed out waiting for VMs: {', '.join(sorted(pending))}") time.sleep(poll_interval) return [latest[vm.id] for vm in vms] def delete_game_vms( self, game_id: str, project_id: str, *, timeout: float = 600, poll_interval: float = 5, ) -> None: """Delete VMs created by this consumer for one game and wait for removal.""" prefix = f"game-{game_id}-" vms = [vm for vm in self.list_vms(project_id).items if vm.name.startswith(prefix)] for vm in vms: self.delete_vm(vm.id) pending = {vm.id for vm in vms} deadline = time.monotonic() + timeout while pending: for vm_id in tuple(pending): try: self.get_vm(vm_id) except VMAPIError as error: if error.status == 404: pending.remove(vm_id) else: raise if pending: if time.monotonic() >= deadline: raise TimeoutError(f"timed out waiting for VMs to delete: {', '.join(sorted(pending))}") time.sleep(poll_interval) def update_vm(self, vm_id: str, changes: VMUpdateRequest) -> VM: return VM.model_validate(self._request( "PUT", f"/api/v1/vms/{vm_id}", body=changes.model_dump(exclude_none=True) )) def delete_vm( self, vm_id: str, request: VMDeleteRequest | None = None ) -> None: self._request( "DELETE", f"/api/v1/vms/{vm_id}", body=request.model_dump(exclude_none=True) if request else None, ) def set_state(self, request: VMStateRequest) -> None: self._request("PUT", "/api/v1/vms", body=[request.model_dump()]) def start_vm(self, vm_id: str) -> None: self.set_state(VMStateRequest(id=vm_id, state="running")) def stop_vm(self, vm_id: str) -> None: self.set_state(VMStateRequest(id=vm_id, state="stopped")) def set_power(self, vm_id: str, request: VMPowerRequest) -> None: self._request("POST", f"/api/v1/vms/{vm_id}/set-power", body=request.model_dump()) def rebuild_vm(self, vm_id: str, request: VMRebuildRequest) -> VM: return VM.model_validate(self._request( "POST", f"/api/v1/vms/{vm_id}/rebuild", body=request.model_dump(exclude_none=True) )) def get_vnc(self, vm_id: str) -> RemoteConsole: return RemoteConsole.model_validate(self._request("POST", f"/api/v1/vms/{vm_id}/get-vnc")) def create_remote_console(self, vm_id: str, request: RemoteConsoleRequest) -> RemoteConsole: return RemoteConsole.model_validate(self._request( "POST", f"/api/v1/vms/{vm_id}/remote-console", body=request.model_dump() )) def set_password(self, vm_id: str, request: VMPasswordRequest) -> None: self._request("POST", f"/api/v1/vms/{vm_id}/set-password", body=request.model_dump()) def delete_password(self, vm_id: str, request: VMDeletePasswordRequest | None = None) -> None: self._request( "POST", f"/api/v1/vms/{vm_id}/delete-password", body=request.model_dump(exclude_none=True) if request else None, ) def check_guest_agent(self, vm_id: str) -> None: self._request("POST", f"/api/v1/vms/{vm_id}/check-guest-agent") def list_availability_zones(self) -> list[AvailabilityZone]: """List availability zones from documented `/api/v1/availability-zones`.""" payload = self._request("GET", "/api/v1/availability-zones") return [AvailabilityZone.model_validate(item) for item in self._require_list(payload)] def list_subnets(self, project_id: str, *, subnet_address: str | None = None) -> ListSubnetsResponse: return ListSubnetsResponse.model_validate(self._request( "GET", "/api/v1/subnets", query=self._without_none( project_id=project_id, subnet_address=subnet_address ) )) def get_subnet(self, subnet_id: str) -> Subnet: return Subnet.model_validate(self._request("GET", f"/api/v1/subnets/{subnet_id}")) def create_subnet(self, request: SubnetCreateRequest) -> Subnet: return Subnet.model_validate(self._request( "POST", "/api/v1/subnets", body=request.model_dump(exclude_none=True) )) def _request( self, method: str, path: str, *, query: Mapping[str, Any] | None = None, body: Mapping[str, Any] | Sequence[Mapping[str, Any]] | None = None, ) -> Any: url = f"{self._base_url}{path}" if query: url = f"{url}?{urlencode(query, doseq=True)}" data = json.dumps(body).encode() if body is not None else None request = Request( url, data=data, method=method, headers={ "Authorization": f"Bearer {self._token}", "Accept": "application/json", "Content-Type": "application/json", }, ) try: with urlopen(request, timeout=self._timeout) as response: return self._decode_response(response.read()) except HTTPError as error: raw_payload = error.read() payload = self._decode_error_response(raw_payload) message = self._error_message(payload, error.reason) raise VMAPIError( error.code, message, payload, content_type=error.headers.get_content_type(), ) from error except URLError as error: raise VMAPIError(None, str(error.reason)) from error @staticmethod def _decode_response(data: bytes) -> Any: if not data: return {} try: payload = json.loads(data) except json.JSONDecodeError as error: raise VMAPIError(None, "API returned invalid JSON") from error return payload @staticmethod def _decode_error_response(data: bytes) -> Any: if not data: return None try: return json.loads(data) except json.JSONDecodeError: text = data.decode("utf-8", errors="replace").strip() return {"raw_response": text[:512]} @staticmethod def _error_message(payload: Any, fallback: str) -> str: if isinstance(payload, list): messages = [item.get("message") for item in payload if isinstance(item, dict)] if messages: return "; ".join(message for message in messages if isinstance(message, str)) if not isinstance(payload, dict): return fallback if isinstance(payload.get("raw_response"), str): return f"{fallback}: {payload['raw_response']}" message = payload.get("message") if isinstance(message, str): return message if isinstance(payload.get("detail"), str): return payload["detail"] return fallback @staticmethod def _require_list(payload: Any) -> list[dict[str, Any]]: if not isinstance(payload, list) or not all(isinstance(item, dict) for item in payload): raise VMAPIError(None, "API returned non-array VM response") return payload @staticmethod def _without_none(**values: Any) -> dict[str, Any]: return {key: value for key, value in values.items() if value is not None}