intelion-cloud 0.2.3__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/PKG-INFO +1 -1
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud/__init__.py +5 -1
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud/models/__init__.py +4 -1
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud/models/components.py +3 -0
- intelion_cloud-0.4.0/intelion_cloud/models/flavors.py +98 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud/models/servers.py +38 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud/resources/cloud_servers.py +16 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud.egg-info/PKG-INFO +1 -1
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud.egg-info/SOURCES.txt +1 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/pyproject.toml +1 -1
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/tests/test_cloud_servers.py +10 -0
- intelion_cloud-0.4.0/tests/test_queue_and_rotation_fields.py +157 -0
- intelion_cloud-0.2.3/intelion_cloud/models/flavors.py +0 -44
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/LICENSE +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/README.md +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud/_client.py +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud/_pagination.py +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud/_transport.py +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud/constants.py +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud/exceptions.py +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud/models/_base.py +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud/models/users.py +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud/resources/__init__.py +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud/resources/_base.py +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud/resources/flavors.py +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud/resources/os_images.py +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud/resources/users.py +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud.egg-info/dependency_links.txt +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud.egg-info/requires.txt +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/intelion_cloud.egg-info/top_level.txt +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/setup.cfg +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/tests/test_client.py +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/tests/test_flavors.py +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/tests/test_models.py +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/tests/test_transport.py +0 -0
- {intelion_cloud-0.2.3 → intelion_cloud-0.4.0}/tests/test_users.py +0 -0
|
@@ -27,11 +27,13 @@ from .models import (
|
|
|
27
27
|
CPU,
|
|
28
28
|
GPU,
|
|
29
29
|
OSImage,
|
|
30
|
+
PasswordRotation,
|
|
30
31
|
RAM,
|
|
31
32
|
SSD,
|
|
32
33
|
CloudServer,
|
|
33
34
|
DebtInfo,
|
|
34
35
|
Flavor,
|
|
36
|
+
FlavorSubstitution,
|
|
35
37
|
PhysicalServer,
|
|
36
38
|
Promocode,
|
|
37
39
|
ServerStatus,
|
|
@@ -41,7 +43,7 @@ from .models import (
|
|
|
41
43
|
WhiteIP,
|
|
42
44
|
)
|
|
43
45
|
|
|
44
|
-
__version__ = "0.
|
|
46
|
+
__version__ = "0.4.0"
|
|
45
47
|
|
|
46
48
|
__all__ = [
|
|
47
49
|
# Clients
|
|
@@ -51,12 +53,14 @@ __all__ = [
|
|
|
51
53
|
"CloudServer",
|
|
52
54
|
"ServerStatus",
|
|
53
55
|
"Flavor",
|
|
56
|
+
"FlavorSubstitution",
|
|
54
57
|
"User",
|
|
55
58
|
"GPU",
|
|
56
59
|
"CPU",
|
|
57
60
|
"RAM",
|
|
58
61
|
"SSD",
|
|
59
62
|
"OSImage",
|
|
63
|
+
"PasswordRotation",
|
|
60
64
|
"UsageAct",
|
|
61
65
|
"DebtInfo",
|
|
62
66
|
"Promocode",
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"""Data models for the Intelion Cloud API."""
|
|
2
2
|
|
|
3
3
|
from .components import CPU, GPU, OSImage, RAM, SSD
|
|
4
|
-
from .flavors import Flavor
|
|
4
|
+
from .flavors import Flavor, FlavorSubstitution
|
|
5
5
|
from .servers import (
|
|
6
6
|
CloudServer,
|
|
7
7
|
DebtInfo,
|
|
8
8
|
PhysicalServer,
|
|
9
|
+
PasswordRotation,
|
|
9
10
|
Promocode,
|
|
10
11
|
ServerStatus,
|
|
11
12
|
SoftwareAddonInstance,
|
|
@@ -21,9 +22,11 @@ __all__ = [
|
|
|
21
22
|
"SSD",
|
|
22
23
|
"OSImage",
|
|
23
24
|
"Flavor",
|
|
25
|
+
"FlavorSubstitution",
|
|
24
26
|
"CloudServer",
|
|
25
27
|
"DebtInfo",
|
|
26
28
|
"PhysicalServer",
|
|
29
|
+
"PasswordRotation",
|
|
27
30
|
"Promocode",
|
|
28
31
|
"ServerStatus",
|
|
29
32
|
"SoftwareAddonInstance",
|
|
@@ -204,6 +204,8 @@ class OSImage:
|
|
|
204
204
|
ssh_enabled: bool = False
|
|
205
205
|
rdp_enabled: bool = False
|
|
206
206
|
compatible_flavor_ids: Optional[List[int]] = None
|
|
207
|
+
cuda_version: Optional[str] = None
|
|
208
|
+
"""CUDA version bundled in the image (e.g. ``"13.3"``); ``None`` if not applicable."""
|
|
207
209
|
|
|
208
210
|
@classmethod
|
|
209
211
|
def from_dict(cls, data: Dict[str, Any]) -> OSImage:
|
|
@@ -215,4 +217,5 @@ class OSImage:
|
|
|
215
217
|
ssh_enabled=data.get("ssh_enabled", False),
|
|
216
218
|
rdp_enabled=data.get("rdp_enabled", False),
|
|
217
219
|
compatible_flavor_ids=data.get("compatible_flavor_ids"),
|
|
220
|
+
cuda_version=_get(data, "cuda_version"),
|
|
218
221
|
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Flavor model — server hardware configuration (FlavorConfig)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
from ._base import _get, _parse_nested
|
|
9
|
+
from .components import CPU, GPU, RAM
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class FlavorSubstitution:
|
|
14
|
+
"""A replacement flavor offered when the requested GPU is queue-disabled.
|
|
15
|
+
|
|
16
|
+
The API returns this instead of letting you queue for a card that is
|
|
17
|
+
fully leased out long-term — such a queue would never come up. Serialized
|
|
18
|
+
by ``servers.services.flavors._serialize_substitution`` on the server side.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
flavor_id: int
|
|
22
|
+
flavor_name: str
|
|
23
|
+
gpu_id: int
|
|
24
|
+
gpu_slug: str
|
|
25
|
+
gpu_name: str
|
|
26
|
+
gpu_ram: int
|
|
27
|
+
gpu_count: int
|
|
28
|
+
cpu_id: int
|
|
29
|
+
cpu_count: int
|
|
30
|
+
ram_id: int
|
|
31
|
+
ram_count: int
|
|
32
|
+
monthly_price_rub_cents: int
|
|
33
|
+
hourly_price_rub_cents: int
|
|
34
|
+
exact_match: bool = True
|
|
35
|
+
"""``False`` — no exact cpu/ram equivalent exists on the replacement card,
|
|
36
|
+
the closest one was picked. Surface this to the user rather than hiding it."""
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_dict(cls, data: Dict[str, Any]) -> FlavorSubstitution:
|
|
40
|
+
return cls(
|
|
41
|
+
flavor_id=data["flavor_id"],
|
|
42
|
+
flavor_name=data.get("flavor_name", ""),
|
|
43
|
+
gpu_id=data.get("gpu_id", 0),
|
|
44
|
+
gpu_slug=data.get("gpu_slug", ""),
|
|
45
|
+
gpu_name=data.get("gpu_name", ""),
|
|
46
|
+
gpu_ram=data.get("gpu_ram", 0),
|
|
47
|
+
gpu_count=data.get("gpu_count", 0),
|
|
48
|
+
cpu_id=data.get("cpu_id", 0),
|
|
49
|
+
cpu_count=data.get("cpu_count", 0),
|
|
50
|
+
ram_id=data.get("ram_id", 0),
|
|
51
|
+
ram_count=data.get("ram_count", 0),
|
|
52
|
+
monthly_price_rub_cents=data.get("monthly_price_rub_cents", 0),
|
|
53
|
+
hourly_price_rub_cents=data.get("hourly_price_rub_cents", 0),
|
|
54
|
+
exact_match=data.get("exact_match", True),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Flavor:
|
|
60
|
+
"""A flavor describing a server hardware configuration template (backed by FlavorConfig)."""
|
|
61
|
+
|
|
62
|
+
id: int
|
|
63
|
+
name: str
|
|
64
|
+
cpu_count: int
|
|
65
|
+
ram_count: int
|
|
66
|
+
gpu_count: Optional[int]
|
|
67
|
+
flavor_monthly_price_rub_cents: int
|
|
68
|
+
flavor_hourly_price_rub_cents: int
|
|
69
|
+
max_available: int
|
|
70
|
+
openstack_id: Optional[str] = None
|
|
71
|
+
cpu: Optional[CPU] = None
|
|
72
|
+
ram: Optional[RAM] = None
|
|
73
|
+
gpu: Optional[GPU] = None
|
|
74
|
+
queue_disabled: bool = False
|
|
75
|
+
"""The GPU is leased out long-term: queueing for it is refused (HTTP 409)."""
|
|
76
|
+
suggested_alternative: Optional[FlavorSubstitution] = None
|
|
77
|
+
"""Set only when ``queue_disabled`` and ``max_available == 0`` — otherwise ``None``."""
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_dict(cls, data: Dict[str, Any]) -> Flavor:
|
|
81
|
+
return cls(
|
|
82
|
+
id=data["id"],
|
|
83
|
+
name=data.get("name", ""),
|
|
84
|
+
cpu_count=data.get("cpu_count", 0),
|
|
85
|
+
ram_count=data.get("ram_count", 0),
|
|
86
|
+
gpu_count=_get(data, "gpu_count"),
|
|
87
|
+
flavor_monthly_price_rub_cents=data.get("flavor_monthly_price_rub_cents", 0),
|
|
88
|
+
flavor_hourly_price_rub_cents=data.get("flavor_hourly_price_rub_cents", 0),
|
|
89
|
+
max_available=data.get("max_available", 0),
|
|
90
|
+
openstack_id=_get(data, "openstack_id"),
|
|
91
|
+
cpu=_parse_nested(data, "cpu", CPU),
|
|
92
|
+
ram=_parse_nested(data, "ram", RAM),
|
|
93
|
+
gpu=_parse_nested(data, "gpu", GPU),
|
|
94
|
+
queue_disabled=bool(data.get("queue_disabled", False)),
|
|
95
|
+
suggested_alternative=_parse_nested(
|
|
96
|
+
data, "suggested_alternative", FlavorSubstitution
|
|
97
|
+
),
|
|
98
|
+
)
|
|
@@ -7,6 +7,30 @@ from typing import Any, Dict, List, Optional
|
|
|
7
7
|
|
|
8
8
|
from ._base import _get, _parse_nested, _parse_nested_list
|
|
9
9
|
from .components import CPU, GPU, OSImage, RAM, SSD
|
|
10
|
+
from .flavors import FlavorSubstitution
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class PasswordRotation:
|
|
15
|
+
"""Root password rotation notice for a server.
|
|
16
|
+
|
|
17
|
+
Present only as an accomplished fact: a pending rotation is deliberately
|
|
18
|
+
not exposed by the API. ``acknowledged`` is kept server-side so the notice
|
|
19
|
+
is not shown again from a different client.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
status: str
|
|
23
|
+
"""``"rotated"`` or ``"failed"``."""
|
|
24
|
+
rotated_at: Optional[str] = None
|
|
25
|
+
acknowledged: bool = False
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_dict(cls, data: Dict[str, Any]) -> PasswordRotation:
|
|
29
|
+
return cls(
|
|
30
|
+
status=data.get("status", ""),
|
|
31
|
+
rotated_at=_get(data, "rotated_at"),
|
|
32
|
+
acknowledged=data.get("acknowledged", False),
|
|
33
|
+
)
|
|
10
34
|
|
|
11
35
|
|
|
12
36
|
@dataclass(frozen=True)
|
|
@@ -188,6 +212,14 @@ class CloudServer:
|
|
|
188
212
|
total_network_disk_size: int = 0
|
|
189
213
|
total_local_disk_size: int = 0
|
|
190
214
|
max_available: int = 0
|
|
215
|
+
queue_disabled: bool = False
|
|
216
|
+
"""This server's GPU is leased out long-term — queueing for it is refused."""
|
|
217
|
+
suggested_alternative: Optional[FlavorSubstitution] = None
|
|
218
|
+
"""Replacement card offered when ``queue_disabled`` and capacity is zero."""
|
|
219
|
+
password_rotation: Optional[PasswordRotation] = None
|
|
220
|
+
"""Set when the root password was rotated (or the rotation failed)."""
|
|
221
|
+
maintenance_reason: Optional[str] = None
|
|
222
|
+
"""Customer-facing maintenance reason; ``None`` means the default wording."""
|
|
191
223
|
|
|
192
224
|
@classmethod
|
|
193
225
|
def from_dict(cls, data: Dict[str, Any]) -> CloudServer:
|
|
@@ -231,6 +263,12 @@ class CloudServer:
|
|
|
231
263
|
total_network_disk_size=data.get("total_network_disk_size", 0),
|
|
232
264
|
total_local_disk_size=data.get("total_local_disk_size", 0),
|
|
233
265
|
max_available=data.get("max_available", 0),
|
|
266
|
+
queue_disabled=bool(data.get("queue_disabled", False)),
|
|
267
|
+
suggested_alternative=_parse_nested(
|
|
268
|
+
data, "suggested_alternative", FlavorSubstitution
|
|
269
|
+
),
|
|
270
|
+
password_rotation=_parse_nested(data, "password_rotation", PasswordRotation),
|
|
271
|
+
maintenance_reason=_get(data, "maintenance_reason"),
|
|
234
272
|
)
|
|
235
273
|
|
|
236
274
|
|
|
@@ -137,6 +137,17 @@ class CloudServers(SyncResource):
|
|
|
137
137
|
data = self._post(f"{_PATH}{server_id}/actions/", json={"status": -3})
|
|
138
138
|
return CloudServer.from_dict(data)
|
|
139
139
|
|
|
140
|
+
def reinstall_os(self, server_id: int) -> CloudServer:
|
|
141
|
+
"""Reinstall the OS on a server, wiping the boot disk.
|
|
142
|
+
|
|
143
|
+
All data on the boot disk is destroyed. The current monthly billing
|
|
144
|
+
cycle is preserved (the open UsageAct is reused). A new IP and root
|
|
145
|
+
password are generated and become available in the user panel a few
|
|
146
|
+
minutes after the call returns.
|
|
147
|
+
"""
|
|
148
|
+
data = self._post(f"{_PATH}{server_id}/reinstall-os/")
|
|
149
|
+
return CloudServer.from_dict(data)
|
|
150
|
+
|
|
140
151
|
# --- Info endpoints ---
|
|
141
152
|
|
|
142
153
|
def get_status(self, server_id: int) -> ServerStatus:
|
|
@@ -264,6 +275,11 @@ class AsyncCloudServers(AsyncResource):
|
|
|
264
275
|
data = await self._post(f"{_PATH}{server_id}/actions/", json={"status": -3})
|
|
265
276
|
return CloudServer.from_dict(data)
|
|
266
277
|
|
|
278
|
+
async def reinstall_os(self, server_id: int) -> CloudServer:
|
|
279
|
+
"""Reinstall the OS on a server, wiping the boot disk."""
|
|
280
|
+
data = await self._post(f"{_PATH}{server_id}/reinstall-os/")
|
|
281
|
+
return CloudServer.from_dict(data)
|
|
282
|
+
|
|
267
283
|
async def get_status(self, server_id: int) -> ServerStatus:
|
|
268
284
|
data = await self._get(f"{_PATH}{server_id}/status/")
|
|
269
285
|
return ServerStatus.from_dict(data)
|
|
@@ -190,6 +190,16 @@ class TestCloudServersActions:
|
|
|
190
190
|
assert server.status == -3
|
|
191
191
|
client.close()
|
|
192
192
|
|
|
193
|
+
@respx.mock(base_url=API_URL)
|
|
194
|
+
def test_reinstall_os(self, respx_mock):
|
|
195
|
+
respx_mock.post("cloud-servers/42/reinstall-os/").respond(
|
|
196
|
+
200, json=SAMPLE_CLOUD_SERVER
|
|
197
|
+
)
|
|
198
|
+
client = IntelionCloud(token="tok", base_url=BASE_URL)
|
|
199
|
+
server = client.cloud_servers.reinstall_os(42)
|
|
200
|
+
assert server.id == SAMPLE_CLOUD_SERVER["id"]
|
|
201
|
+
client.close()
|
|
202
|
+
|
|
193
203
|
@respx.mock(base_url=API_URL)
|
|
194
204
|
def test_start_with_auto_renewal(self, respx_mock):
|
|
195
205
|
respx_mock.post("cloud-servers/42/actions/").respond(
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Fields the API v2 serializers return but the SDK used to drop silently.
|
|
2
|
+
|
|
3
|
+
Server-side sources:
|
|
4
|
+
* ``queue_disabled`` / ``suggested_alternative`` — ``FlavorConfigSerializer``
|
|
5
|
+
and ``UserConfigurationSerializer`` (PR #526);
|
|
6
|
+
* ``password_rotation`` — ``UserConfigurationSerializer`` (incident 2026-07-29);
|
|
7
|
+
* ``maintenance_reason`` — ``UserConfiguration`` model field;
|
|
8
|
+
* ``cuda_version`` — ``OperationalSystemImageSerializer``.
|
|
9
|
+
|
|
10
|
+
Note the shapes are asserted against the real serializers, not against the
|
|
11
|
+
published OpenAPI schema: drf-spectacular types every ``SerializerMethodField``
|
|
12
|
+
as ``string``, so the schema claims ``queue_disabled`` is a string and
|
|
13
|
+
``suggested_alternative``/``password_rotation`` are strings too, while they are
|
|
14
|
+
in fact a bool and two nested objects.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from intelion_cloud.models import CloudServer, Flavor, FlavorSubstitution, OSImage
|
|
18
|
+
|
|
19
|
+
SUBSTITUTION = {
|
|
20
|
+
"flavor_id": 42,
|
|
21
|
+
"flavor_name": "RTX 4090 x1",
|
|
22
|
+
"gpu_id": 7,
|
|
23
|
+
"gpu_slug": "nvidia-rtx-4090",
|
|
24
|
+
"gpu_name": "NVIDIA RTX 4090",
|
|
25
|
+
"gpu_ram": 24,
|
|
26
|
+
"gpu_count": 1,
|
|
27
|
+
"cpu_id": 3,
|
|
28
|
+
"cpu_count": 8,
|
|
29
|
+
"ram_id": 5,
|
|
30
|
+
"ram_count": 32,
|
|
31
|
+
"monthly_price_rub_cents": 4500000,
|
|
32
|
+
"hourly_price_rub_cents": 6800,
|
|
33
|
+
"exact_match": False,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestFlavorSubstitution:
|
|
38
|
+
def test_from_dict(self):
|
|
39
|
+
sub = FlavorSubstitution.from_dict(SUBSTITUTION)
|
|
40
|
+
assert sub.flavor_id == 42
|
|
41
|
+
assert sub.gpu_slug == "nvidia-rtx-4090"
|
|
42
|
+
assert sub.monthly_price_rub_cents == 4500000
|
|
43
|
+
assert sub.exact_match is False
|
|
44
|
+
|
|
45
|
+
def test_exact_match_defaults_to_true(self):
|
|
46
|
+
payload = {k: v for k, v in SUBSTITUTION.items() if k != "exact_match"}
|
|
47
|
+
assert FlavorSubstitution.from_dict(payload).exact_match is True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestFlavorQueueFields:
|
|
51
|
+
def test_queue_disabled_with_alternative(self):
|
|
52
|
+
flavor = Flavor.from_dict({
|
|
53
|
+
"id": 1,
|
|
54
|
+
"name": "H100 x8",
|
|
55
|
+
"cpu_count": 64,
|
|
56
|
+
"ram_count": 512,
|
|
57
|
+
"gpu_count": 8,
|
|
58
|
+
"flavor_monthly_price_rub_cents": 100,
|
|
59
|
+
"flavor_hourly_price_rub_cents": 1,
|
|
60
|
+
"max_available": 0,
|
|
61
|
+
"queue_disabled": True,
|
|
62
|
+
"suggested_alternative": SUBSTITUTION,
|
|
63
|
+
})
|
|
64
|
+
assert flavor.queue_disabled is True
|
|
65
|
+
assert flavor.suggested_alternative is not None
|
|
66
|
+
assert flavor.suggested_alternative.flavor_name == "RTX 4090 x1"
|
|
67
|
+
|
|
68
|
+
def test_absent_fields_do_not_break_old_payloads(self):
|
|
69
|
+
"""Старый сервер (или урезанный ответ) не должен ронять парсинг."""
|
|
70
|
+
flavor = Flavor.from_dict({
|
|
71
|
+
"id": 1,
|
|
72
|
+
"name": "A100 x1",
|
|
73
|
+
"cpu_count": 8,
|
|
74
|
+
"ram_count": 64,
|
|
75
|
+
"gpu_count": 1,
|
|
76
|
+
"flavor_monthly_price_rub_cents": 100,
|
|
77
|
+
"flavor_hourly_price_rub_cents": 1,
|
|
78
|
+
"max_available": 3,
|
|
79
|
+
})
|
|
80
|
+
assert flavor.queue_disabled is False
|
|
81
|
+
assert flavor.suggested_alternative is None
|
|
82
|
+
|
|
83
|
+
def test_null_alternative_stays_none(self):
|
|
84
|
+
flavor = Flavor.from_dict({
|
|
85
|
+
"id": 1,
|
|
86
|
+
"name": "A100 x1",
|
|
87
|
+
"cpu_count": 8,
|
|
88
|
+
"ram_count": 64,
|
|
89
|
+
"gpu_count": 1,
|
|
90
|
+
"flavor_monthly_price_rub_cents": 100,
|
|
91
|
+
"flavor_hourly_price_rub_cents": 1,
|
|
92
|
+
"max_available": 3,
|
|
93
|
+
"queue_disabled": True,
|
|
94
|
+
"suggested_alternative": None,
|
|
95
|
+
})
|
|
96
|
+
assert flavor.queue_disabled is True
|
|
97
|
+
assert flavor.suggested_alternative is None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _server(**extra):
|
|
101
|
+
base = {
|
|
102
|
+
"id": 8564,
|
|
103
|
+
"name": "srv",
|
|
104
|
+
"status": 1,
|
|
105
|
+
"price_plan": 1,
|
|
106
|
+
"is_auto_renewal": True,
|
|
107
|
+
"monthly_price_rub_cents": 100,
|
|
108
|
+
"hourly_price_rub_cents": 1,
|
|
109
|
+
"server_full_rent_price_rub_cents": 100,
|
|
110
|
+
}
|
|
111
|
+
base.update(extra)
|
|
112
|
+
return CloudServer.from_dict(base)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TestCloudServerNewFields:
|
|
116
|
+
def test_password_rotation_rotated(self):
|
|
117
|
+
srv = _server(password_rotation={
|
|
118
|
+
"status": "rotated",
|
|
119
|
+
"rotated_at": "2026-07-29T12:00:00+00:00",
|
|
120
|
+
"acknowledged": False,
|
|
121
|
+
})
|
|
122
|
+
assert srv.password_rotation is not None
|
|
123
|
+
assert srv.password_rotation.status == "rotated"
|
|
124
|
+
assert srv.password_rotation.rotated_at == "2026-07-29T12:00:00+00:00"
|
|
125
|
+
assert srv.password_rotation.acknowledged is False
|
|
126
|
+
|
|
127
|
+
def test_password_rotation_failed_without_timestamp(self):
|
|
128
|
+
srv = _server(password_rotation={"status": "failed", "rotated_at": None})
|
|
129
|
+
assert srv.password_rotation.status == "failed"
|
|
130
|
+
assert srv.password_rotation.rotated_at is None
|
|
131
|
+
|
|
132
|
+
def test_password_rotation_null_is_none(self):
|
|
133
|
+
assert _server(password_rotation=None).password_rotation is None
|
|
134
|
+
|
|
135
|
+
def test_maintenance_reason(self):
|
|
136
|
+
assert _server(maintenance_reason="Замена БП").maintenance_reason == "Замена БП"
|
|
137
|
+
assert _server(maintenance_reason=None).maintenance_reason is None
|
|
138
|
+
assert _server().maintenance_reason is None
|
|
139
|
+
|
|
140
|
+
def test_queue_fields(self):
|
|
141
|
+
srv = _server(queue_disabled=True, suggested_alternative=SUBSTITUTION)
|
|
142
|
+
assert srv.queue_disabled is True
|
|
143
|
+
assert srv.suggested_alternative.gpu_count == 1
|
|
144
|
+
assert _server().queue_disabled is False
|
|
145
|
+
assert _server().suggested_alternative is None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TestOSImageCudaVersion:
|
|
149
|
+
def test_cuda_version(self):
|
|
150
|
+
img = OSImage.from_dict({"id": 1, "name": "Ubuntu 24.04", "cuda_version": "13.3"})
|
|
151
|
+
assert img.cuda_version == "13.3"
|
|
152
|
+
|
|
153
|
+
def test_cuda_version_absent_or_null(self):
|
|
154
|
+
assert OSImage.from_dict({"id": 1, "name": "Windows"}).cuda_version is None
|
|
155
|
+
assert OSImage.from_dict(
|
|
156
|
+
{"id": 1, "name": "Windows", "cuda_version": None}
|
|
157
|
+
).cuda_version is None
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
"""Flavor model — server hardware configuration (FlavorConfig)."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
from typing import Any, Dict, Optional
|
|
7
|
-
|
|
8
|
-
from ._base import _get, _parse_nested
|
|
9
|
-
from .components import CPU, GPU, RAM
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
@dataclass(frozen=True)
|
|
13
|
-
class Flavor:
|
|
14
|
-
"""A flavor describing a server hardware configuration template (backed by FlavorConfig)."""
|
|
15
|
-
|
|
16
|
-
id: int
|
|
17
|
-
name: str
|
|
18
|
-
cpu_count: int
|
|
19
|
-
ram_count: int
|
|
20
|
-
gpu_count: Optional[int]
|
|
21
|
-
flavor_monthly_price_rub_cents: int
|
|
22
|
-
flavor_hourly_price_rub_cents: int
|
|
23
|
-
max_available: int
|
|
24
|
-
openstack_id: Optional[str] = None
|
|
25
|
-
cpu: Optional[CPU] = None
|
|
26
|
-
ram: Optional[RAM] = None
|
|
27
|
-
gpu: Optional[GPU] = None
|
|
28
|
-
|
|
29
|
-
@classmethod
|
|
30
|
-
def from_dict(cls, data: Dict[str, Any]) -> Flavor:
|
|
31
|
-
return cls(
|
|
32
|
-
id=data["id"],
|
|
33
|
-
name=data.get("name", ""),
|
|
34
|
-
cpu_count=data.get("cpu_count", 0),
|
|
35
|
-
ram_count=data.get("ram_count", 0),
|
|
36
|
-
gpu_count=_get(data, "gpu_count"),
|
|
37
|
-
flavor_monthly_price_rub_cents=data.get("flavor_monthly_price_rub_cents", 0),
|
|
38
|
-
flavor_hourly_price_rub_cents=data.get("flavor_hourly_price_rub_cents", 0),
|
|
39
|
-
max_available=data.get("max_available", 0),
|
|
40
|
-
openstack_id=_get(data, "openstack_id"),
|
|
41
|
-
cpu=_parse_nested(data, "cpu", CPU),
|
|
42
|
-
ram=_parse_nested(data, "ram", RAM),
|
|
43
|
-
gpu=_parse_nested(data, "gpu", GPU),
|
|
44
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|