Files
kernel/src/cloud/vm_manager.py
2026-08-12 21:11:39 +03:00

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}