Skip to content

Commit 99dcc6b

Browse files
committed
Feat: Add Terms & Conditions
1 parent d6def2a commit 99dcc6b

File tree

9 files changed

+170
-16
lines changed

9 files changed

+170
-16
lines changed

pyproject.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,16 @@ dynamic = [ "version" ]
3333
dependencies = [
3434
"aiodns==3.2",
3535
"aiohttp==3.10.6",
36-
"aleph-message>=0.5",
36+
"aleph-message @ git+https://github.com/aleph-im/aleph-message@aleph-323-terms-and-conditions",
3737
"aleph-sdk-python>=1.2,<2",
38-
"base58==2.1.1", # Needed now as default with _load_account changement
39-
"py-sr25519-bindings==0.2", # Needed for DOT signatures
38+
"base58==2.1.1", # Needed now as default with _load_account changement
39+
"py-sr25519-bindings==0.2", # Needed for DOT signatures
4040
"pygments==2.18",
41-
"pynacl==1.5", # Needed now as default with _load_account changement
41+
"pynacl==1.5", # Needed now as default with _load_account changement
4242
"python-magic==0.4.27",
4343
"rich==13.9.3",
4444
"setuptools>=65.5",
45-
"substrate-interface==1.7.11", # Needed for DOT signatures
45+
"substrate-interface==1.7.11", # Needed for DOT signatures
4646
"textual==0.73",
4747
"typer==0.12.5",
4848
]

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: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
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
@@ -241,3 +242,35 @@ async def list(
241242
typer.echo(f"Failed to retrieve files for address {address}. Status code: {response.status}")
242243
else:
243244
typer.echo("Error: Please provide either a private key, private key file, or an address.")
245+
246+
247+
@app.command()
248+
async def ipfs_content(
249+
item_hash: str = typer.Argument(..., help="Item hash of the store message"),
250+
as_url: bool = True,
251+
verbose: bool = True,
252+
) -> Optional[str]:
253+
"""Return the underlying ipfs content url for a given store message item hash"""
254+
255+
url, resp = None, None
256+
async with AlephHttpClient(api_server=settings.API_HOST) as client:
257+
try:
258+
message, status = await client.get_message(item_hash=ItemHash(item_hash), with_status=True)
259+
if status != MessageStatus.PROCESSED:
260+
resp = f"Invalid message status: {status}"
261+
elif message.type != MessageType.store:
262+
resp = f"Invalid message type: {message.type}"
263+
elif message.content.item_type != ItemType.ipfs:
264+
resp = f"Invalid item type: {message.content.item_type}"
265+
elif not message.content.item_hash:
266+
resp = f"Invalid CID: {message.content.item_hash}"
267+
else:
268+
url = ("https://ipfs.aleph.cloud/ipfs/" if as_url else "") + message.content.item_hash
269+
resp = f"IPFS Content {'URL' if as_url else 'CID'}: {url}"
270+
except MessageNotFoundError:
271+
resp = f"Message not found: {item_hash}"
272+
except ForgottenMessageError:
273+
resp = f"Message forgotten: {item_hash}"
274+
if verbose:
275+
typer.echo(resp)
276+
return url

src/aleph_client/commands/instance/__init__.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ async def create(
338338
confidential_computing=bool(
339339
crn_info.get("computing", {}).get("ENABLE_CONFIDENTIAL_COMPUTING", False)
340340
),
341+
terms_and_conditions=crn_info.get("terms_and_conditions"),
341342
)
342343
echo("\n* Selected CRN *")
343344
crn.display_crn_specs()
@@ -374,6 +375,16 @@ async def create(
374375
echo("Selected CRN does not support confidential computing.")
375376
raise typer.Exit(1)
376377

378+
if crn.terms_and_conditions:
379+
accepted = await crn.display_terms_and_conditions
380+
if accepted is None:
381+
echo("Failed to fetch terms and conditions.\nContact support or use a different CRN.")
382+
raise typer.Exit(1)
383+
elif not accepted:
384+
echo("Terms & Conditions rejected: instance creation aborted.")
385+
raise typer.Exit(1)
386+
echo("Terms & Conditions accepted.")
387+
377388
async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client:
378389
payment = Payment(
379390
chain=payment_chain,
@@ -395,7 +406,18 @@ async def create(
395406
ssh_keys=[ssh_pubkey],
396407
hypervisor=hypervisor,
397408
payment=payment,
398-
requirements=HostRequirements(node=NodeRequirements(node_hash=crn.hash)) if crn else None,
409+
requirements=(
410+
HostRequirements(
411+
node=NodeRequirements(
412+
node_hash=crn.hash,
413+
terms_and_conditions=(
414+
ItemHash(crn.terms_and_conditions) if crn.terms_and_conditions else None
415+
),
416+
)
417+
)
418+
if crn
419+
else None
420+
),
399421
trusted_execution=(
400422
TrustedExecutionEnvironment(firmware=confidential_firmware_as_hash) if confidential else None
401423
),
@@ -642,7 +664,7 @@ async def _show_instances(messages: List[InstanceMessage], node_list: NodeInfo):
642664
chain_label, chain_color = "BASE", "blue3"
643665
elif chain_label == "SOL":
644666
chain_label, chain_color = "SOL ", "medium_spring_green"
645-
else: # ETH
667+
elif len(chain_label) == 3: # ETH
646668
chain_label += " "
647669
chain = Text.assemble("Chain: ", Text(chain_label, style=chain_color))
648670
created_at_parsed = str(str_to_datetime(str(info["created_at"]))).split(".")[0]
@@ -687,6 +709,36 @@ async def _show_instances(messages: List[InstanceMessage], node_list: NodeInfo):
687709
Text(str(info["ipv6_logs"])),
688710
style="bright_yellow" if len(str(info["ipv6_logs"]).split(":")) == 8 else "dark_orange",
689711
),
712+
(
713+
Text.assemble(
714+
Text(
715+
f"\n[{'✅' if info['terms_and_conditions']['accepted'] else '❌'}] Signed latest Terms & Conditions:\n"
716+
),
717+
Text(
718+
f"{info['terms_and_conditions']['url']}",
719+
style="orange1",
720+
),
721+
(
722+
Text.assemble(
723+
Text(
724+
"\nReason:\n",
725+
),
726+
Text(
727+
f"{info['terms_and_conditions']['info']['outdated']}\n",
728+
style="red",
729+
),
730+
Text(
731+
f"≠ {info['terms_and_conditions']['info']['latest']}",
732+
style="green",
733+
),
734+
)
735+
if info["terms_and_conditions"]["info"]
736+
else ""
737+
),
738+
)
739+
if info["terms_and_conditions"]["hash"]
740+
else ""
741+
),
690742
)
691743
table.add_row(instance, specifications, status_column)
692744
table.add_section()

src/aleph_client/commands/instance/display.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def compose(self):
6262
self.table.add_column("Free RAM 🌡", key="ram")
6363
self.table.add_column("Free Disk 💿", key="hdd")
6464
self.table.add_column("URL", key="url")
65+
self.table.add_column("Terms & Conditions 📝", key="tac")
6566
yield Label("Choose a Compute Resource Node (CRN) to run your instance")
6667
with Horizontal():
6768
self.loader_label_start = Label(self.label_start)
@@ -92,6 +93,7 @@ async def fetch_node_list(self):
9293
machine_usage=None,
9394
qemu_support=None,
9495
confidential_computing=None,
96+
terms_and_conditions=node.get("terms_and_conditions"),
9597
)
9698

9799
# Initialize the progress bar
@@ -141,6 +143,9 @@ async def fetch_node_info(self, node: CRNInfo):
141143
return
142144
self.filtered_crns += 1
143145

146+
# Fetch terms and conditions
147+
tac = await node.link_terms_and_conditions if node.terms_and_conditions else "❌"
148+
144149
self.table.add_row(
145150
_format_score(node.score),
146151
node.name,
@@ -152,6 +157,7 @@ async def fetch_node_info(self, node: CRNInfo):
152157
node.display_ram,
153158
node.display_hdd,
154159
node.url,
160+
tac,
155161
key=node.hash,
156162
)
157163

src/aleph_client/commands/instance/network.py

Lines changed: 29 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, info=None),
9698
)
9799
try:
98100
# Fetch from the scheduler API directly if no payment or no receiver (hold-tier non-confidential)
@@ -118,6 +120,30 @@ 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+
if "terms_and_conditions" in node:
126+
latest_tac_hash = str(node["terms_and_conditions"])
127+
latest_tac_url = (
128+
await ipfs_content(latest_tac_hash, verbose=False) or f"missing → {latest_tac_hash}"
129+
)
130+
vm_tac_hash = safe_getattr(message, "content.requirements.node.terms_and_conditions")
131+
tac_accepted = vm_tac_hash and latest_tac_hash == str(vm_tac_hash)
132+
tac_info = None
133+
if not tac_accepted:
134+
vm_tac_url = (
135+
(await ipfs_content(vm_tac_hash, verbose=False) or f"missing → {vm_tac_hash}")
136+
if vm_tac_hash
137+
else None
138+
)
139+
tac_info = dict(
140+
outdated=f"Outdated: {vm_tac_hash}\nURL: {vm_tac_url}",
141+
latest=f"Latest: {latest_tac_hash}\nURL: {latest_tac_url}",
142+
)
143+
info["terms_and_conditions"] = dict(
144+
hash=latest_tac_hash, url=latest_tac_url, accepted=tac_accepted, info=tac_info
145+
)
146+
121147
path = f"{node['address'].rstrip('/')}/about/executions/list"
122148
executions = await fetch_json(session, path)
123149
if message.item_hash in executions:

src/aleph_client/models.py

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

10+
from aleph_client.commands.files import ipfs_content
911
from aleph_client.commands.node import _escape_and_normalize, _remove_ansi_escape
1012

1113

@@ -114,6 +116,7 @@ class CRNInfo(BaseModel):
114116
machine_usage: Optional[MachineUsage]
115117
qemu_support: Optional[bool]
116118
confidential_computing: Optional[bool]
119+
terms_and_conditions: Optional[str]
117120

118121
@property
119122
def display_cpu(self) -> str:
@@ -133,6 +136,23 @@ def display_hdd(self) -> str:
133136
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"
134137
return ""
135138

139+
@property
140+
async def link_terms_and_conditions(self) -> Optional[str]:
141+
if self.terms_and_conditions:
142+
return await ipfs_content(self.terms_and_conditions, verbose=False)
143+
return None
144+
145+
@property
146+
async def display_terms_and_conditions(self) -> Optional[bool]:
147+
if self.terms_and_conditions:
148+
url = await self.link_terms_and_conditions
149+
echo("* Terms & Conditions *")
150+
if url:
151+
echo("The selected CRN requires you to accept the following conditions and terms of use:\n")
152+
echo(f"→ {url}\n")
153+
return Prompt.ask("To proceed, enter “Yes I read and accept”").lower() == "yes i read and accept"
154+
return None
155+
136156
def display_crn_specs(self):
137157
echo(f"Hash: {self.hash}")
138158
echo(f"Name: {self.name}")
@@ -146,3 +166,5 @@ def display_crn_specs(self):
146166
echo(f"Available Disk: {self.display_hdd}")
147167
echo(f"Support Qemu: {self.qemu_support}")
148168
echo(f"Support Confidential: {self.confidential_computing}")
169+
if self.terms_and_conditions:
170+
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

tests/unit/test_commands.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,3 +293,17 @@ def test_file_download():
293293
)
294294
assert result.exit_code == 0
295295
assert result.stdout is not None
296+
297+
298+
def test_ipfs_content():
299+
# Test retrieve the underlying ipfs content url
300+
result = runner.invoke(
301+
app,
302+
[
303+
"file",
304+
"ipfs-content",
305+
"2f50b6d078005074801260bac1f8860b10d2a92cf00c91459800ea6042a02cc9",
306+
],
307+
)
308+
assert result.exit_code == 0
309+
assert result.stdout is not None

0 commit comments

Comments
 (0)