Skip to content

Fix: Estimated cost based on TX and not hardcoded value #359

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

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 11 additions & 8 deletions src/aleph_client/commands/instance/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
6 changes: 2 additions & 4 deletions src/aleph_client/commands/instance/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
17 changes: 13 additions & 4 deletions src/aleph_client/commands/pricing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
155 changes: 150 additions & 5 deletions tests/unit/test_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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 (
Expand Down Expand Up @@ -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",
Expand All @@ -261,16 +266,18 @@ 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],
)


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():
Expand Down Expand Up @@ -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"),
),
Expand All @@ -461,14 +474,25 @@ 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()
mock_yes_no_input = MagicMock(side_effect=[False, True, True])
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)
Expand All @@ -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 = {
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Loading