Skip to content

Commit

Permalink
Rebase terms and conditions on new changes
Browse files Browse the repository at this point in the history
  • Loading branch information
philogicae committed Jan 9, 2025
1 parent 9ed0c10 commit f79ea20
Show file tree
Hide file tree
Showing 6 changed files with 103 additions and 6 deletions.
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 @@ -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)"
Expand Down
29 changes: 28 additions & 1 deletion src/aleph_client/commands/instance/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

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

View check run for this annotation

Codecov / codecov/patch

src/aleph_client/commands/instance/__init__.py#L442-L443

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

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

View check run for this annotation

Codecov / codecov/patch

src/aleph_client/commands/instance/__init__.py#L445-L446

Added lines #L445 - L446 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 @@ -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()
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
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 @@ 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")

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 @@ 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
Expand Down Expand Up @@ -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

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 @@ async def fetch_node_info(self, node: CRNInfo):
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 @@ -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
Expand Down Expand Up @@ -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 "",
Expand All @@ -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)
Expand Down Expand Up @@ -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
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 @@ 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:
Expand All @@ -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

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 @@ 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())

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

0 comments on commit f79ea20

Please sign in to comment.