little update
This commit is contained in:
56
src/cloud/__init__.py
Normal file
56
src/cloud/__init__.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from .vpc_network_manager import VPCAPIError, VPCNetworkManager
|
||||
from .vm_manager import VMAPIError, VMManager
|
||||
from .iam import IAMAPIError, IAMClient
|
||||
from .models import (
|
||||
AvailabilityZone,
|
||||
CreateStaticRouteRequest,
|
||||
CreateVPCRequest,
|
||||
ListStaticRoutesResponse,
|
||||
ListVMsResponse,
|
||||
ListVPCsResponse,
|
||||
Operation,
|
||||
RemoteConsoleRequest,
|
||||
RouteNextHop,
|
||||
StaticRoute,
|
||||
Subnet,
|
||||
SubnetCreateRequest,
|
||||
VM,
|
||||
VMCreateRequest,
|
||||
VMDeletePasswordRequest,
|
||||
VMDeleteRequest,
|
||||
VMPasswordRequest,
|
||||
VMPowerRequest,
|
||||
VMRebuildRequest,
|
||||
VMStateRequest,
|
||||
VMUpdateRequest,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AvailabilityZone",
|
||||
"CreateStaticRouteRequest",
|
||||
"CreateVPCRequest",
|
||||
"ListStaticRoutesResponse",
|
||||
"ListVMsResponse",
|
||||
"ListVPCsResponse",
|
||||
"Operation",
|
||||
"RemoteConsoleRequest",
|
||||
"RouteNextHop",
|
||||
"StaticRoute",
|
||||
"Subnet",
|
||||
"SubnetCreateRequest",
|
||||
"VMAPIError",
|
||||
"VM",
|
||||
"VMCreateRequest",
|
||||
"VMDeletePasswordRequest",
|
||||
"VMDeleteRequest",
|
||||
"VMManager",
|
||||
"VMPasswordRequest",
|
||||
"VMPowerRequest",
|
||||
"VMRebuildRequest",
|
||||
"VMStateRequest",
|
||||
"VMUpdateRequest",
|
||||
"VPCAPIError",
|
||||
"VPCNetworkManager",
|
||||
"IAMAPIError",
|
||||
"IAMClient",
|
||||
]
|
||||
53
src/cloud/iam.py
Normal file
53
src/cloud/iam.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
class IAMAPIError(RuntimeError):
|
||||
"""Cloud.ru IAM API request failed."""
|
||||
|
||||
|
||||
class IAMClient:
|
||||
TOKEN_URL = "https://iam.api.cloud.ru/api/v1/auth/token"
|
||||
|
||||
def __init__(self, key_id: str, secret: str, *, timeout: float = 30) -> None:
|
||||
if not key_id:
|
||||
raise ValueError("key_id must not be empty")
|
||||
if not secret:
|
||||
raise ValueError("secret must not be empty")
|
||||
self._key_id = key_id
|
||||
self._secret = secret
|
||||
self._timeout = timeout
|
||||
|
||||
def get_access_token(self) -> str:
|
||||
request = Request(
|
||||
self.TOKEN_URL,
|
||||
data=json.dumps({"keyId": self._key_id, "secret": self._secret}).encode(),
|
||||
method="POST",
|
||||
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=self._timeout) as response:
|
||||
payload = self._decode(response.read())
|
||||
except HTTPError as error:
|
||||
raise IAMAPIError(f"IAM token request failed: HTTP {error.code}") from error
|
||||
except URLError as error:
|
||||
raise IAMAPIError(f"IAM token request failed: {error.reason}") from error
|
||||
|
||||
token = payload.get("access_token")
|
||||
if not isinstance(token, str) or not token:
|
||||
raise IAMAPIError("IAM token response has no access_token")
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
def _decode(data: bytes) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(data)
|
||||
except json.JSONDecodeError as error:
|
||||
raise IAMAPIError("IAM token response is not JSON") from error
|
||||
if not isinstance(payload, dict):
|
||||
raise IAMAPIError("IAM token response is not an object")
|
||||
return payload
|
||||
254
src/cloud/models.py
Normal file
254
src/cloud/models.py
Normal file
@@ -0,0 +1,254 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class CloudRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
|
||||
class CloudResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||
|
||||
|
||||
class APIStatus(CloudResponse):
|
||||
code: int | str | None = None
|
||||
message: str | None = None
|
||||
details: list[Any] | None = None
|
||||
|
||||
|
||||
class Operation(CloudResponse):
|
||||
id: str
|
||||
resource_name: str | None = Field(default=None, alias="resourceName")
|
||||
resource_id: str | None = Field(default=None, alias="resourceId")
|
||||
done: bool | None = None
|
||||
description: str | None = None
|
||||
error: APIStatus | None = None
|
||||
|
||||
|
||||
class CreateVPCRequest(CloudRequest):
|
||||
project_id: str = Field(alias="projectId")
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class UpdateVPCRequest(CloudRequest):
|
||||
vpc_id: str = Field(alias="vpcId")
|
||||
name: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_change(self) -> "UpdateVPCRequest":
|
||||
if self.name is None and self.description is None:
|
||||
raise ValueError("name or description must be set")
|
||||
return self
|
||||
|
||||
|
||||
class VPC(CloudResponse):
|
||||
id: str
|
||||
name: str
|
||||
project_id: str = Field(alias="projectId")
|
||||
description: str | None = None
|
||||
is_default: bool | None = Field(default=None, alias="isDefault")
|
||||
|
||||
|
||||
class ListVPCsResponse(CloudResponse):
|
||||
vpcs: list[VPC] = Field(default_factory=list)
|
||||
next_page_token: str | None = Field(default=None, alias="nextPageToken")
|
||||
|
||||
|
||||
class MagicRouterNextHop(CloudRequest):
|
||||
magic_router: dict[str, str] = Field(alias="magicRouter")
|
||||
az_name: str = Field(alias="azName")
|
||||
|
||||
|
||||
class VirtualMachineNextHop(CloudRequest):
|
||||
virtual_machine: dict[str, str] = Field(alias="virtualMachine")
|
||||
interface: dict[str, str]
|
||||
|
||||
|
||||
class VIPNextHop(CloudRequest):
|
||||
vips: list[dict[str, str]] = Field(min_length=1)
|
||||
|
||||
|
||||
class RouteNextHop(CloudRequest):
|
||||
magic_router: MagicRouterNextHop | None = Field(default=None, alias="magicRouter")
|
||||
virtual_machine: VirtualMachineNextHop | None = Field(default=None, alias="virtualMachine")
|
||||
vip: VIPNextHop | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_single_target(self) -> "RouteNextHop":
|
||||
if sum(value is not None for value in (self.magic_router, self.virtual_machine, self.vip)) != 1:
|
||||
raise ValueError("exactly one next-hop target must be set")
|
||||
return self
|
||||
|
||||
|
||||
class CreateStaticRouteRequest(CloudRequest):
|
||||
vpc_id: str = Field(alias="vpcId")
|
||||
project_id: str = Field(alias="projectId")
|
||||
subnet: str
|
||||
next_hop: RouteNextHop = Field(alias="nextHop")
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class UpdateStaticRouteRequest(CloudRequest):
|
||||
vpc_id: str = Field(alias="vpcId")
|
||||
static_route_id: str = Field(alias="staticRouteId")
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class StaticRoute(CloudResponse):
|
||||
id: str | None = None
|
||||
vpc_id: str = Field(alias="vpcId")
|
||||
project_id: str = Field(alias="projectId")
|
||||
subnet: str
|
||||
next_hop: RouteNextHop = Field(alias="nextHop")
|
||||
description: str | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class ListStaticRoutesResponse(CloudResponse):
|
||||
routes: list[StaticRoute] = Field(default_factory=list)
|
||||
next_page_token: str | None = Field(default=None, alias="nextPageToken")
|
||||
total_count: int | None = Field(default=None, alias="totalCount")
|
||||
|
||||
|
||||
VMState = Literal[
|
||||
"running", "stopped", "error", "error_creating", "error_deleting", "creating",
|
||||
"starting", "stopping", "rebooting", "deleting", "updating", "rebuilding",
|
||||
]
|
||||
VMPowerState = Literal["power_on", "power_off", "reboot"]
|
||||
RemoteConsoleProtocol = Literal["vnc", "serial"]
|
||||
|
||||
|
||||
class VMCreateRequest(CloudRequest):
|
||||
project_id: str
|
||||
name: str = Field(min_length=1, max_length=64, pattern=r"^[a-zA-Z][a-zA-Z0-9.\-_]*$")
|
||||
disks: list[dict[str, Any]] = Field(min_length=1)
|
||||
availability_zone_id: str | None = None
|
||||
availability_zone_name: str | None = None
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
flavor_id: str | None = None
|
||||
flavor_name: str | None = None
|
||||
image_id: str | None = None
|
||||
image_name: str | None = None
|
||||
interfaces: list[dict[str, Any]] | None = None
|
||||
subnets: list[dict[str, Any]] | None = None
|
||||
metadata_fields: dict[str, Any] | None = None
|
||||
image_metadata: dict[str, Any] | None = None
|
||||
tag_ids: list[str] | None = None
|
||||
tag_names: list[str] | None = None
|
||||
cloud_init: str | None = None
|
||||
|
||||
|
||||
class VMUpdateRequest(CloudRequest):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
flavor_id: str | None = None
|
||||
flavor_name: str | None = None
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
tag_ids: list[str] | None = None
|
||||
tag_names: list[str] | None = None
|
||||
|
||||
|
||||
class VMDeleteRequest(CloudRequest):
|
||||
delete_attachments: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class VMStateRequest(CloudRequest):
|
||||
id: str
|
||||
state: Literal["running", "stopped"]
|
||||
|
||||
|
||||
class VMPowerRequest(CloudRequest):
|
||||
state: VMPowerState
|
||||
|
||||
|
||||
class VMRebuildRequest(CloudRequest):
|
||||
image_id: str | None = None
|
||||
image_name: str | None = None
|
||||
image_metadata: dict[str, Any] | None = None
|
||||
metadata_fields: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class RemoteConsoleRequest(CloudRequest):
|
||||
protocol: RemoteConsoleProtocol
|
||||
|
||||
|
||||
class VMPasswordRequest(CloudRequest):
|
||||
password: str = Field(min_length=8)
|
||||
|
||||
|
||||
class VMDeletePasswordRequest(CloudRequest):
|
||||
login: str | None = None
|
||||
|
||||
|
||||
class VM(CloudResponse):
|
||||
id: str
|
||||
name: str
|
||||
state: VMState | None = None
|
||||
description: str | None = None
|
||||
locked: bool | None = None
|
||||
project: dict[str, Any] | None = None
|
||||
availability_zone: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ListVMsResponse(CloudResponse):
|
||||
items: list[VM] = Field(default_factory=list)
|
||||
total: int | None = None
|
||||
offset: int | None = None
|
||||
limit: int | None = None
|
||||
|
||||
|
||||
class RemoteConsole(CloudResponse):
|
||||
remote_console_url: str | None = None
|
||||
remote_console_ws: str | None = None
|
||||
vnc_url: str | None = None
|
||||
vnc_ws: str | None = None
|
||||
|
||||
|
||||
class VNCConsole(CloudResponse):
|
||||
url: str | None = None
|
||||
ws_url: str | None = None
|
||||
|
||||
class SubnetCreateRequest(CloudRequest):
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
project_id: str
|
||||
availability_zone_id: str
|
||||
subnet_address: str | None = None
|
||||
prefix_length: int | None = Field(default=None, ge=8, le=29)
|
||||
vpc_id: str | None = None
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
routed_network: bool = True
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_address_or_prefix(self) -> "SubnetCreateRequest":
|
||||
if (self.subnet_address is None) == (self.prefix_length is None):
|
||||
raise ValueError("exactly one of subnet_address or prefix_length must be set")
|
||||
return self
|
||||
|
||||
|
||||
class Subnet(CloudResponse):
|
||||
id: str
|
||||
name: str
|
||||
subnet_address: str
|
||||
project: dict[str, Any] | None = None
|
||||
vpc_id: str | None = None
|
||||
state: str | None = None
|
||||
|
||||
|
||||
class ListSubnetsResponse(CloudResponse):
|
||||
items: list[Subnet] = Field(default_factory=list)
|
||||
total: int | None = None
|
||||
offset: int | None = None
|
||||
limit: int | None = None
|
||||
|
||||
|
||||
class AvailabilityZone(CloudResponse):
|
||||
id: str
|
||||
name: str
|
||||
display_name: str | None = None
|
||||
short_name: str | None = None
|
||||
enabled: bool | None = None
|
||||
default: bool | None = None
|
||||
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}
|
||||
180
src/cloud/vpc_network_manager.py
Normal file
180
src/cloud/vpc_network_manager.py
Normal file
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from .models import (
|
||||
CreateStaticRouteRequest,
|
||||
CreateVPCRequest,
|
||||
ListStaticRoutesResponse,
|
||||
ListVPCsResponse,
|
||||
Operation,
|
||||
RouteNextHop,
|
||||
StaticRoute,
|
||||
UpdateVPCRequest,
|
||||
VPC,
|
||||
)
|
||||
|
||||
|
||||
class VPCAPIError(RuntimeError):
|
||||
"""Cloud.ru Evolution VPC API request failed."""
|
||||
|
||||
def __init__(self, status: int | None, message: str, details: Any = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.details = details
|
||||
|
||||
|
||||
class VPCNetworkManager:
|
||||
"""Client for documented Cloud.ru Evolution VPC API operations."""
|
||||
|
||||
DEFAULT_BASE_URL = "https://vpc.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_vpcs(
|
||||
self,
|
||||
project_id: str,
|
||||
*,
|
||||
page_size: int | None = None,
|
||||
page_token: str | None = None,
|
||||
filter: str | None = None,
|
||||
) -> ListVPCsResponse:
|
||||
return ListVPCsResponse.model_validate(self._request(
|
||||
"GET",
|
||||
"/v1/vpcs",
|
||||
query=self._without_none(
|
||||
projectId=project_id,
|
||||
pageSize=page_size,
|
||||
pageToken=page_token,
|
||||
filter=filter,
|
||||
),
|
||||
))
|
||||
|
||||
def get_vpc(self, vpc_id: str) -> VPC:
|
||||
return VPC.model_validate(self._request("GET", f"/v1/vpcs/{vpc_id}"))
|
||||
|
||||
def create_vpc(
|
||||
self, request: CreateVPCRequest
|
||||
) -> Operation:
|
||||
return Operation.model_validate(self._request(
|
||||
"POST",
|
||||
"/v1/vpcs",
|
||||
body=request.model_dump(by_alias=True, exclude_none=True),
|
||||
))
|
||||
|
||||
def update_vpc(self, request: UpdateVPCRequest) -> Operation:
|
||||
return Operation.model_validate(self._request(
|
||||
"PUT",
|
||||
f"/v1/vpcs/{request.vpc_id}",
|
||||
body=request.model_dump(by_alias=True, exclude_none=True),
|
||||
))
|
||||
|
||||
def delete_vpc(self, vpc_id: str) -> Operation:
|
||||
return Operation.model_validate(self._request("DELETE", f"/v1/vpcs/{vpc_id}"))
|
||||
|
||||
def get_operation(self, operation_id: str) -> Operation:
|
||||
return Operation.model_validate(self._request("GET", f"/v1/vpcs/operations/{operation_id}"))
|
||||
|
||||
def list_static_routes(
|
||||
self,
|
||||
vpc_id: str,
|
||||
*,
|
||||
page_size: int | None = None,
|
||||
page_token: str | None = None,
|
||||
filter: str | None = None,
|
||||
) -> ListStaticRoutesResponse:
|
||||
return ListStaticRoutesResponse.model_validate(self._request(
|
||||
"GET",
|
||||
f"/v1/vpcs/{vpc_id}/routes/static",
|
||||
query=self._without_none(
|
||||
pageSize=page_size, pageToken=page_token, filter=filter
|
||||
),
|
||||
))
|
||||
|
||||
def get_static_route(self, vpc_id: str, static_route_id: str) -> StaticRoute:
|
||||
return StaticRoute.model_validate(self._request(
|
||||
"GET", f"/v1/vpcs/{vpc_id}/routes/static/{static_route_id}"
|
||||
))
|
||||
|
||||
def create_static_route(
|
||||
self,
|
||||
vpc_id: str,
|
||||
request: CreateStaticRouteRequest,
|
||||
) -> Operation:
|
||||
if request.vpc_id != vpc_id:
|
||||
raise ValueError("vpc_id must match request.vpc_id")
|
||||
return Operation.model_validate(self._request(
|
||||
"POST",
|
||||
f"/v1/vpcs/{vpc_id}/routes/static",
|
||||
body=request.model_dump(by_alias=True, exclude_none=True),
|
||||
))
|
||||
|
||||
def delete_static_route(self, vpc_id: str, static_route_id: str) -> Operation:
|
||||
return Operation.model_validate(self._request(
|
||||
"DELETE", f"/v1/vpcs/{vpc_id}/routes/static/{static_route_id}"
|
||||
))
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
query: Mapping[str, Any] | None = None,
|
||||
body: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
url = f"{self._base_url}{path}"
|
||||
if query:
|
||||
url = f"{url}?{urlencode(query)}"
|
||||
|
||||
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:
|
||||
payload = self._decode_response(error.read())
|
||||
raise VPCAPIError(
|
||||
error.code,
|
||||
str(payload.get("message", error.reason)),
|
||||
payload.get("details"),
|
||||
) from error
|
||||
except URLError as error:
|
||||
raise VPCAPIError(None, str(error.reason)) from error
|
||||
|
||||
@staticmethod
|
||||
def _decode_response(data: bytes) -> dict[str, Any]:
|
||||
if not data:
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(data)
|
||||
except json.JSONDecodeError as error:
|
||||
raise VPCAPIError(None, "API returned invalid JSON") from error
|
||||
if not isinstance(payload, dict):
|
||||
raise VPCAPIError(None, "API returned non-object JSON")
|
||||
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