316 lines
11 KiB
Python
316 lines
11 KiB
Python
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}
|