little update
This commit is contained in:
315
src/cloud/vm_manager.py
Normal file
315
src/cloud/vm_manager.py
Normal file
@@ -0,0 +1,315 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user