Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions examples/clusters_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""
Example demonstrating how to use the Clusters API.

This example shows how to:
- Create a new compute cluster
- List all clusters
- Get a specific cluster by ID
- Get cluster nodes
- Scale a cluster
- Delete a cluster
"""

import os

from verda import VerdaClient
from verda.constants import Actions, Locations
Comment thread
shamrin marked this conversation as resolved.
Outdated

# Get credentials from environment variables
CLIENT_ID = os.environ.get('VERDA_CLIENT_ID')
CLIENT_SECRET = os.environ.get('VERDA_CLIENT_SECRET')

# Create client
verda = VerdaClient(CLIENT_ID, CLIENT_SECRET)


def create_cluster_example():
"""Create a new compute cluster."""
# Get SSH keys
ssh_keys = [key.id for key in verda.ssh_keys.get()]
Comment thread
shamrin marked this conversation as resolved.

# Create a cluster with 3 nodes
cluster = verda.clusters.create(
hostname='my-compute-cluster',
cluster_type='16H200',
image='ubuntu-22.04-cuda-12.4-cluster',
description='Example compute cluster for distributed training',
ssh_key_ids=ssh_keys,
location=Locations.FIN_03,
shared_volume_name='my-shared-volume',
shared_volume_size=30000,
)

print(f'Created cluster: {cluster.id}')
print(f'Cluster hostname: {cluster.hostname}')
print(f'Cluster status: {cluster.status}')
print(f'Cluster cluster_type: {cluster.cluster_type}')
print(f'Cluster worker_nodes: {cluster.worker_nodes}')
print(f'Location: {cluster.location}')

return cluster


def list_clusters_example():
"""List all clusters."""
# Get all clusters
clusters = verda.clusters.get()

print(f'\nFound {len(clusters)} cluster(s):')
for cluster in clusters:
print(
f' - {cluster.hostname} ({cluster.id}): {cluster.status} - {len(cluster.worker_nodes)} nodes'
)

# Get clusters with specific status
running_clusters = verda.clusters.get(status=verda.constants.cluster_status.RUNNING)
Comment thread
shamrin marked this conversation as resolved.
Outdated
print(f'\nFound {len(running_clusters)} running cluster(s)')

return clusters


def get_cluster_by_id_example(cluster_id: str):
"""Get a specific cluster by ID."""
cluster = verda.clusters.get_by_id(cluster_id)

print('\nCluster details:')
print(f' ID: {cluster.id}')
print(f' Name: {cluster.hostname}')
print(f' Description: {cluster.description}')
print(f' Status: {cluster.status}')
print(f' Cluster type: {cluster.cluster_type}')
print(f' Created at: {cluster.created_at}')
print(f' Public IP: {cluster.ip}')
print(f' Worker nodes: {len(cluster.worker_nodes)}')

return cluster


def delete_cluster_example(cluster_id: str):
"""Delete a cluster."""
print(f'\nDeleting cluster {cluster_id}...')

verda.clusters.action(cluster_id, Actions.DELETE)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe nicer to use the delete method (or both)


print('Cluster deleted successfully')


def main():
"""Run all cluster examples."""
print('=== Clusters API Example ===\n')

# Create a new cluster
print('1. Creating a new cluster...')
cluster = create_cluster_example()
cluster_id = cluster.id

# List all clusters
print('\n2. Listing all clusters...')
list_clusters_example()

# Get cluster by ID
print('\n3. Getting cluster details...')
get_cluster_by_id_example(cluster_id)

# Delete the cluster
print('\n6. Deleting the cluster...')
Comment thread
shamrin marked this conversation as resolved.
Outdated
delete_cluster_example(cluster_id)

print('\n=== Example completed successfully ===')


if __name__ == '__main__':
main()
3 changes: 1 addition & 2 deletions tests/integration_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@
Make sure to run the server and the account has enough balance before running the tests
"""

BASE_URL = 'http://localhost:3010/v1'

# Load env variables, make sure there's an env file with valid client credentials
load_dotenv()
CLIENT_SECRET = os.getenv('VERDA_CLIENT_SECRET')
CLIENT_ID = os.getenv('VERDA_CLIENT_ID')
BASE_URL = os.getenv('VERDA_BASE_URL', 'http://localhost:3010/v1')


@pytest.fixture
Expand Down
44 changes: 44 additions & 0 deletions tests/integration_tests/test_clusters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import os

import pytest

from verda import VerdaClient
from verda.constants import Locations

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


@pytest.mark.skipif(IN_GITHUB_ACTIONS, reason="Test doesn't work in Github Actions.")
@pytest.mark.withoutresponses
class TestClusters:
def test_create_cluster(self, verda_client: VerdaClient):
# get ssh key
ssh_key = verda_client.ssh_keys.get()[0]

# create instance
cluster = verda_client.clusters.create(
hostname='test-instance',
location=Locations.FIN_03,
cluster_type='16B200',
description='test instance',
image='ubuntu-22.04-cuda-12.8-cluster',
ssh_key_ids=[ssh_key.id],
)

# assert instance is created
assert cluster.id is not None
assert (
cluster.status == verda_client.constants.instance_status.PROVISIONING
Comment thread
huksley marked this conversation as resolved.
Outdated
or cluster.status == verda_client.constants.instance_status.RUNNING
Comment thread
huksley marked this conversation as resolved.
Outdated
)

# If still provisioning, we don't have worker nodes yet and ip is not available
if cluster.status != verda_client.constants.instance_status.PROVISIONING:
Comment thread
huksley marked this conversation as resolved.
assert cluster.worker_nodes is not None
assert len(cluster.worker_nodes) == 2
assert cluster.ip is not None

print(cluster)
Comment thread
huksley marked this conversation as resolved.
Outdated

# delete instance
# verda_client.clusters.action(cluster.id, 'delete')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uncomment?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't delete really until it is running but this is 20 minutes wait usually

Empty file.
180 changes: 180 additions & 0 deletions tests/unit_tests/clusters/test_clusters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import pytest
import responses # https://github.com/getsentry/responses

from verda.clusters import Cluster, ClustersService, ClusterWorkerNode
from verda.constants import ErrorCodes, Locations
from verda.exceptions import APIException

INVALID_REQUEST = ErrorCodes.INVALID_REQUEST
INVALID_REQUEST_MESSAGE = 'Invalid request'

CLUSTER_ID = 'deadc0de-a5d2-4972-ae4e-d429115d055b'
SSH_KEY_ID = '12345dc1-a5d2-4972-ae4e-d429115d055b'

CLUSTER_HOSTNAME = 'test-cluster'
CLUSTER_DESCRIPTION = 'Test compute cluster'
CLUSTER_STATUS = 'running'
CLUSTER_CLUSTER_TYPE = '16H200'
CLUSTER_NODE_COUNT = 2
CLUSTER_LOCATION = Locations.FIN_03
CLUSTER_IMAGE = 'ubuntu-22.04-cuda-12.4-cluster'
CLUSTER_CREATED_AT = '2024-01-01T00:00:00Z'
CLUSTER_IP = '10.0.0.1'

NODE_1_ID = 'node1-c0de-a5d2-4972-ae4e-d429115d055b'
NODE_2_ID = 'node2-c0de-a5d2-4972-ae4e-d429115d055b'

NODES_PAYLOAD = [
{
'id': NODE_1_ID,
'status': 'running',
'hostname': 'test-cluster-node-1',
'private_ip': '10.0.0.1',
},
{
'id': NODE_2_ID,
'status': 'running',
'hostname': 'test-cluster-node-2',
'private_ip': '10.0.0.2',
},
]

CLUSTER_PAYLOAD = [
{
'id': CLUSTER_ID,
'hostname': CLUSTER_HOSTNAME,
'description': CLUSTER_DESCRIPTION,
'status': CLUSTER_STATUS,
'created_at': CLUSTER_CREATED_AT,
'location': CLUSTER_LOCATION,
'cluster_type': CLUSTER_CLUSTER_TYPE,
'worker_nodes': NODES_PAYLOAD,
'ssh_key_ids': [SSH_KEY_ID],
'image': CLUSTER_IMAGE,
'ip': CLUSTER_IP,
}
]


class TestClustersService:
@pytest.fixture
def clusters_service(self, http_client):
return ClustersService(http_client)

@pytest.fixture
def endpoint(self, http_client):
return http_client._base_url + '/clusters'

def test_get_clusters(self, clusters_service, endpoint):
# arrange - add response mock
responses.add(responses.GET, endpoint, json=CLUSTER_PAYLOAD, status=200)

# act
clusters = clusters_service.get()
cluster = clusters[0]

# assert
assert isinstance(clusters, list)
assert len(clusters) == 1
assert isinstance(cluster, Cluster)
assert cluster.id == CLUSTER_ID
assert cluster.hostname == CLUSTER_HOSTNAME
assert cluster.description == CLUSTER_DESCRIPTION
assert cluster.status == CLUSTER_STATUS
assert cluster.created_at == CLUSTER_CREATED_AT
assert cluster.location == CLUSTER_LOCATION
assert cluster.cluster_type == CLUSTER_CLUSTER_TYPE
assert isinstance(cluster.worker_nodes, list)
assert len(cluster.worker_nodes) == CLUSTER_NODE_COUNT
assert isinstance(cluster.worker_nodes[0], ClusterWorkerNode)
assert cluster.ssh_key_ids == [SSH_KEY_ID]
assert cluster.image == CLUSTER_IMAGE
assert cluster.ip == CLUSTER_IP
assert responses.assert_call_count(endpoint, 1) is True

def test_create_cluster_successful(self, clusters_service, endpoint):
# arrange - add response mock
# create cluster
responses.add(responses.POST, endpoint, json={'id': CLUSTER_ID}, status=200)
# get cluster by id
url = endpoint + '/' + CLUSTER_ID
responses.add(responses.GET, url, json=CLUSTER_PAYLOAD[0], status=200)

# act
cluster = clusters_service.create(
hostname=CLUSTER_HOSTNAME,
cluster_type=CLUSTER_CLUSTER_TYPE,
image=CLUSTER_IMAGE,
description=CLUSTER_DESCRIPTION,
ssh_key_ids=[SSH_KEY_ID],
location=CLUSTER_LOCATION,
)

# assert
assert isinstance(cluster, Cluster)
assert cluster.id == CLUSTER_ID
assert cluster.hostname == CLUSTER_HOSTNAME
assert cluster.description == CLUSTER_DESCRIPTION
assert cluster.status == CLUSTER_STATUS
assert cluster.cluster_type == CLUSTER_CLUSTER_TYPE
assert len(cluster.worker_nodes) == CLUSTER_NODE_COUNT
assert cluster.ssh_key_ids == [SSH_KEY_ID]
assert cluster.location == CLUSTER_LOCATION
assert cluster.image == CLUSTER_IMAGE
assert responses.assert_call_count(endpoint, 1) is True
assert responses.assert_call_count(url, 1) is True

def test_create_cluster_failed(self, clusters_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:
clusters_service.create(
hostname=CLUSTER_HOSTNAME,
cluster_type=CLUSTER_CLUSTER_TYPE,
image=CLUSTER_IMAGE,
description=CLUSTER_DESCRIPTION,
ssh_key_ids=[SSH_KEY_ID],
location=CLUSTER_LOCATION,
)

# 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_delete_cluster_successful(self, clusters_service, endpoint):
# arrange - add response mock
url = endpoint
responses.add(responses.PUT, url, status=202)
Comment thread
shamrin marked this conversation as resolved.
Outdated

# act
result = clusters_service.delete(CLUSTER_ID)

# assert
assert result is None
assert responses.assert_call_count(url, 1) is True

def test_delete_cluster_failed(self, clusters_service, endpoint):
# arrange - add response mock
responses.add(
responses.PUT,
endpoint,
json={'code': INVALID_REQUEST, 'message': INVALID_REQUEST_MESSAGE},
status=400,
)

# act
with pytest.raises(APIException) as excinfo:
clusters_service.delete('invalid_id')

# assert
assert excinfo.value.code == INVALID_REQUEST
assert excinfo.value.message == INVALID_REQUEST_MESSAGE
assert responses.assert_call_count(endpoint, 1) is True
4 changes: 4 additions & 0 deletions verda/_verda.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from verda._version import __version__
from verda.authentication import AuthenticationService
from verda.balance import BalanceService
from verda.clusters import ClustersService
from verda.constants import Constants
from verda.containers import ContainersService
from verda.http_client import HTTPClient
Expand Down Expand Up @@ -79,5 +80,8 @@ def __init__(
self.containers: ContainersService = ContainersService(self._http_client, inference_key)
"""Containers service. Deploy, manage, and monitor container deployments"""

self.clusters: ClustersService = ClustersService(self._http_client)
"""Clusters service. Create, manage, and scale compute clusters"""
Comment thread
shamrin marked this conversation as resolved.
Outdated


__all__ = ['VerdaClient']
5 changes: 5 additions & 0 deletions verda/clusters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Clusters service for managing compute clusters."""

from verda.clusters._clusters import Cluster, ClustersService, ClusterWorkerNode

__all__ = ['Cluster', 'ClusterWorkerNode', 'ClustersService']
Loading