Skip to content

Commit a69239a

Browse files
huksleyRuslan Gainutdinov
andauthored
feat: on_spot_discontinue on os_volume and delete_permanently on instance action (#74)
* feat: add on_spot_discontinue on os_volume and delete_permanently on instance action * fix: tests * fix: reformat * fix: formatting * fix: add test for spot * fix: format * fix: copilot review fixes * fix: fix * fix: simpler check --------- Co-authored-by: Ruslan Gainutdinov <ruslan@datacrunch.io>
1 parent 4bd8212 commit a69239a

File tree

5 files changed

+187
-16
lines changed

5 files changed

+187
-16
lines changed
Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import logging
12
import os
3+
import time
24

35
import pytest
46

@@ -7,6 +9,9 @@
79

810
IN_GITHUB_ACTIONS = os.getenv('GITHUB_ACTIONS') == 'true'
911

12+
logging.basicConfig(level=logging.DEBUG)
13+
logger = logging.getLogger()
14+
1015

1116
@pytest.mark.skipif(IN_GITHUB_ACTIONS, reason="Test doesn't work in Github Actions.")
1217
@pytest.mark.withoutresponses
@@ -19,20 +24,37 @@ def test_create_instance(self, verda_client: VerdaClient):
1924
instance = verda_client.instances.create(
2025
hostname='test-instance',
2126
location=Locations.FIN_03,
22-
instance_type='CPU.4V',
23-
description='test instance',
24-
image='ubuntu-18.04',
27+
instance_type='CPU.4V.16G',
28+
description='test cpu instance',
29+
image='ubuntu-22.04',
2530
ssh_key_ids=[ssh_key.id],
31+
os_volume={'name': 'test-os-volume-cpu', 'size': 55},
2632
)
2733

2834
# assert instance is created
2935
assert instance.id is not None
3036
assert instance.status == verda_client.constants.instance_status.PROVISIONING
3137

32-
# delete instance
33-
verda_client.instances.action(instance.id, 'delete')
38+
while instance.status != verda_client.constants.instance_status.RUNNING:
39+
time.sleep(2)
40+
logger.debug('Waiting for instance to be running... %s', instance.status)
41+
instance = verda_client.instances.get_by_id(instance.id)
42+
43+
logger.debug('Instance is running... %s', instance.status)
44+
logger.debug('Instance ID: %s', instance.id)
45+
logger.debug('Instance OS Volume ID: %s', instance.os_volume_id)
46+
logger.debug('Instance IP: %s', instance.ip)
47+
48+
# assert os volume is created
49+
assert instance.os_volume_id is not None
3450

35-
# permanently delete all volumes in trash
36-
trash = verda_client.volumes.get_in_trash()
37-
for volume in trash:
38-
verda_client.volumes.delete(volume.id, is_permanent=True)
51+
# get os volume
52+
os_volume = verda_client.volumes.get_by_id(instance.os_volume_id)
53+
assert os_volume.id is not None
54+
assert os_volume.name == 'test-os-volume-cpu'
55+
assert os_volume.size == 55
56+
57+
# delete instance
58+
verda_client.instances.action(
59+
instance.id, 'delete', volume_ids=[instance.os_volume_id], delete_permanently=True
60+
)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import logging
2+
import os
3+
import time
4+
5+
import pytest
6+
7+
from verda import VerdaClient
8+
from verda.constants import Locations
9+
from verda.instances import OSVolume
10+
11+
IN_GITHUB_ACTIONS = os.getenv('GITHUB_ACTIONS') == 'true'
12+
13+
logging.basicConfig(level=logging.DEBUG)
14+
logger = logging.getLogger()
15+
16+
17+
@pytest.mark.skipif(IN_GITHUB_ACTIONS, reason="Test doesn't work in Github Actions.")
18+
@pytest.mark.withoutresponses
19+
class TestInstances:
20+
def test_create_spot(self, verda_client: VerdaClient):
21+
# get ssh key
22+
ssh_key = verda_client.ssh_keys.get()[0]
23+
24+
# create instance
25+
instance = verda_client.instances.create(
26+
hostname='test-instance',
27+
location=Locations.FIN_03,
28+
instance_type='CPU.4V.16G',
29+
description='test cpu instance',
30+
image='ubuntu-22.04',
31+
is_spot=True,
32+
ssh_key_ids=[ssh_key.id],
33+
os_volume=OSVolume(
34+
name='test-os-volume-spot', size=56, on_spot_discontinue='delete_permanently'
35+
),
36+
)
37+
38+
# assert instance is created
39+
assert instance.id is not None
40+
assert instance.status == verda_client.constants.instance_status.PROVISIONING
41+
42+
while instance.status != verda_client.constants.instance_status.RUNNING:
43+
time.sleep(2)
44+
logger.debug('Waiting for instance to be running... %s', instance.status)
45+
instance = verda_client.instances.get_by_id(instance.id)
46+
47+
logger.debug('Instance is running... %s', instance.status)
48+
logger.debug('Instance ID: %s', instance.id)
49+
logger.debug('Instance OS Volume ID: %s', instance.os_volume_id)
50+
logger.debug('Instance IP: %s', instance.ip)
51+
52+
# assert os volume is created
53+
assert instance.os_volume_id is not None
54+
55+
# get os volume
56+
os_volume = verda_client.volumes.get_by_id(instance.os_volume_id)
57+
assert os_volume.id is not None
58+
assert os_volume.name == 'test-os-volume-spot'
59+
assert os_volume.size == 56

tests/unit_tests/instances/test_instances.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import json
2+
13
import pytest
2-
import responses # https://github.com/getsentry/responses
4+
import responses
35

46
from verda.constants import Actions, ErrorCodes, Locations
57
from verda.exceptions import APIException
6-
from verda.instances import Instance, InstancesService
8+
from verda.instances import Instance, InstancesService, OSVolume
79

810
INVALID_REQUEST = ErrorCodes.INVALID_REQUEST
911
INVALID_REQUEST_MESSAGE = 'Your existence is invalid'
@@ -266,6 +268,33 @@ def test_create_spot_instance_successful(self, instances_service, endpoint):
266268
assert responses.assert_call_count(endpoint, 1) is True
267269
assert responses.assert_call_count(url, 1) is True
268270

271+
def test_create_spot_instance_with_spot_volume_policy(self, instances_service, endpoint):
272+
# arrange
273+
responses.add(responses.POST, endpoint, body=INSTANCE_ID, status=200)
274+
url = endpoint + '/' + INSTANCE_ID
275+
responses.add(responses.GET, url, json=PAYLOAD[0], status=200)
276+
277+
os_volume = OSVolume(
278+
name='spot-instance-os-volume', size=50, on_spot_discontinue='delete_permanently'
279+
)
280+
281+
# act
282+
instances_service.create(
283+
instance_type=INSTANCE_TYPE,
284+
image=INSTANCE_IMAGE,
285+
ssh_key_ids=[SSH_KEY_ID],
286+
hostname=INSTANCE_HOSTNAME,
287+
description=INSTANCE_DESCRIPTION,
288+
os_volume=os_volume,
289+
)
290+
291+
# assert
292+
request_body = responses.calls[0].request.body.decode('utf-8')
293+
body = json.loads(request_body)
294+
assert body['os_volume']['name'] == os_volume.name
295+
assert body['os_volume']['size'] == os_volume.size
296+
assert body['os_volume']['on_spot_discontinue'] == 'delete_permanently'
297+
269298
def test_create_instance_attached_os_volume_successful(self, instances_service, endpoint):
270299
# arrange - add response mock
271300
# create instance
@@ -340,6 +369,28 @@ def test_action_successful(self, instances_service, endpoint):
340369
assert result is None
341370
assert responses.assert_call_count(url, 1) is True
342371

372+
def test_action_with_delete_permanently_sends_payload(self, instances_service, endpoint):
373+
# arrange
374+
url = endpoint
375+
responses.add(responses.PUT, url, status=202)
376+
volume_ids = [OS_VOLUME_ID]
377+
378+
# act
379+
instances_service.action(
380+
id_list=[INSTANCE_ID],
381+
action=Actions.DELETE,
382+
volume_ids=volume_ids,
383+
delete_permanently=True,
384+
)
385+
386+
# assert
387+
request_body = responses.calls[0].request.body.decode('utf-8')
388+
body = json.loads(request_body)
389+
assert body['id'] == [INSTANCE_ID]
390+
assert body['action'] == Actions.DELETE
391+
assert body['volume_ids'] == volume_ids
392+
assert body['delete_permanently'] is True
393+
343394
def test_action_failed(self, instances_service, endpoint):
344395
# arrange - add response mock
345396
url = endpoint

verda/instances/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,8 @@
1-
from ._instances import Contract, Instance, InstancesService, Pricing
1+
from ._instances import (
2+
Contract,
3+
Instance,
4+
InstancesService,
5+
OnSpotDiscontinue,
6+
OSVolume,
7+
Pricing,
8+
)

verda/instances/_instances.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,35 @@
33
from dataclasses import dataclass
44
from typing import Literal
55

6-
from dataclasses_json import dataclass_json
6+
from dataclasses_json import Undefined, dataclass_json
77

88
from verda.constants import InstanceStatus, Locations
99

1010
INSTANCES_ENDPOINT = '/instances'
1111

1212
Contract = Literal['LONG_TERM', 'PAY_AS_YOU_GO', 'SPOT']
1313
Pricing = Literal['DYNAMIC_PRICE', 'FIXED_PRICE']
14+
OnSpotDiscontinue = Literal['keep_detached', 'move_to_trash', 'delete_permanently']
15+
16+
17+
@dataclass_json(undefined=Undefined.EXCLUDE)
18+
@dataclass
19+
class OSVolume:
20+
"""Represents an operating system volume.
21+
22+
Attributes:
23+
name: Name of the volume.
24+
size: Size of the volume in GB.
25+
on_spot_discontinue: What to do with the volume on spot discontinue.
26+
- keep_detached: Keep the volume detached.
27+
- move_to_trash: Move the volume to trash.
28+
- delete_permanently: Delete the volume permanently.
29+
Defaults to keep_detached.
30+
"""
31+
32+
name: str
33+
size: int
34+
on_spot_discontinue: OnSpotDiscontinue | None = None
1435

1536

1637
@dataclass_json
@@ -123,7 +144,7 @@ def create(
123144
startup_script_id: str | None = None,
124145
volumes: list[dict] | None = None,
125146
existing_volumes: list[str] | None = None,
126-
os_volume: dict | None = None,
147+
os_volume: OSVolume | dict | None = None,
127148
is_spot: bool = False,
128149
contract: Contract | None = None,
129150
pricing: Pricing | None = None,
@@ -170,7 +191,7 @@ def create(
170191
'hostname': hostname,
171192
'description': description,
172193
'location_code': location,
173-
'os_volume': os_volume,
194+
'os_volume': os_volume.to_dict() if isinstance(os_volume, OSVolume) else os_volume,
174195
'volumes': volumes or [],
175196
'existing_volumes': existing_volumes or [],
176197
'is_spot': is_spot,
@@ -204,21 +225,32 @@ def action(
204225
id_list: list[str] | str,
205226
action: str,
206227
volume_ids: list[str] | None = None,
228+
delete_permanently: bool = False,
207229
) -> None:
208230
"""Performs an action on one or more instances.
209231
210232
Args:
211233
id_list: Single instance ID or list of instance IDs to act upon.
212234
action: Action to perform on the instances.
213235
volume_ids: Optional list of volume IDs to delete.
236+
delete_permanently: When deleting (or discontinuing), delete the
237+
given volume IDs permanently. Only applicable when volume_ids
238+
is also provided.
214239
215240
Raises:
216241
HTTPError: If the action fails or other API error occurs.
217242
"""
218243
if type(id_list) is str:
219244
id_list = [id_list]
220245

221-
payload = {'id': id_list, 'action': action, 'volume_ids': volume_ids}
246+
payload = {
247+
'id': id_list,
248+
'action': action,
249+
'volume_ids': volume_ids,
250+
}
251+
252+
if delete_permanently:
253+
payload['delete_permanently'] = True
222254

223255
self._http_client.put(INSTANCES_ENDPOINT, json=payload)
224256
return

0 commit comments

Comments
 (0)