From f79ea2081e742290e0d8942e5a773b05b975801b Mon Sep 17 00:00:00 2001 From: philogicae <38438271+philogicae@users.noreply.github.com> Date: Thu, 9 Jan 2025 23:18:19 +0200 Subject: [PATCH] Rebase terms and conditions on new changes --- src/aleph_client/commands/help_strings.py | 1 + .../commands/instance/__init__.py | 29 +++++++++++++- src/aleph_client/commands/instance/display.py | 6 +++ src/aleph_client/commands/instance/network.py | 10 +++++ src/aleph_client/models.py | 38 +++++++++++++++++++ tests/unit/test_instance.py | 25 +++++++++--- 6 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/aleph_client/commands/help_strings.py b/src/aleph_client/commands/help_strings.py index e64e8bd6..a1207217 100644 --- a/src/aleph_client/commands/help_strings.py +++ b/src/aleph_client/commands/help_strings.py @@ -37,6 +37,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)" diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index 838ca851..f9004f06 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -125,6 +125,7 @@ async def create( 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), @@ -354,6 +355,7 @@ async def create( crn_info.get("computing", {}).get("ENABLE_CONFIDENTIAL_COMPUTING", False) ), gpu_support=bool(crn_info.get("computing", {}).get("ENABLE_GPU_SUPPORT", False)), + terms_and_conditions=crn_info.get("terms_and_conditions"), ) crn.display_crn_specs() except Exception as e: @@ -434,8 +436,20 @@ async def create( 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) + elif not accepted: + echo("Terms & Conditions rejected: instance creation aborted.") + raise typer.Exit(1) + 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, ) @@ -780,6 +794,17 @@ async def _show_instances(messages: List[InstanceMessage], node_list: NodeInfo): 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() @@ -1162,6 +1187,7 @@ async def confidential_create( 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), @@ -1194,6 +1220,7 @@ async def confidential_create( 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, diff --git a/src/aleph_client/commands/instance/display.py b/src/aleph_client/commands/instance/display.py index a05d1632..305387b0 100644 --- a/src/aleph_client/commands/instance/display.py +++ b/src/aleph_client/commands/instance/display.py @@ -72,6 +72,7 @@ def compose(self): 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") yield Label("Choose a Compute Resource Node (CRN) to run your instance") with Horizontal(): self.loader_label_start = Label(self.label_start) @@ -103,6 +104,7 @@ async def fetch_node_list(self): qemu_support=None, confidential_computing=None, gpu_support=None, + terms_and_conditions=node.get("terms_and_conditions"), ) # Initialize the progress bar @@ -161,6 +163,9 @@ async def fetch_node_info(self, node: CRNInfo): return self.filtered_crns += 1 + # Fetch terms and conditions + tac = await node.terms_and_conditions_content + self.table.add_row( _format_score(node.score), node.name, @@ -173,6 +178,7 @@ async def fetch_node_info(self, node: CRNInfo): node.display_ram, node.display_hdd, node.url, + tac.url if tac else "✖", key=node.hash, ) diff --git a/src/aleph_client/commands/instance/network.py b/src/aleph_client/commands/instance/network.py index 17d11817..99af85dd 100644 --- a/src/aleph_client/commands/instance/network.py +++ b/src/aleph_client/commands/instance/network.py @@ -15,6 +15,7 @@ from pydantic import ValidationError 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 @@ -88,6 +89,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 "", @@ -98,6 +100,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) @@ -134,6 +139,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 diff --git a/src/aleph_client/models.py b/src/aleph_client/models.py index b8bbe893..b118dec5 100644 --- a/src/aleph_client/models.py +++ b/src/aleph_client/models.py @@ -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 @@ -131,6 +135,7 @@ class CRNInfo(BaseModel): qemu_support: Optional[bool] confidential_computing: Optional[bool] gpu_support: Optional[bool] + terms_and_conditions: Optional[str] @property def display_cpu(self) -> str: @@ -150,6 +155,32 @@ def display_hdd(self) -> str: 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 + + 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 + def display_crn_specs(self): console = Console() @@ -172,6 +203,13 @@ def display_crn_specs(self): "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()) diff --git a/tests/unit/test_instance.py b/tests/unit/test_instance.py index ff60714b..1830a8fa 100644 --- a/tests/unit/test_instance.py +++ b/tests/unit/test_instance.py @@ -128,6 +128,7 @@ def create_mock_crn_info(): qemu_support=True, confidential_computing=True, gpu_support=True, + terms_and_conditions=FAKE_STORE_HASH, ) ) @@ -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) @@ -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 @@ -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 @@ -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(): @@ -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 @@ -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, @@ -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(): @@ -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() @@ -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,