Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/aleph_client/commands/help_strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
SSH_PUBKEY_FILE = "Path to a public ssh key to be added to the instance"
CRN_HASH = "Hash of the CRN to deploy to (only applicable for confidential and/or Pay-As-You-Go instances)"
CRN_URL = "URL of the CRN to deploy to (only applicable for confidential and/or Pay-As-You-Go instances)"
CRN_AUTO_TAC = "Automatically accept the Terms & Conditions of the CRN if you read them beforehand"
CONFIDENTIAL_OPTION = "Launch a confidential instance (requires creating an encrypted volume)"
CONFIDENTIAL_FIRMWARE = "Hash to UEFI Firmware to launch confidential instance"
CONFIDENTIAL_FIRMWARE_HASH = "Hash of the UEFI Firmware content, to validate measure (ignored if path is provided)"
Expand Down
32 changes: 30 additions & 2 deletions src/aleph_client/commands/instance/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
None,
help=help_strings.IMMUTABLE_VOLUME,
),
crn_auto_tac: bool = typer.Option(False, help=help_strings.CRN_AUTO_TAC),
channel: Optional[str] = typer.Option(default=settings.DEFAULT_CHANNEL, help=help_strings.CHANNEL),
private_key: Optional[str] = typer.Option(settings.PRIVATE_KEY_STRING, help=help_strings.PRIVATE_KEY),
private_key_file: Optional[Path] = typer.Option(settings.PRIVATE_KEY_FILE, help=help_strings.PRIVATE_KEY_FILE),
Expand Down Expand Up @@ -337,7 +338,7 @@
if crn_url and crn_hash:
crn_url = sanitize_url(crn_url)
try:
crn_name, score, reward_addr = "?", 0, ""
crn_name, score, reward_addr, terms_and_conditions = "?", 0, "", None
nodes: NodeInfo = await _fetch_nodes()
for node in nodes.nodes:
found_node, hash_match = None, False
Expand All @@ -353,6 +354,7 @@
crn_name = found_node["name"]
score = found_node["score"]
reward_addr = found_node["stream_reward"]
terms_and_conditions = node["terms_and_conditions"]
break
else:
echo(
Expand All @@ -379,6 +381,7 @@
crn_info.get("computing", {}).get("ENABLE_CONFIDENTIAL_COMPUTING", False)
),
gpu_support=bool(crn_info.get("computing", {}).get("ENABLE_GPU_SUPPORT", False)),
terms_and_conditions=terms_and_conditions,
)
crn.display_crn_specs()
except Exception as e:
Expand Down Expand Up @@ -459,8 +462,20 @@
device_id=selected_gpu.device_id,
)
]
if crn.terms_and_conditions:
accepted = await crn.display_terms_and_conditions(auto_accept=crn_auto_tac)
if accepted is None:
echo("Failed to fetch terms and conditions.\nContact support or use a different CRN.")
raise typer.Exit(1)

Check warning on line 469 in src/aleph_client/commands/instance/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/aleph_client/commands/instance/__init__.py#L468-L469

Added lines #L468 - L469 were not covered by tests
elif not accepted:
echo("Terms & Conditions rejected: instance creation aborted.")
raise typer.Exit(1)

Check warning on line 472 in src/aleph_client/commands/instance/__init__.py

View check run for this annotation

Codecov / codecov/patch

src/aleph_client/commands/instance/__init__.py#L471-L472

Added lines #L471 - L472 were not covered by tests
echo("Terms & Conditions accepted.")
requirements = HostRequirements(
node=NodeRequirements(node_hash=crn.hash),
node=NodeRequirements(
node_hash=crn.hash,
terms_and_conditions=(ItemHash(crn.terms_and_conditions) if crn.terms_and_conditions else None),
),
gpu=gpu_requirement,
)

Expand Down Expand Up @@ -808,6 +823,17 @@
Text(info["ipv6_logs"]),
style="bright_yellow" if len(info["ipv6_logs"].split(":")) == 8 else "dark_orange",
),
(
Text.assemble(
Text(f"\n[{'✅' if info['tac_accepted'] else '❌'}] Accepted Terms & Conditions: "),
Text(
f"{info['tac_url']}",
style="orange1",
),
)
if info["tac_hash"]
else ""
),
)
table.add_row(instance, specifications, status_column)
table.add_section()
Expand Down Expand Up @@ -1207,6 +1233,7 @@
None,
help=help_strings.IMMUTABLE_VOLUME,
),
crn_auto_tac: bool = typer.Option(False, help=help_strings.CRN_AUTO_TAC),
channel: Optional[str] = typer.Option(default=settings.DEFAULT_CHANNEL, help=help_strings.CHANNEL),
private_key: Optional[str] = typer.Option(settings.PRIVATE_KEY_STRING, help=help_strings.PRIVATE_KEY),
private_key_file: Optional[Path] = typer.Option(settings.PRIVATE_KEY_FILE, help=help_strings.PRIVATE_KEY_FILE),
Expand Down Expand Up @@ -1239,6 +1266,7 @@
ssh_pubkey_file=ssh_pubkey_file,
crn_hash=crn_hash,
crn_url=crn_url,
crn_auto_tac=crn_auto_tac,
confidential=True,
confidential_firmware=confidential_firmware,
gpu=gpu,
Expand Down
6 changes: 6 additions & 0 deletions src/aleph_client/commands/instance/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
self.table.add_column("Free RAM 🌡", key="ram")
self.table.add_column("Free Disk 💿", key="hdd")
self.table.add_column("URL", key="url")
self.table.add_column("Terms & Conditions 📝", key="tac")

Check warning on line 75 in src/aleph_client/commands/instance/display.py

View check run for this annotation

Codecov / codecov/patch

src/aleph_client/commands/instance/display.py#L75

Added line #L75 was not covered by tests
yield Label("Choose a Compute Resource Node (CRN) to run your instance")
with Horizontal():
self.loader_label_start = Label(self.label_start)
Expand Down Expand Up @@ -103,6 +104,7 @@
qemu_support=None,
confidential_computing=None,
gpu_support=None,
terms_and_conditions=node["terms_and_conditions"],
)

# Initialize the progress bar
Expand Down Expand Up @@ -161,6 +163,9 @@
return
self.filtered_crns += 1

# Fetch terms and conditions
tac = await node.terms_and_conditions_content

Check warning on line 167 in src/aleph_client/commands/instance/display.py

View check run for this annotation

Codecov / codecov/patch

src/aleph_client/commands/instance/display.py#L167

Added line #L167 was not covered by tests

self.table.add_row(
_format_score(node.score),
node.name,
Expand All @@ -173,6 +178,7 @@
node.display_ram,
node.display_hdd,
node.url,
tac.url if tac else "✖",
key=node.hash,
)

Expand Down
10 changes: 10 additions & 0 deletions src/aleph_client/commands/instance/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from typer import Exit

from aleph_client.commands import help_strings
from aleph_client.commands.files import download
from aleph_client.commands.node import NodeInfo, _fetch_nodes
from aleph_client.models import MachineUsage
from aleph_client.utils import fetch_json, sanitize_url
Expand Down Expand Up @@ -91,6 +92,7 @@ async def fetch_vm_info(message: InstanceMessage, node_list: NodeInfo) -> tuple[
firmware = safe_getattr(message, "content.environment.trusted_execution.firmware")
is_confidential = firmware and len(firmware) == 64
has_gpu = safe_getattr(message, "content.requirements.gpu")
tac_hash = safe_getattr(message, "content.requirements.node.terms_and_conditions")

info = dict(
crn_hash=str(crn_hash) if crn_hash else "",
Expand All @@ -101,6 +103,9 @@ async def fetch_vm_info(message: InstanceMessage, node_list: NodeInfo) -> tuple[
allocation_type="",
ipv6_logs="",
crn_url="",
tac_hash=str(tac_hash) if tac_hash else "",
tac_url="",
tac_accepted="",
)
try:
# Fetch from the scheduler API directly if no payment or no receiver (hold-tier non-confidential)
Expand Down Expand Up @@ -137,6 +142,11 @@ async def fetch_vm_info(message: InstanceMessage, node_list: NodeInfo) -> tuple[
info["crn_url"] = help_strings.CRN_UNKNOWN
if not info["ipv6_logs"]:
info["ipv6_logs"] = help_strings.VM_NOT_READY
# Terms and conditions
if tac_hash:
tac = await download(tac_hash, only_info=True, verbose=False)
tac_url = safe_getattr(tac, "url") or f"missing → {tac_hash}"
info.update(dict(tac_url=tac_url, tac_accepted="Yes"))
except (aiohttp.ClientResponseError, aiohttp.ClientConnectorError) as e:
info["ipv6_logs"] = f"Not available. Server error: {e}"
return message.item_hash, info
Expand Down
38 changes: 38 additions & 0 deletions src/aleph_client/models.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
from datetime import datetime
from typing import List, Optional

from aleph.sdk.types import StoredContent
from aleph_message.models import ItemHash
from aleph_message.models.execution.environment import CpuProperties, GpuDeviceClass
from pydantic import BaseModel
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Prompt
from rich.text import Text
from typer import echo

from aleph_client.commands.files import download
from aleph_client.commands.node import _escape_and_normalize, _remove_ansi_escape


Expand Down Expand Up @@ -131,6 +135,7 @@
qemu_support: Optional[bool]
confidential_computing: Optional[bool]
gpu_support: Optional[bool]
terms_and_conditions: Optional[str]

@property
def display_cpu(self) -> str:
Expand All @@ -150,6 +155,32 @@
return f"{self.machine_usage.disk.available_kB / 1_000_000:>4.0f} / {self.machine_usage.disk.total_kB / 1_000_000:>4.0f} GB"
return ""

@property
async def terms_and_conditions_content(self) -> Optional[StoredContent]:
if self.terms_and_conditions:
return await download(self.terms_and_conditions, only_info=True, verbose=False)
return None

Check warning on line 162 in src/aleph_client/models.py

View check run for this annotation

Codecov / codecov/patch

src/aleph_client/models.py#L162

Added line #L162 was not covered by tests

async def display_terms_and_conditions(self, auto_accept: bool = False) -> Optional[bool]:
if self.terms_and_conditions:
tac = await self.terms_and_conditions_content
if tac:
text = Text.assemble(
"The selected CRN requires you to accept the following conditions and terms of use:\n",
f"Filename: {tac.filename}\n" if tac.filename else "",
Text.from_markup(f"↳ [orange1]{tac.url}[/orange1]"),
)
console = Console()
console.print(
Panel(text, title="Terms & Conditions", border_style="blue", expand=False, title_align="left")
)

if auto_accept:
echo("To proceed, enter “Yes I read and accept”: Yes I read and accept")
return True
return Prompt.ask("To proceed, enter “Yes I read and accept”").lower() == "yes i read and accept"
return None

Check warning on line 182 in src/aleph_client/models.py

View check run for this annotation

Codecov / codecov/patch

src/aleph_client/models.py#L181-L182

Added lines #L181 - L182 were not covered by tests

def display_crn_specs(self):
console = Console()

Expand All @@ -172,6 +203,13 @@
"Support Qemu": self.qemu_support,
"Support Confidential": self.confidential_computing,
"Support GPU": self.gpu_support,
**(
{
"Terms & Conditions": self.terms_and_conditions,
}
if self.terms_and_conditions
else {}
),
}
text = "\n".join(f"[orange3]{key}[/orange3]: {value}" for key, value in data.items())

Expand Down
25 changes: 20 additions & 5 deletions tests/unit/test_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ def create_mock_crn_info():
qemu_support=True,
confidential_computing=True,
gpu_support=True,
terms_and_conditions=FAKE_STORE_HASH,
)
)

Expand Down Expand Up @@ -179,7 +180,7 @@ def test_sanitize_url_with_https_scheme():
assert sanitize_url(url) == url


def create_mock_instance_message(mock_account, payg=False, coco=False, gpu=False):
def create_mock_instance_message(mock_account, payg=False, coco=False, gpu=False, tac=False):
tmp = list(FAKE_VM_HASH)
random.shuffle(tmp)
vm_item_hash = "".join(tmp)
Expand Down Expand Up @@ -207,7 +208,7 @@ def create_mock_instance_message(mock_account, payg=False, coco=False, gpu=False
volumes=[],
),
)
if payg or coco or gpu:
if payg or coco or gpu or tac:
vm.content.metadata["name"] += "_payg" # type: ignore
vm.content.payment = Payment(chain=Chain.AVAX, receiver=FAKE_ADDRESS_EVM, type=PaymentType.superfluid) # type: ignore
vm.content.requirements = Dict( # type: ignore
Expand All @@ -230,6 +231,9 @@ def create_mock_instance_message(mock_account, payg=False, coco=False, gpu=False
device_id="abcd:1234",
)
]
if tac:
vm.content.metadata["name"] += "_tac" # type: ignore
vm.content.requirements.node.terms_and_conditions = FAKE_STORE_HASH # type: ignore
return vm


Expand All @@ -238,7 +242,8 @@ def create_mock_instance_messages(mock_account):
payg = create_mock_instance_message(mock_account, payg=True)
coco = create_mock_instance_message(mock_account, coco=True)
gpu = create_mock_instance_message(mock_account, gpu=True)
return AsyncMock(return_value=[regular, payg, coco, gpu])
tac = create_mock_instance_message(mock_account, tac=True)
return AsyncMock(return_value=[regular, payg, coco, gpu, tac])


def create_mock_validate_ssh_pubkey_file():
Expand All @@ -258,7 +263,12 @@ def create_mock_shutil():


def create_mock_client():
mock_client = AsyncMock(get_message=AsyncMock(return_value=True))
mock_client = AsyncMock(
get_message=AsyncMock(return_value=True),
get_stored_content=AsyncMock(
return_value=Dict(filename="fake_tac", hash="0xfake_tac", url="https://fake.tac.com")
),
)
mock_client_class = MagicMock()
mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
return mock_client_class, mock_client
Expand Down Expand Up @@ -444,6 +454,7 @@ async def create_instance(instance_spec):
persistent_volume=None,
ephemeral_volume=None,
immutable_volume=None,
crn_auto_tac=True,
channel=settings.DEFAULT_CHANNEL,
crn_hash=None,
crn_url=None,
Expand Down Expand Up @@ -473,10 +484,12 @@ async def create_instance(instance_spec):
async def test_list_instances():
mock_load_account = create_mock_load_account()
mock_account = mock_load_account.return_value
mock_client_class, mock_client = create_mock_client()
mock_auth_client_class, mock_auth_client = create_mock_auth_client(mock_account)
mock_instance_messages = create_mock_instance_messages(mock_account)

@patch("aleph_client.commands.instance._load_account", mock_load_account)
@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():
Expand All @@ -490,7 +503,8 @@ async def list_instance():
mock_instance_messages.assert_called_once()
mock_auth_client.get_messages.assert_called_once()
mock_auth_client.get_program_price.assert_called()
assert mock_auth_client.get_program_price.call_count == 3
assert mock_auth_client.get_program_price.call_count == 4
assert mock_client.get_stored_content.call_count == 1

await list_instance()

Expand Down Expand Up @@ -769,6 +783,7 @@ async def coco_create(instance_spec):
persistent_volume=None,
ephemeral_volume=None,
immutable_volume=None,
crn_auto_tac=True,
policy=0x1,
confidential_firmware=FAKE_STORE_HASH,
firmware_hash=None,
Expand Down
Loading