Skip to content

Commit cd802fe

Browse files
committed
Feat: Add T&C (rebased)
2 parents ffcf106 + 1a56d28 commit cd802fe

File tree

9 files changed

+162
-31
lines changed

9 files changed

+162
-31
lines changed

scripts/gendoc.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@
33
Copied from typer.cli.py to customise doc generation
44
"""
55

6-
import click
76
import importlib.util
87
import re
98
import sys
9+
from pathlib import Path
10+
from typing import Any, List, Optional
11+
12+
import click
1013
import typer
1114
import typer.core
1215
from click import Command, Group
13-
from pathlib import Path
14-
from typing import Any, List, Optional
1516

1617
default_app_names = ("app", "cli", "main")
1718
default_func_names = ("main", "cli", "app")

src/aleph_client/commands/files.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,24 @@
1111
from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient
1212
from aleph.sdk.account import _load_account
1313
from aleph.sdk.conf import settings
14+
from aleph.sdk.exceptions import ForgottenMessageError, MessageNotFoundError
1415
from aleph.sdk.types import AccountFromPrivateKey, StorageEnum
15-
from aleph_message.models import ItemHash, StoreMessage
16+
from aleph_message.models import ItemHash, ItemType, MessageType, StoreMessage
1617
from aleph_message.status import MessageStatus
1718
from pydantic import BaseModel, Field
1819
from rich import box
1920
from rich.console import Console
2021
from rich.table import Table
2122

2223
from aleph_client.commands import help_strings
23-
from aleph_client.commands.utils import setup_logging
24+
from aleph_client.commands.utils import safe_getattr, setup_logging
2425
from aleph_client.utils import AsyncTyper
2526

2627
logger = logging.getLogger(__name__)
2728
app = AsyncTyper(no_args_is_help=True)
2829

30+
IPFS_GATEWAY = "https://ipfs.aleph.cloud/ipfs/"
31+
2932

3033
@app.command()
3134
async def pin(
@@ -241,3 +244,43 @@ async def list(
241244
typer.echo(f"Failed to retrieve files for address {address}. Status code: {response.status}")
242245
else:
243246
typer.echo("Error: Please provide either a private key, private key file, or an address.")
247+
248+
249+
class IpfsContent(BaseModel):
250+
filename: Optional[str]
251+
cid: str
252+
url: str
253+
254+
255+
@app.command()
256+
async def ipfs_content(
257+
item_hash: str = typer.Argument(..., help="Item hash of the store message"),
258+
verbose: bool = True,
259+
) -> Optional[IpfsContent]:
260+
"""Return the underlying ipfs content for a given store message item hash"""
261+
262+
result, resp = None, None
263+
async with AlephHttpClient(api_server=settings.API_HOST) as client:
264+
try:
265+
message, status = await client.get_message(item_hash=ItemHash(item_hash), with_status=True)
266+
if status != MessageStatus.PROCESSED:
267+
resp = f"Invalid message status: {status}"
268+
elif message.type != MessageType.store:
269+
resp = f"Invalid message type: {message.type}"
270+
elif message.content.item_type != ItemType.ipfs:
271+
resp = f"Invalid item type: {message.content.item_type}"
272+
elif not message.content.item_hash:
273+
resp = f"Invalid CID: {message.content.item_hash}"
274+
else:
275+
filename = message.content.metadata.name if safe_getattr(message.content, "metadata.name") else ""
276+
cid = message.content.item_hash
277+
url = f"{IPFS_GATEWAY}{cid}"
278+
result = IpfsContent(filename=filename, cid=cid, url=url)
279+
resp = f"Filename: {filename or 'None'}\nCID: {cid}\nURL: {url}"
280+
except MessageNotFoundError:
281+
resp = f"Message not found: {item_hash}"
282+
except ForgottenMessageError:
283+
resp = f"Message forgotten: {item_hash}"
284+
if verbose:
285+
typer.echo(resp)
286+
return result

src/aleph_client/commands/help_strings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
SSH_PUBKEY_FILE = "Path to a public ssh key to be added to the instance"
3636
CRN_HASH = "Hash of the CRN to deploy to (only applicable for confidential and/or Pay-As-You-Go instances)"
3737
CRN_URL = "URL of the CRN to deploy to (only applicable for confidential and/or Pay-As-You-Go instances)"
38+
CRN_AUTO_TAC = "Automatically accept the Terms & Conditions of the CRN if you read them beforehand"
3839
CONFIDENTIAL_OPTION = "Launch a confidential instance (requires creating an encrypted volume)"
3940
CONFIDENTIAL_FIRMWARE = "Hash to UEFI Firmware to launch confidential instance"
4041
CONFIDENTIAL_FIRMWARE_HASH = "Hash of the UEFI Firmware content, to validate measure (ignored if path is provided)"

src/aleph_client/commands/instance/__init__.py

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,25 +27,6 @@
2727
from aleph.sdk.query.responses import PriceResponse
2828
from aleph.sdk.types import StorageEnum
2929
from aleph.sdk.utils import calculate_firmware_hash
30-
from aleph_message.models import InstanceMessage, StoreMessage
31-
from aleph_message.models.base import Chain, MessageType
32-
from aleph_message.models.execution.base import Payment, PaymentType
33-
from aleph_message.models.execution.environment import (
34-
GpuDeviceClass,
35-
GpuProperties,
36-
HostRequirements,
37-
HypervisorType,
38-
NodeRequirements,
39-
TrustedExecutionEnvironment,
40-
)
41-
from aleph_message.models.item_hash import ItemHash
42-
from click import echo
43-
from rich import box
44-
from rich.console import Console
45-
from rich.prompt import Confirm, Prompt
46-
from rich.table import Table
47-
from rich.text import Text
48-
4930
from aleph_client.commands import help_strings
5031
from aleph_client.commands.instance.display import CRNTable
5132
from aleph_client.commands.instance.network import (
@@ -69,6 +50,24 @@
6950
)
7051
from aleph_client.models import CRNInfo
7152
from aleph_client.utils import AsyncTyper, sanitize_url
53+
from aleph_message.models import InstanceMessage, StoreMessage
54+
from aleph_message.models.base import Chain, MessageType
55+
from aleph_message.models.execution.base import Payment, PaymentType
56+
from aleph_message.models.execution.environment import (
57+
GpuDeviceClass,
58+
GpuProperties,
59+
HostRequirements,
60+
HypervisorType,
61+
NodeRequirements,
62+
TrustedExecutionEnvironment,
63+
)
64+
from aleph_message.models.item_hash import ItemHash
65+
from click import echo
66+
from rich import box
67+
from rich.console import Console
68+
from rich.prompt import Confirm, Prompt
69+
from rich.table import Table
70+
from rich.text import Text
7271

7372
logger = logging.getLogger(__name__)
7473
app = AsyncTyper(no_args_is_help=True)
@@ -117,6 +116,7 @@ async def create(
117116
None,
118117
help=help_strings.IMMUTABLE_VOLUME,
119118
),
119+
crn_auto_tac: bool = typer.Option(False, help=help_strings.CRN_AUTO_TAC),
120120
channel: Optional[str] = typer.Option(default=settings.DEFAULT_CHANNEL, help=help_strings.CHANNEL),
121121
private_key: Optional[str] = typer.Option(settings.PRIVATE_KEY_STRING, help=help_strings.PRIVATE_KEY),
122122
private_key_file: Optional[Path] = typer.Option(settings.PRIVATE_KEY_FILE, help=help_strings.PRIVATE_KEY_FILE),
@@ -342,6 +342,7 @@ async def create(
342342
crn_info.get("computing", {}).get("ENABLE_CONFIDENTIAL_COMPUTING", False)
343343
),
344344
gpu_support=bool(crn_info.get("computing", {}).get("ENABLE_GPU_SUPPORT", False)),
345+
terms_and_conditions=crn_info.get("terms_and_conditions"),
345346
)
346347
echo("\n* Selected CRN *")
347348
crn.display_crn_specs()
@@ -409,6 +410,15 @@ async def create(
409410
device_id=selected_gpu.device_id,
410411
)
411412
]
413+
if crn.terms_and_conditions:
414+
accepted = await crn.display_terms_and_conditions(auto_accept=crn_auto_tac)
415+
if accepted is None:
416+
echo("Failed to fetch terms and conditions.\nContact support or use a different CRN.")
417+
raise typer.Exit(1)
418+
elif not accepted:
419+
echo("Terms & Conditions rejected: instance creation aborted.")
420+
raise typer.Exit(1)
421+
echo("Terms & Conditions accepted.")
412422

413423
async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client:
414424
payment = Payment(
@@ -433,7 +443,12 @@ async def create(
433443
payment=payment,
434444
requirements=(
435445
HostRequirements(
436-
node=NodeRequirements(node_hash=crn.hash),
446+
node=NodeRequirements(
447+
node_hash=crn.hash,
448+
terms_and_conditions=(
449+
ItemHash(crn.terms_and_conditions) if crn.terms_and_conditions else None
450+
),
451+
),
437452
gpu=gpu_requirement,
438453
)
439454
if crn
@@ -685,7 +700,7 @@ async def _show_instances(messages: List[InstanceMessage], node_list: NodeInfo):
685700
chain_label, chain_color = "BASE", "blue3"
686701
elif chain_label == "SOL":
687702
chain_label, chain_color = "SOL ", "medium_spring_green"
688-
else: # ETH
703+
elif len(chain_label) == 3: # ETH
689704
chain_label += " "
690705
chain = Text.assemble("Chain: ", Text(chain_label, style=chain_color))
691706
created_at_parsed = str(str_to_datetime(str(info["created_at"]))).split(".")[0]
@@ -730,6 +745,19 @@ async def _show_instances(messages: List[InstanceMessage], node_list: NodeInfo):
730745
Text(str(info["ipv6_logs"])),
731746
style="bright_yellow" if len(str(info["ipv6_logs"]).split(":")) == 8 else "dark_orange",
732747
),
748+
(
749+
Text.assemble(
750+
Text(
751+
f"\n[{'✅' if info['terms_and_conditions']['accepted'] else '❌'}] Accepted Terms & Conditions:\n"
752+
),
753+
Text(
754+
f"{info['terms_and_conditions']['url']}",
755+
style="orange1",
756+
),
757+
)
758+
if info["terms_and_conditions"]["hash"]
759+
else ""
760+
),
733761
)
734762
table.add_row(instance, specifications, status_column)
735763
table.add_section()
@@ -1192,6 +1220,7 @@ async def confidential_create(
11921220
None,
11931221
help=help_strings.IMMUTABLE_VOLUME,
11941222
),
1223+
crn_auto_tac: bool = typer.Option(False, help=help_strings.CRN_AUTO_TAC),
11951224
channel: Optional[str] = typer.Option(default=settings.DEFAULT_CHANNEL, help=help_strings.CHANNEL),
11961225
private_key: Optional[str] = typer.Option(settings.PRIVATE_KEY_STRING, help=help_strings.PRIVATE_KEY),
11971226
private_key_file: Optional[Path] = typer.Option(settings.PRIVATE_KEY_FILE, help=help_strings.PRIVATE_KEY_FILE),
@@ -1223,6 +1252,7 @@ async def confidential_create(
12231252
ssh_pubkey_file=ssh_pubkey_file,
12241253
crn_hash=crn_hash,
12251254
crn_url=crn_url,
1255+
crn_auto_tac=crn_auto_tac,
12261256
confidential=True,
12271257
confidential_firmware=confidential_firmware,
12281258
skip_volume=skip_volume,

src/aleph_client/commands/instance/display.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def compose(self):
7070
self.table.add_column("Free RAM 🌡", key="ram")
7171
self.table.add_column("Free Disk 💿", key="hdd")
7272
self.table.add_column("URL", key="url")
73+
self.table.add_column("Terms & Conditions 📝", key="tac")
7374
yield Label("Choose a Compute Resource Node (CRN) to run your instance")
7475
with Horizontal():
7576
self.loader_label_start = Label(self.label_start)
@@ -101,6 +102,7 @@ async def fetch_node_list(self):
101102
qemu_support=None,
102103
confidential_computing=None,
103104
gpu_support=None,
105+
terms_and_conditions=node.get("terms_and_conditions"),
104106
)
105107

106108
# Initialize the progress bar
@@ -154,6 +156,9 @@ async def fetch_node_info(self, node: CRNInfo):
154156
return
155157
self.filtered_crns += 1
156158

159+
# Fetch terms and conditions
160+
tac = await node.terms_and_conditions_content
161+
157162
self.table.add_row(
158163
_format_score(node.score),
159164
node.name,
@@ -166,6 +171,7 @@ async def fetch_node_info(self, node: CRNInfo):
166171
node.display_ram,
167172
node.display_hdd,
168173
node.url,
174+
tac.url if tac else "✖",
169175
key=node.hash,
170176
)
171177

src/aleph_client/commands/instance/network.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
from ipaddress import IPv6Interface
55
from json import JSONDecodeError
6-
from typing import Optional
6+
from typing import Any, Optional
77

88
import aiohttp
99
from aleph.sdk import AlephHttpClient
@@ -14,10 +14,11 @@
1414
from pydantic import ValidationError
1515

1616
from aleph_client.commands import help_strings
17+
from aleph_client.commands.files import ipfs_content
1718
from aleph_client.commands.node import NodeInfo, _fetch_nodes
1819
from aleph_client.commands.utils import safe_getattr
1920
from aleph_client.models import MachineUsage
20-
from aleph_client.utils import AsyncTyper, fetch_json, sanitize_url
21+
from aleph_client.utils import fetch_json, sanitize_url
2122

2223
logger = logging.getLogger(__name__)
2324

@@ -68,7 +69,7 @@ async def fetch_crn_info(node_url: str) -> dict | None:
6869
return None
6970

7071

71-
async def fetch_vm_info(message: InstanceMessage, node_list: NodeInfo) -> tuple[str, dict[str, object]]:
72+
async def fetch_vm_info(message: InstanceMessage, node_list: NodeInfo) -> tuple[str, dict[str, Any]]:
7273
"""
7374
Fetches VM information given an instance message and the node list.
7475
@@ -93,6 +94,7 @@ async def fetch_vm_info(message: InstanceMessage, node_list: NodeInfo) -> tuple[
9394
allocation_type="",
9495
ipv6_logs="",
9596
crn_url="",
97+
terms_and_conditions=dict(hash=None, url=None, accepted=False),
9698
)
9799
try:
98100
# Fetch from the scheduler API directly if no payment or no receiver (hold-tier non-confidential)
@@ -118,6 +120,14 @@ async def fetch_vm_info(message: InstanceMessage, node_list: NodeInfo) -> tuple[
118120
for node in node_list.nodes:
119121
if node["hash"] == safe_getattr(message, "content.requirements.node.node_hash"):
120122
info["crn_url"] = node["address"].rstrip("/")
123+
124+
# Terms and conditions
125+
tac_item_hash = safe_getattr(message, "content.requirements.node.terms_and_conditions")
126+
if tac_item_hash:
127+
tac = await ipfs_content(tac_item_hash, verbose=False)
128+
tac_url = (tac and tac.url) or f"missing → {tac_item_hash}"
129+
info["terms_and_conditions"] = dict(hash=tac_item_hash, url=tac_url, accepted=True)
130+
121131
path = f"{node['address'].rstrip('/')}/about/executions/list"
122132
executions = await fetch_json(session, path)
123133
if message.item_hash in executions:

src/aleph_client/models.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
from aleph_message.models import ItemHash
66
from aleph_message.models.execution.environment import CpuProperties
77
from pydantic import BaseModel
8+
from rich.prompt import Prompt
89
from typer import echo
910

11+
from aleph_client.commands.files import IpfsContent, ipfs_content
1012
from aleph_client.commands.node import _escape_and_normalize, _remove_ansi_escape
1113

1214

@@ -130,6 +132,7 @@ class CRNInfo(BaseModel):
130132
qemu_support: Optional[bool]
131133
confidential_computing: Optional[bool]
132134
gpu_support: Optional[bool]
135+
terms_and_conditions: Optional[str]
133136

134137
@property
135138
def display_cpu(self) -> str:
@@ -149,6 +152,27 @@ def display_hdd(self) -> str:
149152
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"
150153
return ""
151154

155+
@property
156+
async def terms_and_conditions_content(self) -> Optional[IpfsContent]:
157+
if self.terms_and_conditions:
158+
return await ipfs_content(self.terms_and_conditions, verbose=False)
159+
return None
160+
161+
async def display_terms_and_conditions(self, auto_accept: bool = False) -> Optional[bool]:
162+
if self.terms_and_conditions:
163+
tac = await self.terms_and_conditions_content
164+
echo("* Terms & Conditions *")
165+
if tac:
166+
echo("The selected CRN requires you to accept the following conditions and terms of use:\n")
167+
if tac.filename:
168+
echo(f"Filename: {tac.filename}")
169+
echo(f"↳ {tac.url}\n")
170+
if auto_accept:
171+
echo("To proceed, enter “Yes I read and accept”: Yes I read and accept")
172+
return True
173+
return Prompt.ask("To proceed, enter “Yes I read and accept”").lower() == "yes i read and accept"
174+
return None
175+
152176
def display_crn_specs(self):
153177
echo(f"Hash: {self.hash}")
154178
echo(f"Name: {self.name}")
@@ -163,3 +187,5 @@ def display_crn_specs(self):
163187
echo(f"Support Qemu: {self.qemu_support}")
164188
echo(f"Support Confidential: {self.confidential_computing}")
165189
echo(f"Support GPU: {self.gpu_support}")
190+
if self.terms_and_conditions:
191+
echo(f"Terms & Conditions: {self.terms_and_conditions}")

tests/unit/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"""
99
from pathlib import Path
1010
from tempfile import NamedTemporaryFile
11-
from typing import Generator
11+
from typing import Generator, Tuple
1212

1313
import pytest
1414
from aleph.sdk.chains.common import generate_key
@@ -27,7 +27,7 @@ def empty_account_file() -> Generator[Path, None, None]:
2727

2828

2929
@pytest.fixture
30-
def env_files(new_config_file: Path, empty_account_file: Path) -> Generator[Path, None, None]:
30+
def env_files(new_config_file: Path, empty_account_file: Path) -> Generator[Tuple[Path, Path], None, None]:
3131
new_config_file.write_text(f'{{"path": "{empty_account_file}", "chain": "ETH"}}')
3232
empty_account_file.write_bytes(generate_key())
3333
yield empty_account_file, new_config_file

0 commit comments

Comments
 (0)