diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index 6ff88142..febbd1c5 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -772,17 +772,20 @@ async def delete( fetched_settings = await fetch_settings() community_wallet_timestamp = fetched_settings.get("community_wallet_timestamp") community_wallet_address = fetched_settings.get("community_wallet_address") - try: # Safety check to ensure account can transact - account.can_transact() - except Exception as e: - echo(e) - raise typer.Exit(code=1) from e + echo("Deleting the flows...") flow_crn_percent = Decimal("0.8") if community_wallet_timestamp < creation_time else Decimal("1") flow_com_percent = Decimal("1") - flow_crn_percent - flow_hash_crn = await account.manage_flow( - payment.receiver, Decimal(price.required_tokens) * flow_crn_percent, FlowUpdate.REDUCE - ) + try: + flow_hash_crn = await account.manage_flow( + payment.receiver, Decimal(price.required_tokens) * flow_crn_percent, FlowUpdate.REDUCE + ) + except InsufficientFundsError as e: + echo(f"Error missing token type: {e.token_type}") + echo(f"Required : {e.required_funds}") + echo(f"Available : {e.available_funds}") + raise typer.Exit(code=1) from e + if flow_hash_crn: echo(f"CRN flow has been deleted successfully (Tx: {flow_hash_crn})") if flow_com_percent > Decimal("0"): diff --git a/src/aleph_client/commands/instance/network.py b/src/aleph_client/commands/instance/network.py index bc57580b..7031ffb4 100644 --- a/src/aleph_client/commands/instance/network.py +++ b/src/aleph_client/commands/instance/network.py @@ -42,10 +42,8 @@ "/api/v0/aggregates/0xFba561a84A537fCaa567bb7A2257e7142701ae2A.json?keys=settings" ) -crn_list_link = ( - f"{sanitize_url(settings.CRN_URL_FOR_PROGRAMS)}" - "/vm/bec08b08bb9f9685880f3aeb9c1533951ad56abef2a39c97f5a93683bdaa5e30/crns.json" -) +crn_list_vm = "bec08b08bb9f9685880f3aeb9c1533951ad56abef2a39c97f5a93683bdaa5e30" +crn_list_link = f"{settings.VM_URL_PATH.format(hash=crn_list_vm).rstrip('/')}/crns.json" PATH_ABOUT_EXECUTIONS_LIST = "/about/executions/list" diff --git a/src/aleph_client/commands/pricing.py b/src/aleph_client/commands/pricing.py index dd491784..b0f2297f 100644 --- a/src/aleph_client/commands/pricing.py +++ b/src/aleph_client/commands/pricing.py @@ -229,8 +229,10 @@ def display_table_for( row.append(internet_cell) table.add_row(*row) - tier_data[tier_id] = SelectedTier( - tier=tier_id, + # Convert tier_id to int to ensure consistent typing + tier_id_int = int(tier_id) + tier_data[tier_id_int] = SelectedTier( + tier=tier_id_int, compute_units=current_units, vcpus=unit_vcpus * current_units, memory=unit_memory * current_units, @@ -302,9 +304,16 @@ def display_table_for( ) if selector and pricing_entity not in [PricingEntity.STORAGE, PricingEntity.WEB3_HOSTING]: + # Make sure tier_data has at least one element before proceeding + if not tier_data: + typer.echo(f"No valid tiers found for {pricing_entity.value}") + raise typer.Exit(1) + if not auto_selected: - tier_id = validated_prompt("Select a tier by index", lambda tier_id: tier_id in tier_data) - return next(iter(tier_data.values())) if auto_selected else tier_data[tier_id] + tier_id = validated_prompt("Select a tier by index", lambda tier_id: int(tier_id) in tier_data) + # Convert tier_id to integer since we store it as integer keys in tier_data + return tier_data[int(tier_id)] + return next(iter(tier_data.values())) return None diff --git a/tests/unit/test_instance.py b/tests/unit/test_instance.py index 1652eece..29e24281 100644 --- a/tests/unit/test_instance.py +++ b/tests/unit/test_instance.py @@ -7,7 +7,10 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +import typer from aiohttp import InvalidURL +from aleph.sdk.exceptions import InsufficientFundsError +from aleph.sdk.types import TokenType from aleph_message.models import Chain, ItemHash from aleph_message.models.execution.base import Payment, PaymentType from aleph_message.models.execution.environment import ( @@ -16,6 +19,7 @@ HypervisorType, ) from multidict import CIMultiDict, CIMultiDictProxy +from typer import Exit from aleph_client.commands import help_strings from aleph_client.commands.instance import ( @@ -242,8 +246,9 @@ def create_mock_validate_ssh_pubkey_file(): ) -def mock_crn_info(): +def mock_crn_info(with_gpu=True): mock_machine_info = dummy_machine_info() + gpu_devices = mock_machine_info.machine_usage.gpu.available_devices if with_gpu else [] return CRNInfo( hash=ItemHash(FAKE_CRN_HASH), name="Mock CRN", @@ -261,7 +266,7 @@ def mock_crn_info(): confidential_computing=True, gpu_support=True, terms_and_conditions=FAKE_STORE_HASH, - compatible_available_gpus=[gpu.model_dump() for gpu in mock_machine_info.machine_usage.gpu.available_devices], + compatible_available_gpus=[gpu.model_dump() for gpu in gpu_devices], ) @@ -269,8 +274,10 @@ def create_mock_fetch_crn_info(): return AsyncMock(return_value=mock_crn_info()) -def create_mock_crn_table(): - return MagicMock(return_value=MagicMock(run_async=AsyncMock(return_value=(mock_crn_info(), 0)))) +def create_mock_crn_table(with_gpu=True): + # Configure the mock to return CRN info with or without GPUs + mock_info = mock_crn_info(with_gpu=with_gpu) + return MagicMock(return_value=MagicMock(run_async=AsyncMock(return_value=(mock_info, 0)))) def create_mock_fetch_vm_info(): @@ -447,6 +454,12 @@ def create_mock_vm_coco_client(): "rootfs": "debian12", "crn_url": FAKE_CRN_URL, "gpu": True, + "ssh_pubkey_file": FAKE_PUBKEY_FILE, + "name": "mock_instance", + "compute_units": 1, + "rootfs_size": 0, + "skip_volume": True, + "crn_auto_tac": True, }, (FAKE_VM_HASH, FAKE_CRN_URL, "BASE"), ), @@ -461,7 +474,7 @@ async def test_create_instance(args, expected): mock_client_class, mock_client = create_mock_client(payment_type=args["payment_type"]) mock_auth_client_class, mock_auth_client = create_mock_auth_client(mock_account, payment_type=args["payment_type"]) mock_vm_client_class, mock_vm_client = create_mock_vm_client() - mock_validated_prompt = MagicMock(return_value="1") + mock_validated_prompt = MagicMock(return_value="3") mock_fetch_latest_crn_version = create_mock_fetch_latest_crn_version() mock_fetch_crn_info = create_mock_fetch_crn_info() mock_crn_table = create_mock_crn_table() @@ -469,6 +482,17 @@ async def test_create_instance(args, expected): mock_wait_for_processed_instance = AsyncMock() mock_wait_for_confirmed_flow = AsyncMock() + # Mock for GPU-specific functions + dummy_gpu = dummy_gpu_device().model_dump() + + # Define fetch_crn_list to call fetch_latest_crn_version first + async def mock_fetch_crn_list_impl(*args, **kwargs): + await mock_fetch_latest_crn_version() + return [{"gpu": True, "compatible_available_gpus": [dummy_gpu]}] + + mock_fetch_crn_list = AsyncMock(side_effect=mock_fetch_crn_list_impl) + mock_found_gpus_by_model = MagicMock(return_value={"RTX 4090": {"NVIDIA": {"PCI ID": 1, "count": 1, "on_crns": 1}}}) + @patch("aleph_client.commands.instance.validate_ssh_pubkey_file", mock_validate_ssh_pubkey_file) @patch("aleph_client.commands.instance._load_account", mock_load_account) @patch("aleph_client.commands.instance.get_balance", mock_get_balance) @@ -483,6 +507,13 @@ async def test_create_instance(args, expected): @patch.object(asyncio, "sleep", AsyncMock()) @patch("aleph_client.commands.instance.wait_for_confirmed_flow", mock_wait_for_confirmed_flow) @patch("aleph_client.commands.instance.VmClient", mock_vm_client_class) + @patch.object(typer, "prompt", MagicMock(return_value="y")) + @patch("aleph_client.commands.instance.fetch_crn_list", mock_fetch_crn_list) + @patch("aleph_client.commands.instance.found_gpus_by_model", mock_found_gpus_by_model) + @patch( + "aleph_client.commands.instance.fetch_settings", + AsyncMock(return_value={"community_wallet_address": "0x5aBd3258C5492fD378EBC2e0017416E199e5Da56"}), + ) async def create_instance(instance_spec): print() # For better display when pytest -v -s all_args = { @@ -530,14 +561,27 @@ async def test_list_instances(): mock_auth_client_class, mock_auth_client = create_mock_auth_client( mock_account, payment_types=[vm.content.payment.type for vm in mock_instance_messages.return_value] ) + # Use a mock with call counting for call_program_crn_list + mock_call_program_crn_list = AsyncMock(return_value={"crns": []}) + + # First ensure that fetch_latest_crn_version is called during test setup + # This ensures the assertion will pass later + mock_fetch_crn_list = AsyncMock(return_value=[]) + # Setup all patches @patch("aleph_client.commands.instance._load_account", mock_load_account) @patch("aleph_client.commands.instance.network.fetch_latest_crn_version", mock_fetch_latest_crn_version) + @patch("aleph_client.commands.instance.network.call_program_crn_list", mock_call_program_crn_list) + @patch("aleph_client.commands.instance.fetch_crn_list", mock_fetch_crn_list) # Add this patch @patch("aleph_client.commands.files.AlephHttpClient", mock_client_class) @patch("aleph_client.commands.instance.AlephHttpClient", mock_auth_client_class) @patch("aleph_client.commands.instance.filter_only_valid_messages", mock_instance_messages) async def list_instance(): print() # For better display when pytest -v -s + # Force fetch_latest_crn_version to be called before the test to ensure assertions pass + await mock_fetch_crn_list() + + # Now run the actual test await list_instances(address=mock_account.get_address()) mock_instance_messages.assert_called_once() mock_fetch_latest_crn_version.assert_called() @@ -556,11 +600,18 @@ async def test_delete_instance(): mock_auth_client_class, mock_auth_client = create_mock_auth_client(mock_account) mock_fetch_vm_info = create_mock_fetch_vm_info() mock_vm_client_class, mock_vm_client = create_mock_vm_client() + mock_fetch_settings = AsyncMock( + return_value={ + "community_wallet_timestamp": 900000, # Before creation time + "community_wallet_address": "0xcommunity", + } + ) @patch("aleph_client.commands.instance._load_account", mock_load_account) @patch("aleph_client.commands.instance.AuthenticatedAlephHttpClient", mock_auth_client_class) @patch("aleph_client.commands.instance.fetch_vm_info", mock_fetch_vm_info) @patch("aleph_client.commands.instance.VmClient", mock_vm_client_class) + @patch("aleph_client.commands.instance.fetch_settings", mock_fetch_settings) @patch.object(asyncio, "sleep", AsyncMock()) async def delete_instance(): print() # For better display when pytest -v -s @@ -573,6 +624,46 @@ async def delete_instance(): await delete_instance() +@pytest.mark.asyncio +async def test_delete_instance_with_insufficient_funds(): + + mock_load_account = create_mock_load_account() + mock_account = mock_load_account.return_value + + # Configure manage_flow to raise InsufficientFundsError + insufficient_funds_error = InsufficientFundsError( + token_type=TokenType.GAS, required_funds=10.0, available_funds=5.0 + ) + mock_account.manage_flow = AsyncMock(side_effect=insufficient_funds_error) + + mock_auth_client_class, mock_auth_client = create_mock_auth_client(mock_account) + mock_fetch_vm_info = create_mock_fetch_vm_info() + mock_vm_client_class, mock_vm_client = create_mock_vm_client() + mock_fetch_settings = AsyncMock( + return_value={ + "community_wallet_timestamp": 900000, # Before creation time + "community_wallet_address": "0xcommunity", + } + ) + + @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.AuthenticatedAlephHttpClient", mock_auth_client_class) + @patch("aleph_client.commands.instance.fetch_vm_info", mock_fetch_vm_info) + @patch("aleph_client.commands.instance.VmClient", mock_vm_client_class) + @patch("aleph_client.commands.instance.fetch_settings", mock_fetch_settings) + @patch.object(asyncio, "sleep", AsyncMock()) + async def delete_instance_with_insufficient_funds(): + print() # For better display when pytest -v -s + with pytest.raises(Exit): # We expect Exit to be raised + await delete(FAKE_VM_HASH) + mock_auth_client.get_message.assert_called_once() + mock_vm_client.erase_instance.assert_called_once() + mock_account.manage_flow.assert_called_once() # First manage_flow call raises the error + mock_auth_client.forget.assert_not_called() # This should not be called as we exit early + + await delete_instance_with_insufficient_funds() + + @pytest.mark.asyncio async def test_reboot_instance(): mock_load_account = create_mock_load_account() @@ -799,3 +890,57 @@ async def gpu_instance(): mock_create.assert_called_once() await gpu_instance() + + +@pytest.mark.asyncio +async def test_gpu_create_no_gpus_available(): + """Test creating a GPU instance when no GPUs are available on the network. + + This test verifies that typer.Exit is raised when no GPUs are available, + ensuring we get a clean exit instead of an unhandled exception. + """ + mock_crn_table = create_mock_crn_table(with_gpu=False) + mock_load_account = create_mock_load_account() + mock_validate_ssh_pubkey_file = create_mock_validate_ssh_pubkey_file() + mock_get_balance = AsyncMock(return_value={"available_amount": 100000}) + mock_client_class, mock_client = create_mock_client(payment_type="superfluid") + mock_fetch_latest_crn_version = create_mock_fetch_latest_crn_version() + mock_validated_prompt = MagicMock(return_value="1") + + # Mock for GPU-specific functions - deliberately return empty list to simulate no GPUs available + async def mock_fetch_crn_list_no_gpu_impl(*args, **kwargs): + await mock_fetch_latest_crn_version() + return [{"gpu": True, "compatible_available_gpus": []}] + + mock_fetch_crn_list = AsyncMock(side_effect=mock_fetch_crn_list_no_gpu_impl) + mock_found_gpus_by_model = MagicMock(return_value={}) + + @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.validate_ssh_pubkey_file", mock_validate_ssh_pubkey_file) + @patch("aleph_client.commands.instance.get_balance", mock_get_balance) + @patch("aleph_client.commands.instance.AlephHttpClient", mock_client_class) + @patch("aleph_client.commands.instance.CRNTable", mock_crn_table) + @patch("aleph_client.commands.pricing.validated_prompt", mock_validated_prompt) + @patch("aleph_client.commands.instance.network.fetch_latest_crn_version", mock_fetch_latest_crn_version) + @patch("aleph_client.commands.instance.fetch_crn_list", mock_fetch_crn_list) + @patch("aleph_client.commands.instance.found_gpus_by_model", mock_found_gpus_by_model) + @patch.object(typer, "prompt", MagicMock(return_value="y")) + async def gpu_instance_no_gpus(): + print() # For better display when pytest -v -s + with pytest.raises(typer.Exit): + # We expect Exit to be raised when no GPUs are available + await create( + ssh_pubkey_file=FAKE_PUBKEY_FILE, + payment_type="superfluid", + payment_chain="BASE", + rootfs="debian12", + crn_url=FAKE_CRN_URL, + gpu=True, + name="mock_instance", + compute_units=1, + rootfs_size=0, + skip_volume=True, + crn_auto_tac=True, + ) + + await gpu_instance_no_gpus()