diff --git a/datacrunch_compat/datacrunch/__init__.py b/datacrunch_compat/datacrunch/__init__.py index 08d0de2..eaa885e 100644 --- a/datacrunch_compat/datacrunch/__init__.py +++ b/datacrunch_compat/datacrunch/__init__.py @@ -5,7 +5,9 @@ __version__, authentication, balance, + cluster_types, constants, + container_types, containers, exceptions, helpers, @@ -13,6 +15,7 @@ images, instance_types, instances, + job_deployments, locations, ssh_keys, startup_scripts, @@ -28,7 +31,9 @@ '__version__', 'authentication', 'balance', + 'cluster_types', 'constants', + 'container_types', 'containers', 'datacrunch', 'exceptions', @@ -37,6 +42,7 @@ 'images', 'instance_types', 'instances', + 'job_deployments', 'locations', 'ssh_keys', 'startup_scripts', diff --git a/datacrunch_compat/datacrunch/datacrunch.py b/datacrunch_compat/datacrunch/datacrunch.py index f14f0e3..6fea7ce 100644 --- a/datacrunch_compat/datacrunch/datacrunch.py +++ b/datacrunch_compat/datacrunch/datacrunch.py @@ -4,12 +4,15 @@ from verda._version import __version__ from verda.authentication import AuthenticationService from verda.balance import BalanceService +from verda.cluster_types import ClusterTypesService from verda.constants import Constants +from verda.container_types import ContainerTypesService from verda.containers import ContainersService from verda.http_client import HTTPClient from verda.images import ImagesService from verda.instance_types import InstanceTypesService from verda.instances import InstancesService +from verda.job_deployments import JobDeploymentsService from verda.locations import LocationsService from verda.ssh_keys import SSHKeysService from verda.startup_scripts import StartupScriptsService @@ -20,13 +23,16 @@ __all__ = [ 'AuthenticationService', 'BalanceService', + 'ClusterTypesService', 'Constants', + 'ContainerTypesService', 'ContainersService', 'DataCrunchClient', 'HTTPClient', 'ImagesService', 'InstanceTypesService', 'InstancesService', + 'JobDeploymentsService', 'LocationsService', 'SSHKeysService', 'StartupScriptsService', diff --git a/docs/source/api/services/cluster_types.rst b/docs/source/api/services/cluster_types.rst new file mode 100644 index 0000000..a07a41a --- /dev/null +++ b/docs/source/api/services/cluster_types.rst @@ -0,0 +1,8 @@ +Cluster Types +============= + +.. autoclass:: verda.cluster_types.ClusterTypesService + :members: + +.. autoclass:: verda.cluster_types.ClusterType + :members: diff --git a/docs/source/api/services/container_types.rst b/docs/source/api/services/container_types.rst new file mode 100644 index 0000000..a2d53c7 --- /dev/null +++ b/docs/source/api/services/container_types.rst @@ -0,0 +1,8 @@ +Container Types +=============== + +.. autoclass:: verda.container_types.ContainerTypesService + :members: + +.. autoclass:: verda.container_types.ContainerType + :members: diff --git a/docs/source/api/services/job_deployments.rst b/docs/source/api/services/job_deployments.rst new file mode 100644 index 0000000..2d06fbd --- /dev/null +++ b/docs/source/api/services/job_deployments.rst @@ -0,0 +1,14 @@ +Job Deployments +=============== + +.. autoclass:: verda.job_deployments.JobDeploymentsService + :members: + +.. autoclass:: verda.job_deployments.JobDeployment + :members: + +.. autoclass:: verda.job_deployments.JobDeploymentSummary + :members: + +.. autoclass:: verda.job_deployments.JobScalingOptions + :members: diff --git a/tests/unit_tests/cluster_types/test_cluster_types.py b/tests/unit_tests/cluster_types/test_cluster_types.py new file mode 100644 index 0000000..cea9e04 --- /dev/null +++ b/tests/unit_tests/cluster_types/test_cluster_types.py @@ -0,0 +1,51 @@ +import responses # https://github.com/getsentry/responses + +from verda.cluster_types import ClusterType, ClusterTypesService + +CLUSTER_TYPE_ID = 'cluster-c0de-a5d2-4972-ae4e-d429115d055b' + + +@responses.activate +def test_cluster_types(http_client): + endpoint = http_client._base_url + '/cluster-types?currency=usd' + responses.add( + responses.GET, + endpoint, + json=[ + { + 'id': CLUSTER_TYPE_ID, + 'model': 'H200', + 'name': 'H200 Cluster', + 'cluster_type': '16H200', + 'cpu': {'description': '64 CPU', 'number_of_cores': 64}, + 'gpu': {'description': '16x H200', 'number_of_gpus': 16}, + 'gpu_memory': {'description': '2.2TB VRAM', 'size_in_gigabytes': 2200}, + 'memory': {'description': '4TB RAM', 'size_in_gigabytes': 4096}, + 'price_per_hour': '45.50', + 'currency': 'usd', + 'manufacturer': 'NVIDIA', + 'node_details': ['2x 8 GPU nodes'], + 'supported_os': ['ubuntu-24.04-cuda-12.8-cluster'], + } + ], + status=200, + ) + + service = ClusterTypesService(http_client) + + cluster_types = service.get() + cluster_type = cluster_types[0] + + assert isinstance(cluster_types, list) + assert len(cluster_types) == 1 + assert isinstance(cluster_type, ClusterType) + assert cluster_type.id == CLUSTER_TYPE_ID + assert cluster_type.model == 'H200' + assert cluster_type.name == 'H200 Cluster' + assert cluster_type.cluster_type == '16H200' + assert cluster_type.price_per_hour == 45.5 + assert cluster_type.currency == 'usd' + assert cluster_type.manufacturer == 'NVIDIA' + assert cluster_type.node_details == ['2x 8 GPU nodes'] + assert cluster_type.supported_os == ['ubuntu-24.04-cuda-12.8-cluster'] + assert responses.assert_call_count(endpoint, 1) is True diff --git a/tests/unit_tests/container_types/test_container_types.py b/tests/unit_tests/container_types/test_container_types.py new file mode 100644 index 0000000..fbe337b --- /dev/null +++ b/tests/unit_tests/container_types/test_container_types.py @@ -0,0 +1,49 @@ +import responses # https://github.com/getsentry/responses + +from verda.container_types import ContainerType, ContainerTypesService + +CONTAINER_TYPE_ID = 'type-c0de-a5d2-4972-ae4e-d429115d055b' + + +@responses.activate +def test_container_types(http_client): + endpoint = http_client._base_url + '/container-types?currency=eur' + responses.add( + responses.GET, + endpoint, + json=[ + { + 'id': CONTAINER_TYPE_ID, + 'model': 'H100', + 'name': 'H100 SXM5 80GB', + 'instance_type': '1H100.80S.22V', + 'cpu': {'description': '22 CPU', 'number_of_cores': 22}, + 'gpu': {'description': '1x H100 SXM5 80GB', 'number_of_gpus': 1}, + 'gpu_memory': {'description': '80GB GPU RAM', 'size_in_gigabytes': 80}, + 'memory': {'description': '187GB RAM', 'size_in_gigabytes': 187}, + 'serverless_price': '1.75', + 'serverless_spot_price': '0.87', + 'currency': 'eur', + 'manufacturer': 'NVIDIA', + } + ], + status=200, + ) + + service = ContainerTypesService(http_client) + + container_types = service.get(currency='eur') + container_type = container_types[0] + + assert isinstance(container_types, list) + assert len(container_types) == 1 + assert isinstance(container_type, ContainerType) + assert container_type.id == CONTAINER_TYPE_ID + assert container_type.model == 'H100' + assert container_type.name == 'H100 SXM5 80GB' + assert container_type.instance_type == '1H100.80S.22V' + assert container_type.serverless_price == 1.75 + assert container_type.serverless_spot_price == 0.87 + assert container_type.currency == 'eur' + assert container_type.manufacturer == 'NVIDIA' + assert responses.assert_call_count(endpoint, 1) is True diff --git a/tests/unit_tests/instance_types/test_instance_types.py b/tests/unit_tests/instance_types/test_instance_types.py index 459d809..2595c54 100644 --- a/tests/unit_tests/instance_types/test_instance_types.py +++ b/tests/unit_tests/instance_types/test_instance_types.py @@ -14,23 +14,36 @@ STORAGE_DESCRIPTION = '1800GB NVME' STORAGE_SIZE = 1800 INSTANCE_TYPE_DESCRIPTION = 'Dedicated Bare metal Server' +BEST_FOR = ['Large model inference', 'Multi-GPU training'] +MODEL = 'V100' +NAME = 'Tesla V100' +P2P = '300 GB/s' PRICE_PER_HOUR = 5.0 SPOT_PRICE_PER_HOUR = 2.5 +SERVERLESS_PRICE = 1.25 +SERVERLESS_SPOT_PRICE = 0.75 INSTANCE_TYPE = '8V100.48M' +CURRENCY = 'eur' +MANUFACTURER = 'NVIDIA' +DISPLAY_NAME = 'NVIDIA Tesla V100' +SUPPORTED_OS = ['ubuntu-24.04-cuda-12.8-open-docker'] +@responses.activate def test_instance_types(http_client): # arrange - add response mock responses.add( responses.GET, - http_client._base_url + '/instance-types', + http_client._base_url + '/instance-types?currency=eur', json=[ { 'id': TYPE_ID, + 'best_for': BEST_FOR, 'cpu': { 'description': CPU_DESCRIPTION, 'number_of_cores': NUMBER_OF_CORES, }, + 'deploy_warning': 'Use updated drivers', 'gpu': { 'description': GPU_DESCRIPTION, 'number_of_gpus': NUMBER_OF_GPUS, @@ -48,9 +61,18 @@ def test_instance_types(http_client): 'size_in_gigabytes': STORAGE_SIZE, }, 'description': INSTANCE_TYPE_DESCRIPTION, + 'model': MODEL, + 'name': NAME, + 'p2p': P2P, 'price_per_hour': '5.00', 'spot_price': '2.50', + 'serverless_price': '1.25', + 'serverless_spot_price': '0.75', 'instance_type': INSTANCE_TYPE, + 'currency': CURRENCY, + 'manufacturer': MANUFACTURER, + 'display_name': DISPLAY_NAME, + 'supported_os': SUPPORTED_OS, } ], status=200, @@ -59,7 +81,7 @@ def test_instance_types(http_client): instance_types_service = InstanceTypesService(http_client) # act - instance_types = instance_types_service.get() + instance_types = instance_types_service.get(currency='eur') instance_type = instance_types[0] # assert @@ -71,6 +93,17 @@ def test_instance_types(http_client): assert instance_type.price_per_hour == PRICE_PER_HOUR assert instance_type.spot_price_per_hour == SPOT_PRICE_PER_HOUR assert instance_type.instance_type == INSTANCE_TYPE + assert instance_type.best_for == BEST_FOR + assert instance_type.model == MODEL + assert instance_type.name == NAME + assert instance_type.p2p == P2P + assert instance_type.currency == CURRENCY + assert instance_type.manufacturer == MANUFACTURER + assert instance_type.display_name == DISPLAY_NAME + assert instance_type.supported_os == SUPPORTED_OS + assert instance_type.deploy_warning == 'Use updated drivers' + assert instance_type.serverless_price == SERVERLESS_PRICE + assert instance_type.serverless_spot_price == SERVERLESS_SPOT_PRICE assert isinstance(instance_type.cpu, dict) assert isinstance(instance_type.gpu, dict) assert isinstance(instance_type.memory, dict) diff --git a/tests/unit_tests/job_deployments/test_job_deployments.py b/tests/unit_tests/job_deployments/test_job_deployments.py new file mode 100644 index 0000000..a265fe4 --- /dev/null +++ b/tests/unit_tests/job_deployments/test_job_deployments.py @@ -0,0 +1,209 @@ +import json + +import pytest +import responses # https://github.com/getsentry/responses + +from verda.containers import ComputeResource, Container, ContainerRegistrySettings +from verda.exceptions import APIException +from verda.job_deployments import ( + JobDeployment, + JobDeploymentsService, + JobDeploymentStatus, + JobDeploymentSummary, + JobScalingOptions, +) + +JOB_NAME = 'test-job' +CONTAINER_NAME = 'worker' +INVALID_REQUEST = 'INVALID_REQUEST' +INVALID_REQUEST_MESSAGE = 'Invalid request' + +JOB_SUMMARY_PAYLOAD = [ + { + 'name': JOB_NAME, + 'created_at': '2024-01-01T00:00:00Z', + 'compute': { + 'name': 'H100', + 'size': 1, + }, + } +] + +JOB_PAYLOAD = { + 'name': JOB_NAME, + 'containers': [ + { + 'name': CONTAINER_NAME, + 'image': 'busybox:latest', + 'exposed_port': 8080, + 'env': [], + 'volume_mounts': [], + } + ], + 'endpoint_base_url': 'https://test-job.datacrunch.io', + 'created_at': '2024-01-01T00:00:00Z', + 'compute': { + 'name': 'H100', + 'size': 1, + }, + 'container_registry_settings': { + 'is_private': False, + 'credentials': None, + }, +} + +SCALING_PAYLOAD = { + 'max_replica_count': 5, + 'queue_message_ttl_seconds': 600, + 'deadline_seconds': 1800, +} + + +class TestJobDeploymentsService: + @pytest.fixture + def service(self, http_client): + return JobDeploymentsService(http_client) + + @pytest.fixture + def endpoint(self, http_client): + return http_client._base_url + '/job-deployments' + + @responses.activate + def test_get_job_deployments(self, service, endpoint): + responses.add(responses.GET, endpoint, json=JOB_SUMMARY_PAYLOAD, status=200) + + deployments = service.get() + + assert isinstance(deployments, list) + assert len(deployments) == 1 + assert isinstance(deployments[0], JobDeploymentSummary) + assert deployments[0].name == JOB_NAME + assert deployments[0].compute.name == 'H100' + assert responses.assert_call_count(endpoint, 1) is True + + @responses.activate + def test_get_job_deployment_by_name(self, service, endpoint): + url = f'{endpoint}/{JOB_NAME}' + responses.add(responses.GET, url, json=JOB_PAYLOAD, status=200) + + deployment = service.get_by_name(JOB_NAME) + + assert isinstance(deployment, JobDeployment) + assert deployment.name == JOB_NAME + assert deployment.endpoint_base_url == 'https://test-job.datacrunch.io' + assert deployment.compute.size == 1 + assert deployment.containers[0].name == CONTAINER_NAME + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_get_job_deployment_by_name_error(self, service, endpoint): + url = f'{endpoint}/missing-job' + responses.add( + responses.GET, + url, + json={'code': INVALID_REQUEST, 'message': INVALID_REQUEST_MESSAGE}, + status=400, + ) + + with pytest.raises(APIException) as excinfo: + service.get_by_name('missing-job') + + assert excinfo.value.code == INVALID_REQUEST + assert excinfo.value.message == INVALID_REQUEST_MESSAGE + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_create_job_deployment(self, service, endpoint): + responses.add(responses.POST, endpoint, json=JOB_PAYLOAD, status=201) + + deployment = JobDeployment( + name=JOB_NAME, + containers=[Container(image='busybox:latest', exposed_port=8080, name=CONTAINER_NAME)], + compute=ComputeResource(name='H100', size=1), + container_registry_settings=ContainerRegistrySettings(is_private=False), + scaling=JobScalingOptions(**SCALING_PAYLOAD), + ) + + created = service.create(deployment) + + assert isinstance(created, JobDeployment) + assert created.name == JOB_NAME + request_body = json.loads(responses.calls[0].request.body.decode('utf-8')) + assert request_body['scaling'] == SCALING_PAYLOAD + assert responses.assert_call_count(endpoint, 1) is True + + @responses.activate + def test_update_job_deployment(self, service, endpoint): + url = f'{endpoint}/{JOB_NAME}' + responses.add(responses.PATCH, url, json=JOB_PAYLOAD, status=200) + + deployment = JobDeployment( + name=JOB_NAME, + containers=[Container(image='busybox:latest', exposed_port=8080, name=CONTAINER_NAME)], + compute=ComputeResource(name='H100', size=1), + scaling=JobScalingOptions(**SCALING_PAYLOAD), + ) + + updated = service.update(JOB_NAME, deployment) + + assert isinstance(updated, JobDeployment) + assert updated.name == JOB_NAME + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_delete_job_deployment(self, service, endpoint): + url = f'{endpoint}/{JOB_NAME}?timeout=120000' + responses.add(responses.DELETE, url, status=200) + + service.delete(JOB_NAME, timeout=120000) + + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_get_job_status(self, service, endpoint): + url = f'{endpoint}/{JOB_NAME}/status' + responses.add(responses.GET, url, json={'status': 'running'}, status=200) + + status = service.get_status(JOB_NAME) + + assert status == JobDeploymentStatus.RUNNING + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_get_job_scaling_options(self, service, endpoint): + url = f'{endpoint}/{JOB_NAME}/scaling' + responses.add(responses.GET, url, json=SCALING_PAYLOAD, status=200) + + scaling = service.get_scaling_options(JOB_NAME) + + assert isinstance(scaling, JobScalingOptions) + assert scaling.max_replica_count == 5 + assert scaling.deadline_seconds == 1800 + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_pause_job_deployment(self, service, endpoint): + url = f'{endpoint}/{JOB_NAME}/pause' + responses.add(responses.POST, url, status=204) + + service.pause(JOB_NAME) + + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_resume_job_deployment(self, service, endpoint): + url = f'{endpoint}/{JOB_NAME}/resume' + responses.add(responses.POST, url, status=204) + + service.resume(JOB_NAME) + + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_purge_job_deployment_queue(self, service, endpoint): + url = f'{endpoint}/{JOB_NAME}/purge-queue' + responses.add(responses.POST, url, status=204) + + service.purge_queue(JOB_NAME) + + assert responses.assert_call_count(url, 1) is True diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py index 1296162..b1f6029 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/test_client.py @@ -16,6 +16,7 @@ class TestVerdaClient: + @responses.activate def test_client(self): # arrange - add response mock responses.add(responses.POST, BASE_URL + '/oauth2/token', json=response_json, status=200) @@ -25,7 +26,11 @@ def test_client(self): # assert assert client.constants.base_url == BASE_URL + assert hasattr(client, 'container_types') + assert hasattr(client, 'cluster_types') + assert hasattr(client, 'job_deployments') + @responses.activate def test_client_with_default_base_url(self): # arrange - add response mock DEFAULT_BASE_URL = 'https://api.verda.com/v1' @@ -42,6 +47,7 @@ def test_client_with_default_base_url(self): # assert assert client.constants.base_url == DEFAULT_BASE_URL + @responses.activate def test_invalid_client_credentials(self): # arrange - add response mock responses.add( diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py new file mode 100644 index 0000000..afa798e --- /dev/null +++ b/tests/unit_tests/test_helpers.py @@ -0,0 +1,27 @@ +from verda.helpers import strip_none_values + + +def test_strip_none_values_removes_none_recursively(): + data = { + 'name': 'job', + 'optional': None, + 'nested': { + 'keep': 'value', + 'drop': None, + }, + 'items': [ + {'keep': 1, 'drop': None}, + None, + ['value', None], + ], + } + + assert strip_none_values(data) == { + 'name': 'job', + 'nested': {'keep': 'value'}, + 'items': [ + {'keep': 1}, + None, + ['value', None], + ], + } diff --git a/verda/_verda.py b/verda/_verda.py index 3a47831..544b775 100644 --- a/verda/_verda.py +++ b/verda/_verda.py @@ -1,13 +1,16 @@ from verda._version import __version__ from verda.authentication import AuthenticationService from verda.balance import BalanceService +from verda.cluster_types import ClusterTypesService from verda.clusters import ClustersService from verda.constants import Constants +from verda.container_types import ContainerTypesService from verda.containers import ContainersService from verda.http_client import HTTPClient from verda.images import ImagesService from verda.instance_types import InstanceTypesService from verda.instances import InstancesService +from verda.job_deployments import JobDeploymentsService from verda.locations import LocationsService from verda.ssh_keys import SSHKeysService from verda.startup_scripts import StartupScriptsService @@ -80,8 +83,17 @@ def __init__( self.containers: ContainersService = ContainersService(self._http_client, inference_key) """Containers service. Deploy, manage, and monitor container deployments""" + self.job_deployments: JobDeploymentsService = JobDeploymentsService(self._http_client) + """Job deployments service. Deploy and manage serverless jobs""" + + self.container_types: ContainerTypesService = ContainerTypesService(self._http_client) + """Container types service. Get available serverless container info""" + self.clusters: ClustersService = ClustersService(self._http_client) """Clusters service. Create and manage compute clusters""" + self.cluster_types: ClusterTypesService = ClusterTypesService(self._http_client) + """Cluster types service. Get available cluster info""" + __all__ = ['VerdaClient'] diff --git a/verda/cluster_types/__init__.py b/verda/cluster_types/__init__.py new file mode 100644 index 0000000..581deec --- /dev/null +++ b/verda/cluster_types/__init__.py @@ -0,0 +1,3 @@ +from verda.cluster_types._cluster_types import ClusterType, ClusterTypesService + +__all__ = ['ClusterType', 'ClusterTypesService'] diff --git a/verda/cluster_types/_cluster_types.py b/verda/cluster_types/_cluster_types.py new file mode 100644 index 0000000..6f3a261 --- /dev/null +++ b/verda/cluster_types/_cluster_types.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass + +from dataclasses_json import dataclass_json + +from verda.constants import Currency + +CLUSTER_TYPES_ENDPOINT = '/cluster-types' + + +@dataclass_json +@dataclass +class ClusterType: + """Cluster type returned by the public API.""" + + id: str + model: str + name: str + cluster_type: str + cpu: dict + gpu: dict + gpu_memory: dict + memory: dict + price_per_hour: float + currency: Currency + manufacturer: str + node_details: list[str] + supported_os: list[str] + + +class ClusterTypesService: + """Service for interacting with cluster types.""" + + def __init__(self, http_client) -> None: + self._http_client = http_client + + def get(self, currency: Currency = 'usd') -> list[ClusterType]: + """Return all available cluster types.""" + cluster_types = self._http_client.get( + CLUSTER_TYPES_ENDPOINT, + params={'currency': currency}, + ).json() + return [ + ClusterType( + id=cluster_type['id'], + model=cluster_type['model'], + name=cluster_type['name'], + cluster_type=cluster_type['cluster_type'], + cpu=cluster_type['cpu'], + gpu=cluster_type['gpu'], + gpu_memory=cluster_type['gpu_memory'], + memory=cluster_type['memory'], + price_per_hour=float(cluster_type['price_per_hour']), + currency=cluster_type['currency'], + manufacturer=cluster_type['manufacturer'], + node_details=cluster_type['node_details'], + supported_os=cluster_type['supported_os'], + ) + for cluster_type in cluster_types + ] diff --git a/verda/constants.py b/verda/constants.py index 777e7a7..414aa53 100644 --- a/verda/constants.py +++ b/verda/constants.py @@ -1,3 +1,8 @@ +from typing import Literal + +Currency = Literal['usd', 'eur'] + + class Actions: """Instance actions.""" diff --git a/verda/container_types/__init__.py b/verda/container_types/__init__.py new file mode 100644 index 0000000..8b960ad --- /dev/null +++ b/verda/container_types/__init__.py @@ -0,0 +1,3 @@ +from verda.container_types._container_types import ContainerType, ContainerTypesService + +__all__ = ['ContainerType', 'ContainerTypesService'] diff --git a/verda/container_types/_container_types.py b/verda/container_types/_container_types.py new file mode 100644 index 0000000..4a1f65d --- /dev/null +++ b/verda/container_types/_container_types.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass + +from dataclasses_json import dataclass_json + +from verda.constants import Currency + +CONTAINER_TYPES_ENDPOINT = '/container-types' + + +@dataclass_json +@dataclass +class ContainerType: + """Container type returned by the public API.""" + + id: str + model: str + name: str + instance_type: str + cpu: dict + gpu: dict + gpu_memory: dict + memory: dict + serverless_price: float + serverless_spot_price: float + currency: Currency + manufacturer: str + + +class ContainerTypesService: + """Service for interacting with container types.""" + + def __init__(self, http_client) -> None: + self._http_client = http_client + + def get(self, currency: Currency = 'usd') -> list[ContainerType]: + """Return all available container types.""" + container_types = self._http_client.get( + CONTAINER_TYPES_ENDPOINT, + params={'currency': currency}, + ).json() + return [ + ContainerType( + id=container_type['id'], + model=container_type['model'], + name=container_type['name'], + instance_type=container_type['instance_type'], + cpu=container_type['cpu'], + gpu=container_type['gpu'], + gpu_memory=container_type['gpu_memory'], + memory=container_type['memory'], + serverless_price=float(container_type['serverless_price']), + serverless_spot_price=float(container_type['serverless_spot_price']), + currency=container_type['currency'], + manufacturer=container_type['manufacturer'], + ) + for container_type in container_types + ] diff --git a/verda/helpers.py b/verda/helpers.py index 8bb4ff7..dc92c55 100644 --- a/verda/helpers.py +++ b/verda/helpers.py @@ -1,4 +1,5 @@ import json +from typing import Any def stringify_class_object_properties(class_object: type) -> str: @@ -15,3 +16,12 @@ def stringify_class_object_properties(class_object: type) -> str: if property[:1] != '_' and type(getattr(class_object, property, '')).__name__ != 'method' } return json.dumps(class_properties, indent=2) + + +def strip_none_values(data: Any) -> Any: + """Recursively remove ``None`` values from JSON-serializable data.""" + if isinstance(data, dict): + return {key: strip_none_values(value) for key, value in data.items() if value is not None} + if isinstance(data, list): + return [strip_none_values(item) for item in data] + return data diff --git a/verda/instance_types/_instance_types.py b/verda/instance_types/_instance_types.py index 322174d..b147f3d 100644 --- a/verda/instance_types/_instance_types.py +++ b/verda/instance_types/_instance_types.py @@ -2,6 +2,8 @@ from dataclasses_json import dataclass_json +from verda.constants import Currency + INSTANCE_TYPES_ENDPOINT = '/instance-types' @@ -11,16 +13,27 @@ class InstanceType: """Instance type. Attributes: - id: instance type id. - instance_type: instance type, e.g. '8V100.48M'. - price_per_hour: instance type price per hour. - spot_price_per_hour: instance type spot price per hour. - description: instance type description. - cpu: instance type cpu details. - gpu: instance type gpu details. - memory: instance type memory details. - gpu_memory: instance type gpu memory details. - storage: instance type storage details. + id: Instance type ID. + instance_type: Instance type, e.g. '8V100.48M'. + price_per_hour: Instance type price per hour. + spot_price_per_hour: Instance type spot price per hour. + description: Instance type description. + cpu: Instance type CPU details. + gpu: Instance type GPU details. + memory: Instance type memory details. + gpu_memory: Instance type GPU memory details. + storage: Instance type storage details. + best_for: Suggested use cases for the instance type. + model: GPU model. + name: Human-readable instance type name. + p2p: Peer-to-peer interconnect bandwidth details. + currency: Currency used for pricing. + manufacturer: Hardware manufacturer. + display_name: Display name shown to users. + supported_os: Supported operating system images. + deploy_warning: Optional deployment warning returned by the API. + serverless_price: Optional serverless price for the same hardware profile. + serverless_spot_price: Optional serverless spot price for the same hardware profile. """ id: str @@ -33,6 +46,17 @@ class InstanceType: memory: dict gpu_memory: dict storage: dict + best_for: list[str] + model: str + name: str + p2p: str + currency: Currency + manufacturer: str + display_name: str + supported_os: list[str] + deploy_warning: str | None = None + serverless_price: float | None = None + serverless_spot_price: float | None = None class InstanceTypesService: @@ -41,13 +65,16 @@ class InstanceTypesService: def __init__(self, http_client) -> None: self._http_client = http_client - def get(self) -> list[InstanceType]: + def get(self, currency: Currency = 'usd') -> list[InstanceType]: """Get all instance types. :return: list of instance type objects :rtype: list[InstanceType] """ - instance_types = self._http_client.get(INSTANCE_TYPES_ENDPOINT).json() + instance_types = self._http_client.get( + INSTANCE_TYPES_ENDPOINT, + params={'currency': currency}, + ).json() instance_type_objects = [ InstanceType( id=instance_type['id'], @@ -60,6 +87,25 @@ def get(self) -> list[InstanceType]: memory=instance_type['memory'], gpu_memory=instance_type['gpu_memory'], storage=instance_type['storage'], + best_for=instance_type['best_for'], + model=instance_type['model'], + name=instance_type['name'], + p2p=instance_type['p2p'], + currency=instance_type['currency'], + manufacturer=instance_type['manufacturer'], + display_name=instance_type['display_name'], + supported_os=instance_type['supported_os'], + deploy_warning=instance_type.get('deploy_warning'), + serverless_price=( + float(instance_type['serverless_price']) + if instance_type.get('serverless_price') is not None + else None + ), + serverless_spot_price=( + float(instance_type['serverless_spot_price']) + if instance_type.get('serverless_spot_price') is not None + else None + ), ) for instance_type in instance_types ] diff --git a/verda/job_deployments/__init__.py b/verda/job_deployments/__init__.py new file mode 100644 index 0000000..b5cf9f4 --- /dev/null +++ b/verda/job_deployments/__init__.py @@ -0,0 +1,15 @@ +from verda.job_deployments._job_deployments import ( + JobDeployment, + JobDeploymentsService, + JobDeploymentStatus, + JobDeploymentSummary, + JobScalingOptions, +) + +__all__ = [ + 'JobDeployment', + 'JobDeploymentStatus', + 'JobDeploymentSummary', + 'JobDeploymentsService', + 'JobScalingOptions', +] diff --git a/verda/job_deployments/_job_deployments.py b/verda/job_deployments/_job_deployments.py new file mode 100644 index 0000000..0a75e5a --- /dev/null +++ b/verda/job_deployments/_job_deployments.py @@ -0,0 +1,116 @@ +"""Serverless job deployment service for Verda.""" + +from dataclasses import dataclass, field +from enum import Enum + +from dataclasses_json import Undefined, dataclass_json + +from verda.containers import ComputeResource, Container, ContainerRegistrySettings +from verda.helpers import strip_none_values +from verda.http_client import HTTPClient + +JOB_DEPLOYMENTS_ENDPOINT = '/job-deployments' + + +class JobDeploymentStatus(str, Enum): + """Possible states of a job deployment.""" + + PAUSED = 'paused' + TERMINATING = 'terminating' + RUNNING = 'running' + + +@dataclass_json +@dataclass +class JobScalingOptions: + """Scaling configuration for a job deployment.""" + + max_replica_count: int + queue_message_ttl_seconds: int + deadline_seconds: int + + +@dataclass_json(undefined=Undefined.EXCLUDE) +@dataclass +class JobDeploymentSummary: + """Short job deployment information returned by the list endpoint.""" + + name: str + created_at: str + compute: ComputeResource + + +@dataclass_json(undefined=Undefined.EXCLUDE) +@dataclass +class JobDeployment: + """Configuration and metadata for a serverless job deployment.""" + + name: str + containers: list[Container] + compute: ComputeResource + scaling: JobScalingOptions | None = None + container_registry_settings: ContainerRegistrySettings = field( + default_factory=lambda: ContainerRegistrySettings(is_private=False) + ) + endpoint_base_url: str | None = None + created_at: str | None = None + + +class JobDeploymentsService: + """Service for managing serverless job deployments.""" + + def __init__(self, http_client: HTTPClient) -> None: + self._http_client = http_client + + def get(self) -> list[JobDeploymentSummary]: + """Return all job deployments.""" + response = self._http_client.get(JOB_DEPLOYMENTS_ENDPOINT) + return [JobDeploymentSummary.from_dict(job) for job in response.json()] + + def get_by_name(self, job_name: str) -> JobDeployment: + """Return a job deployment by name.""" + response = self._http_client.get(f'{JOB_DEPLOYMENTS_ENDPOINT}/{job_name}') + return JobDeployment.from_dict(response.json(), infer_missing=True) + + def create(self, deployment: JobDeployment) -> JobDeployment: + """Create a new job deployment.""" + response = self._http_client.post( + JOB_DEPLOYMENTS_ENDPOINT, + json=strip_none_values(deployment.to_dict()), + ) + return JobDeployment.from_dict(response.json(), infer_missing=True) + + def update(self, job_name: str, deployment: JobDeployment) -> JobDeployment: + """Update an existing job deployment.""" + response = self._http_client.patch( + f'{JOB_DEPLOYMENTS_ENDPOINT}/{job_name}', + json=strip_none_values(deployment.to_dict()), + ) + return JobDeployment.from_dict(response.json(), infer_missing=True) + + def delete(self, job_name: str, timeout: float | None = None) -> None: + """Delete a job deployment.""" + params = {'timeout': timeout} if timeout is not None else None + self._http_client.delete(f'{JOB_DEPLOYMENTS_ENDPOINT}/{job_name}', params=params) + + def get_status(self, job_name: str) -> JobDeploymentStatus: + """Return the current status for a job deployment.""" + response = self._http_client.get(f'{JOB_DEPLOYMENTS_ENDPOINT}/{job_name}/status') + return JobDeploymentStatus(response.json()['status']) + + def get_scaling_options(self, job_name: str) -> JobScalingOptions: + """Return scaling options for a job deployment.""" + response = self._http_client.get(f'{JOB_DEPLOYMENTS_ENDPOINT}/{job_name}/scaling') + return JobScalingOptions.from_dict(response.json()) + + def pause(self, job_name: str) -> None: + """Pause a job deployment.""" + self._http_client.post(f'{JOB_DEPLOYMENTS_ENDPOINT}/{job_name}/pause') + + def resume(self, job_name: str) -> None: + """Resume a job deployment.""" + self._http_client.post(f'{JOB_DEPLOYMENTS_ENDPOINT}/{job_name}/resume') + + def purge_queue(self, job_name: str) -> None: + """Purge the job deployment queue.""" + self._http_client.post(f'{JOB_DEPLOYMENTS_ENDPOINT}/{job_name}/purge-queue')