From 0f228992554e060dc77d384fad544e2982de148e Mon Sep 17 00:00:00 2001 From: Jvst Me Date: Mon, 9 Feb 2026 12:33:46 +0100 Subject: [PATCH 1/3] Allow creating instances without waiting Add `InstancesService.create_nowait` method that returns immediately after sending a create request to the API. --- tests/unit_tests/instances/test_instances.py | 42 +++++++++ verda/instances/_instances.py | 94 ++++++++++++++++---- 2 files changed, 118 insertions(+), 18 deletions(-) diff --git a/tests/unit_tests/instances/test_instances.py b/tests/unit_tests/instances/test_instances.py index 53654d3..048f6af 100644 --- a/tests/unit_tests/instances/test_instances.py +++ b/tests/unit_tests/instances/test_instances.py @@ -357,6 +357,48 @@ def test_create_instance_failed(self, instances_service, endpoint): assert excinfo.value.message == INVALID_REQUEST_MESSAGE assert responses.assert_call_count(endpoint, 1) is True + def test_create_nowait_successful(self, instances_service, endpoint): + # arrange - add response mock + responses.add(responses.POST, endpoint, body=INSTANCE_ID, status=200) + + # act + result = instances_service.create_nowait( + instance_type=INSTANCE_TYPE, + image=INSTANCE_IMAGE, + ssh_key_ids=[SSH_KEY_ID], + hostname=INSTANCE_HOSTNAME, + description=INSTANCE_DESCRIPTION, + os_volume=INSTANCE_OS_VOLUME, + ) + + # assert + assert result == INSTANCE_ID + assert responses.assert_call_count(endpoint, 1) is True + + def test_create_nowait_failed(self, instances_service, endpoint): + # arrange - add response mock + responses.add( + responses.POST, + endpoint, + json={'code': INVALID_REQUEST, 'message': INVALID_REQUEST_MESSAGE}, + status=400, + ) + + # act + with pytest.raises(APIException) as excinfo: + instances_service.create_nowait( + instance_type=INSTANCE_TYPE, + image=INSTANCE_IMAGE, + ssh_key_ids=[SSH_KEY_ID], + hostname=INSTANCE_HOSTNAME, + description=INSTANCE_DESCRIPTION, + ) + + # assert + assert excinfo.value.code == INVALID_REQUEST + assert excinfo.value.message == INVALID_REQUEST_MESSAGE + assert responses.assert_call_count(endpoint, 1) is True + def test_action_successful(self, instances_service, endpoint): # arrange - add response mock url = endpoint diff --git a/verda/instances/_instances.py b/verda/instances/_instances.py index 5fd3a48..b45dc64 100644 --- a/verda/instances/_instances.py +++ b/verda/instances/_instances.py @@ -180,6 +180,81 @@ def create( Returns: The newly created instance object. + Raises: + HTTPError: If instance creation fails or other API error occurs. + """ + id = self.create_nowait( + instance_type=instance_type, + image=image, + hostname=hostname, + description=description, + ssh_key_ids=ssh_key_ids, + location=location, + startup_script_id=startup_script_id, + volumes=volumes, + existing_volumes=existing_volumes, + os_volume=os_volume, + is_spot=is_spot, + contract=contract, + pricing=pricing, + coupon=coupon, + ) + + # Wait for instance to enter provisioning state with timeout + # TODO(shamrin) extract backoff logic, _clusters module has the same code + deadline = time.monotonic() + max_wait_time + for i in itertools.count(): + instance = self.get_by_id(id) + if instance.status != InstanceStatus.ORDERED: + return instance + + now = time.monotonic() + if now >= deadline: + raise TimeoutError( + f'Instance {id} did not enter provisioning state within {max_wait_time:.1f} seconds' + ) + + interval = min(initial_interval * backoff_coefficient**i, max_interval, deadline - now) + time.sleep(interval) + + def create_nowait( + self, + instance_type: str, + image: str, + hostname: str, + description: str, + ssh_key_ids: list = [], + location: str = Locations.FIN_03, + startup_script_id: str | None = None, + volumes: list[dict] | None = None, + existing_volumes: list[str] | None = None, + os_volume: OSVolume | dict | None = None, + is_spot: bool = False, + contract: Contract | None = None, + pricing: Pricing | None = None, + coupon: str | None = None, + ) -> str: + """Creates a new cloud instance without waiting for it to enter the provisioning state. + + Args: + instance_type: Type of instance to create (e.g., '8V100.48V'). + image: Image type or existing OS volume ID for the instance. + hostname: Network hostname for the instance. + description: Human-readable description of the instance. + ssh_key_ids: List of SSH key IDs to associate with the instance. + location: Datacenter location code (default: Locations.FIN_03). + startup_script_id: Optional ID of startup script to run. + volumes: Optional list of volume configurations to create. + existing_volumes: Optional list of existing volume IDs to attach. + os_volume: Optional OS volume configuration details. + is_spot: Whether to create a spot instance. + contract: Optional contract type for the instance. + pricing: Optional pricing model for the instance. + coupon: Optional coupon code for discounts. + + Returns: + The newly created instance ID. + Raises: HTTPError: If instance creation fails or other API error occurs. """ @@ -201,24 +276,7 @@ def create( payload['contract'] = contract if pricing: payload['pricing'] = pricing - id = self._http_client.post(INSTANCES_ENDPOINT, json=payload).text - - # Wait for instance to enter provisioning state with timeout - # TODO(shamrin) extract backoff logic, _clusters module has the same code - deadline = time.monotonic() + max_wait_time - for i in itertools.count(): - instance = self.get_by_id(id) - if instance.status != InstanceStatus.ORDERED: - return instance - - now = time.monotonic() - if now >= deadline: - raise TimeoutError( - f'Instance {id} did not enter provisioning state within {max_wait_time:.1f} seconds' - ) - - interval = min(initial_interval * backoff_coefficient**i, max_interval, deadline - now) - time.sleep(interval) + return self._http_client.post(INSTANCES_ENDPOINT, json=payload).text def action( self, From d37820712cd4b2e8d4ecef606976b0fc8bb457e8 Mon Sep 17 00:00:00 2001 From: Jvst Me Date: Tue, 10 Feb 2026 14:10:00 +0100 Subject: [PATCH 2/3] `create_nowait` -> `create(wait_for_status)` --- tests/unit_tests/instances/test_instances.py | 80 ++++++++------ verda/instances/_instances.py | 103 +++++-------------- 2 files changed, 74 insertions(+), 109 deletions(-) diff --git a/tests/unit_tests/instances/test_instances.py b/tests/unit_tests/instances/test_instances.py index 048f6af..b9dbc0a 100644 --- a/tests/unit_tests/instances/test_instances.py +++ b/tests/unit_tests/instances/test_instances.py @@ -1,9 +1,10 @@ +import copy import json import pytest import responses -from verda.constants import Actions, ErrorCodes, Locations +from verda.constants import Actions, ErrorCodes, InstanceStatus, Locations from verda.exceptions import APIException from verda.instances import Instance, InstancesService, OSVolume @@ -333,49 +334,62 @@ def test_create_instance_attached_os_volume_successful(self, instances_service, assert responses.assert_call_count(endpoint, 1) is True assert responses.assert_call_count(url, 1) is True - def test_create_instance_failed(self, instances_service, endpoint): - # arrange - add response mock - responses.add( - responses.POST, - endpoint, - json={'code': INVALID_REQUEST, 'message': INVALID_REQUEST_MESSAGE}, - status=400, - ) - - # act - with pytest.raises(APIException) as excinfo: - instances_service.create( - instance_type=INSTANCE_TYPE, - image=INSTANCE_IMAGE, - ssh_key_ids=[SSH_KEY_ID], - hostname=INSTANCE_HOSTNAME, - description=INSTANCE_DESCRIPTION, - ) - - # assert - assert excinfo.value.code == INVALID_REQUEST - assert excinfo.value.message == INVALID_REQUEST_MESSAGE - assert responses.assert_call_count(endpoint, 1) is True - - def test_create_nowait_successful(self, instances_service, endpoint): + @pytest.mark.parametrize( + ('wait_for_status', 'expected_status', 'expected_get_instance_call_count'), + [ + (None, InstanceStatus.ORDERED, 1), + (InstanceStatus.ORDERED, InstanceStatus.ORDERED, 1), + (InstanceStatus.PROVISIONING, InstanceStatus.PROVISIONING, 2), + (lambda status: status != InstanceStatus.ORDERED, InstanceStatus.PROVISIONING, 2), + (InstanceStatus.RUNNING, InstanceStatus.RUNNING, 3), + ], + ) + def test_create_wait_for_status( + self, + instances_service, + endpoint, + wait_for_status, + expected_status, + expected_get_instance_call_count, + ): # arrange - add response mock + # create instance responses.add(responses.POST, endpoint, body=INSTANCE_ID, status=200) + # First get instance by id - ordered + get_instance_url = endpoint + '/' + INSTANCE_ID + payload = copy.deepcopy(PAYLOAD[0]) + payload['status'] = InstanceStatus.ORDERED + responses.add(responses.GET, get_instance_url, json=payload, status=200) + # Second get instance by id - provisioning + payload = copy.deepcopy(PAYLOAD[0]) + payload['status'] = InstanceStatus.PROVISIONING + responses.add(responses.GET, get_instance_url, json=payload, status=200) + # Third get instance by id - running + payload = copy.deepcopy(PAYLOAD[0]) + payload['status'] = InstanceStatus.RUNNING + responses.add(responses.GET, get_instance_url, json=payload, status=200) # act - result = instances_service.create_nowait( + instance = instances_service.create( instance_type=INSTANCE_TYPE, - image=INSTANCE_IMAGE, - ssh_key_ids=[SSH_KEY_ID], + image=OS_VOLUME_ID, hostname=INSTANCE_HOSTNAME, description=INSTANCE_DESCRIPTION, - os_volume=INSTANCE_OS_VOLUME, + wait_for_status=wait_for_status, + max_interval=0, + max_wait_time=1, ) # assert - assert result == INSTANCE_ID + assert isinstance(instance, Instance) + assert instance.id == INSTANCE_ID + assert instance.status == expected_status assert responses.assert_call_count(endpoint, 1) is True + assert ( + responses.assert_call_count(get_instance_url, expected_get_instance_call_count) is True + ) - def test_create_nowait_failed(self, instances_service, endpoint): + def test_create_instance_failed(self, instances_service, endpoint): # arrange - add response mock responses.add( responses.POST, @@ -386,7 +400,7 @@ def test_create_nowait_failed(self, instances_service, endpoint): # act with pytest.raises(APIException) as excinfo: - instances_service.create_nowait( + instances_service.create( instance_type=INSTANCE_TYPE, image=INSTANCE_IMAGE, ssh_key_ids=[SSH_KEY_ID], diff --git a/verda/instances/_instances.py b/verda/instances/_instances.py index b45dc64..5345ba6 100644 --- a/verda/instances/_instances.py +++ b/verda/instances/_instances.py @@ -1,5 +1,6 @@ import itertools import time +from collections.abc import Callable from dataclasses import dataclass from typing import Literal @@ -150,6 +151,7 @@ def create( pricing: Pricing | None = None, coupon: str | None = None, *, + wait_for_status: str | Callable[[str], bool] | None = lambda s: s != InstanceStatus.ORDERED, max_wait_time: float = 180, initial_interval: float = 0.5, max_interval: float = 5, @@ -172,6 +174,7 @@ def create( contract: Optional contract type for the instance. pricing: Optional pricing model for the instance. coupon: Optional coupon code for discounts. + wait_for_status: Status to wait for the instance to reach, or callable that returns True when the desired status is reached. Default to any status other than ORDERED. If None, no wait is performed. max_wait_time: Maximum total wait for the instance to start provisioning, in seconds (default: 180) initial_interval: Initial interval, in seconds (default: 0.5) max_interval: The longest single delay allowed between retries, in seconds (default: 5) @@ -180,81 +183,6 @@ def create( Returns: The newly created instance object. - Raises: - HTTPError: If instance creation fails or other API error occurs. - """ - id = self.create_nowait( - instance_type=instance_type, - image=image, - hostname=hostname, - description=description, - ssh_key_ids=ssh_key_ids, - location=location, - startup_script_id=startup_script_id, - volumes=volumes, - existing_volumes=existing_volumes, - os_volume=os_volume, - is_spot=is_spot, - contract=contract, - pricing=pricing, - coupon=coupon, - ) - - # Wait for instance to enter provisioning state with timeout - # TODO(shamrin) extract backoff logic, _clusters module has the same code - deadline = time.monotonic() + max_wait_time - for i in itertools.count(): - instance = self.get_by_id(id) - if instance.status != InstanceStatus.ORDERED: - return instance - - now = time.monotonic() - if now >= deadline: - raise TimeoutError( - f'Instance {id} did not enter provisioning state within {max_wait_time:.1f} seconds' - ) - - interval = min(initial_interval * backoff_coefficient**i, max_interval, deadline - now) - time.sleep(interval) - - def create_nowait( - self, - instance_type: str, - image: str, - hostname: str, - description: str, - ssh_key_ids: list = [], - location: str = Locations.FIN_03, - startup_script_id: str | None = None, - volumes: list[dict] | None = None, - existing_volumes: list[str] | None = None, - os_volume: OSVolume | dict | None = None, - is_spot: bool = False, - contract: Contract | None = None, - pricing: Pricing | None = None, - coupon: str | None = None, - ) -> str: - """Creates a new cloud instance without waiting for it to enter the provisioning state. - - Args: - instance_type: Type of instance to create (e.g., '8V100.48V'). - image: Image type or existing OS volume ID for the instance. - hostname: Network hostname for the instance. - description: Human-readable description of the instance. - ssh_key_ids: List of SSH key IDs to associate with the instance. - location: Datacenter location code (default: Locations.FIN_03). - startup_script_id: Optional ID of startup script to run. - volumes: Optional list of volume configurations to create. - existing_volumes: Optional list of existing volume IDs to attach. - os_volume: Optional OS volume configuration details. - is_spot: Whether to create a spot instance. - contract: Optional contract type for the instance. - pricing: Optional pricing model for the instance. - coupon: Optional coupon code for discounts. - - Returns: - The newly created instance ID. - Raises: HTTPError: If instance creation fails or other API error occurs. """ @@ -276,7 +204,30 @@ def create_nowait( payload['contract'] = contract if pricing: payload['pricing'] = pricing - return self._http_client.post(INSTANCES_ENDPOINT, json=payload).text + id = self._http_client.post(INSTANCES_ENDPOINT, json=payload).text + + if not wait_for_status: + return self.get_by_id(id) + + # Wait for instance to enter provisioning state with timeout + # TODO(shamrin) extract backoff logic, _clusters module has the same code + deadline = time.monotonic() + max_wait_time + for i in itertools.count(): + instance = self.get_by_id(id) + if callable(wait_for_status): + if wait_for_status(instance.status): + return instance + elif instance.status == wait_for_status: + return instance + + now = time.monotonic() + if now >= deadline: + raise TimeoutError( + f'Instance {id} did not enter provisioning state within {max_wait_time:.1f} seconds' + ) + + interval = min(initial_interval * backoff_coefficient**i, max_interval, deadline - now) + time.sleep(interval) def action( self, From 4aa422fc7f1986005185adb360c1699683380e05 Mon Sep 17 00:00:00 2001 From: jvstme <36324149+jvstme@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:58:18 +0000 Subject: [PATCH 3/3] Update verda/instances/_instances.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- verda/instances/_instances.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/verda/instances/_instances.py b/verda/instances/_instances.py index 5345ba6..a32add8 100644 --- a/verda/instances/_instances.py +++ b/verda/instances/_instances.py @@ -206,7 +206,7 @@ def create( payload['pricing'] = pricing id = self._http_client.post(INSTANCES_ENDPOINT, json=payload).text - if not wait_for_status: + if wait_for_status is None: return self.get_by_id(id) # Wait for instance to enter provisioning state with timeout