-
Notifications
You must be signed in to change notification settings - Fork 13
feat: cluster API support for Verda Cloud Python SDK #70
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
0d0e7ab
d6b9918
814d02e
99668e8
7f86615
471e089
f009010
d2c3a04
f5c275c
7f9a1c5
0a94fab
4f52e85
f57532b
1e53597
f65b848
b4c5e67
d3a6203
b6c08c8
a9e3e60
c3ddff6
1c6ffd3
7597d89
37f9c32
09e138b
e34d36b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
| # 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()] | ||
|
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) | ||
|
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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe nicer to use the |
||
|
|
||
| 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...') | ||
|
shamrin marked this conversation as resolved.
Outdated
|
||
| delete_cluster_example(cluster_id) | ||
|
|
||
| print('\n=== Example completed successfully ===') | ||
|
|
||
|
|
||
| if __name__ == '__main__': | ||
| main() | ||
| 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 | ||
|
huksley marked this conversation as resolved.
Outdated
|
||
| or cluster.status == verda_client.constants.instance_status.RUNNING | ||
|
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: | ||
|
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) | ||
|
huksley marked this conversation as resolved.
Outdated
|
||
|
|
||
| # delete instance | ||
| # verda_client.clusters.action(cluster.id, 'delete') | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. uncomment?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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) | ||
|
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 | ||
| 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'] |
Uh oh!
There was an error while loading. Please reload this page.