From 6f7273b93983775581cbfa0fc161a7042060ce15 Mon Sep 17 00:00:00 2001 From: Olivier Desenfans Date: Tue, 25 Aug 2026 15:23:13 +0200 Subject: [PATCH 1/2] feat: add create_verifiable_program to the authenticated client Adds make_verifiable_program_content and AuthenticatedAlephHttpClient.create_verifiable_program to publish V-PROGRAM messages (SEV-SNP verifiable programs) without going through the generic submit(). Payment defaults to credit and the content is always immutable, matching the aleph-message validators. The rejection handling shared with create_program is extracted into _raise_for_rejected_executable. --- src/aleph/sdk/client/abstract.py | 53 ++++++++++++++++ src/aleph/sdk/client/authenticated_http.py | 74 ++++++++++++++++++++-- src/aleph/sdk/utils.py | 74 +++++++++++++++++++++- tests/unit/services/test_authorizations.py | 3 + tests/unit/test_asynchronous.py | 62 ++++++++++++++++++ 5 files changed, 260 insertions(+), 6 deletions(-) diff --git a/src/aleph/sdk/client/abstract.py b/src/aleph/sdk/client/abstract.py index 4a19f14f..c7af8496 100644 --- a/src/aleph/sdk/client/abstract.py +++ b/src/aleph/sdk/client/abstract.py @@ -13,6 +13,7 @@ List, Mapping, Optional, + Sequence, Tuple, Type, Union, @@ -29,11 +30,14 @@ parse_message, ) from aleph_message.models.execution.environment import ( + DEFAULT_SNP_POLICY, HostRequirements, HypervisorType, + LaunchMeasurement, TrustedExecutionEnvironment, ) from aleph_message.models.execution.program import Encoding +from aleph_message.models.execution.vprogram import VerifiedVolume, VerifiedWorkload from aleph_message.status import MessageStatus from typing_extensions import deprecated @@ -534,6 +538,55 @@ async def create_program( "Did you mean to import `AuthenticatedAlephHttpClient`?" ) + @abstractmethod + async def create_verifiable_program( + self, + runtime: str, + workload: Union[VerifiedWorkload, Mapping[str, Any]], + measurements: Sequence[Union[LaunchMeasurement, Mapping[str, Any]]], + policy: int = DEFAULT_SNP_POLICY, + runtime_comment: str = "", + metadata: Optional[dict[str, Any]] = None, + address: Optional[str] = None, + payment: Optional[Payment] = None, + vcpus: Optional[int] = None, + memory: Optional[int] = None, + timeout_seconds: Optional[float] = None, + internet: bool = True, + volumes: Optional[Sequence[Union[VerifiedVolume, Mapping[str, Any]]]] = None, + requirements: Optional[HostRequirements] = None, + sync: bool = False, + channel: Optional[str] = settings.DEFAULT_CHANNEL, + storage_engine: StorageEnum = StorageEnum.storage, + ) -> Tuple[AlephMessage, MessageStatus]: + """ + Post a (create) V-PROGRAM message: an auto-booting SEV-SNP confidential + VM whose full software stack is attestable. + + V-Programs are credit-only and immutable: no amendments, no + environment variables and no authorized keys. Every input reaching the + guest is either measured or dm-verity bound. + + :param runtime: Item hash of the runtime manifest STORE message + :param workload: The verity-bound workload volume (ref, hash_tree, roothash) + :param measurements: Expected launch measurements, one per vcpu_type + :param policy: SEV-SNP guest policy (Default: DEFAULT_SNP_POLICY) + :param runtime_comment: Free-form comment on the runtime + :param metadata: Metadata to attach to the message + :param address: Address to use (Default: account.get_address()) + :param payment: Payment method, must be credit (Default: credit on ETH) + :param vcpus: Number of vCPUs to allocate + :param memory: Memory to allocate, in MiB + :param timeout_seconds: Timeout in seconds + :param internet: Whether the VM has internet access + :param volumes: Extra verity-bound read-only volumes (at most 8) + :param requirements: Host requirements (e.g. a specific CRN) + :param sync: If true, waits for the message to be processed by the API server + :param channel: Channel to use (Default: "TEST") + :param storage_engine: Storage engine to use (Default: "storage") + """ + raise NotImplementedError + @abstractmethod async def create_instance( self, diff --git a/src/aleph/sdk/client/authenticated_http.py b/src/aleph/sdk/client/authenticated_http.py index 0c27bf4b..2ca4b419 100644 --- a/src/aleph/sdk/client/authenticated_http.py +++ b/src/aleph/sdk/client/authenticated_http.py @@ -7,7 +7,7 @@ import time from io import BytesIO from pathlib import Path -from typing import Any, Dict, Mapping, NoReturn, Optional, Tuple, Union +from typing import Any, Dict, Mapping, NoReturn, Optional, Sequence, Tuple, Union import aiohttp import aleph_cid @@ -27,19 +27,28 @@ ProgramMessage, StoreContent, StoreMessage, + VerifiableProgramMessage, ) from aleph_message.models.execution.base import Encoding, Payment, PaymentType from aleph_message.models.execution.environment import ( + DEFAULT_SNP_POLICY, HostRequirements, HypervisorType, + LaunchMeasurement, TrustedExecutionEnvironment, ) +from aleph_message.models.execution.vprogram import VerifiedVolume, VerifiedWorkload from aleph_message.status import MessageStatus from ..conf import settings from ..exceptions import BroadcastError, InsufficientFundsError, InvalidMessageError from ..types import Account, StorageEnum, TokenType -from ..utils import extended_json_encoder, make_instance_content, make_program_content +from ..utils import ( + extended_json_encoder, + make_instance_content, + make_program_content, + make_verifiable_program_content, +) from .abstract import AuthenticatedAlephClient from .http import AlephHttpClient from .services.authenticated_port_forwarder import AuthenticatedPortForwarder @@ -51,7 +60,7 @@ import magic except ImportError: logger.info("Could not import library 'magic', MIME type detection disabled") - magic = None # type:ignore + magic = None # type: ignore class AuthenticatedAlephHttpClient(AlephHttpClient, AuthenticatedAlephClient): @@ -487,8 +496,11 @@ async def create_program( if status in (MessageStatus.PROCESSED, MessageStatus.PENDING): return message, status # type: ignore - # get the reason for rejection - rejected_message = await self.get_message_error(message.item_hash) + await self._raise_for_rejected_executable(message.item_hash) + + async def _raise_for_rejected_executable(self, item_hash: str) -> NoReturn: + """Fetch the rejection reason of an executable message and raise.""" + rejected_message = await self.get_message_error(item_hash) assert rejected_message, "No rejected message found" error_code = rejected_message["error_code"] if error_code == 5: @@ -506,6 +518,58 @@ async def create_program( else: raise ValueError(f"Unknown error code {error_code}: {rejected_message}") + async def create_verifiable_program( + self, + runtime: str, + workload: Union[VerifiedWorkload, Mapping[str, Any]], + measurements: Sequence[Union[LaunchMeasurement, Mapping[str, Any]]], + policy: int = DEFAULT_SNP_POLICY, + runtime_comment: str = "", + metadata: Optional[dict[str, Any]] = None, + address: Optional[str] = None, + payment: Optional[Payment] = None, + vcpus: Optional[int] = None, + memory: Optional[int] = None, + timeout_seconds: Optional[float] = None, + internet: bool = True, + volumes: Optional[Sequence[Union[VerifiedVolume, Mapping[str, Any]]]] = None, + requirements: Optional[HostRequirements] = None, + sync: bool = False, + channel: Optional[str] = settings.DEFAULT_CHANNEL, + storage_engine: StorageEnum = StorageEnum.storage, + ) -> Tuple[VerifiableProgramMessage, MessageStatus]: + address = address or settings.ADDRESS_TO_USE or self.account.get_address() + + content = make_verifiable_program_content( + runtime=runtime, + workload=workload, + measurements=measurements, + policy=policy, + runtime_comment=runtime_comment, + metadata=metadata, + address=address, + vcpus=vcpus, + memory=memory, + timeout_seconds=timeout_seconds, + internet=internet, + volumes=volumes, + requirements=requirements, + payment=payment, + ) + + message, status, _ = await self.submit( + content=content.model_dump(exclude_none=True), + message_type=MessageType.v_program, + channel=channel, + storage_engine=storage_engine, + sync=sync, + raise_on_rejected=False, + ) + if status in (MessageStatus.PROCESSED, MessageStatus.PENDING): + return message, status # type: ignore + + await self._raise_for_rejected_executable(message.item_hash) + async def create_instance( self, rootfs: str, diff --git a/src/aleph/sdk/utils.py b/src/aleph/sdk/utils.py index 0e23ced2..d0da57c0 100644 --- a/src/aleph/sdk/utils.py +++ b/src/aleph/sdk/utils.py @@ -20,6 +20,7 @@ Mapping, Optional, Protocol, + Sequence, Tuple, Type, TypeVar, @@ -38,14 +39,17 @@ MachineType, MessageType, ProgramContent, + VerifiableProgramContent, ) from aleph_message.models.execution.base import Payment, PaymentType from aleph_message.models.execution.environment import ( + DEFAULT_SNP_POLICY, FunctionEnvironment, FunctionTriggers, HostRequirements, HypervisorType, InstanceEnvironment, + LaunchMeasurement, MachineResources, Subscription, TrustedExecutionEnvironment, @@ -62,6 +66,13 @@ PersistentVolumeSizeMib, VolumePersistence, ) +from aleph_message.models.execution.vprogram import ( + TeeVerification, + VerifiableProgramEnvironment, + VerifiableProgramRuntime, + VerifiedVolume, + VerifiedWorkload, +) from aleph_message.utils import Mebibytes from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -76,7 +87,7 @@ import magic except ImportError: logger.info("Could not import library 'magic', MIME type detection disabled") - magic = None # type:ignore + magic = None # type: ignore def try_open_zip(path: Path) -> None: @@ -114,6 +125,67 @@ def create_archive(path: Path) -> Tuple[Path, Encoding]: raise FileNotFoundError("No file or directory to create the archive from") +def make_verifiable_program_content( + runtime: str, + workload: Union[VerifiedWorkload, Mapping[str, Any]], + measurements: Sequence[Union[LaunchMeasurement, Mapping[str, Any]]], + policy: int = DEFAULT_SNP_POLICY, + runtime_comment: str = "", + metadata: Optional[dict[str, Any]] = None, + address: Optional[str] = None, + vcpus: Optional[int] = None, + memory: Optional[int] = None, + timeout_seconds: Optional[float] = None, + internet: bool = True, + volumes: Optional[Sequence[Union[VerifiedVolume, Mapping[str, Any]]]] = None, + requirements: Optional[HostRequirements] = None, + payment: Optional[Payment] = None, +) -> VerifiableProgramContent: + """ + Create VerifiableProgramContent object given the provided fields. + + V-Programs are credit-only and immutable: the payment defaults to + credit on ETH and allow_amend is always False. Environment variables and + authorized keys are not accepted since they would reach the guest + unmeasured; ship them in the verity-bound workload instead. + """ + + address = address or "0x0000000000000000000000000000000000000000" + payment = payment or Payment(chain=Chain.ETH, type=PaymentType.credit) + vcpus = vcpus or settings.DEFAULT_VM_VCPUS + memory = memory or settings.DEFAULT_VM_MEMORY + timeout_seconds = timeout_seconds or settings.DEFAULT_VM_TIMEOUT + volumes = volumes if volumes is not None else [] + + return VerifiableProgramContent( + address=address, + allow_amend=False, + environment=VerifiableProgramEnvironment(internet=internet), + resources=MachineResources( + vcpus=vcpus, + memory=Mebibytes(memory), + seconds=int(timeout_seconds), + ), + runtime=VerifiableProgramRuntime( + ref=ItemHash(runtime), comment=runtime_comment + ), + workload=VerifiedWorkload.model_validate(workload), + verification=TeeVerification( + backend="sev_snp", + policy=policy, + measurements=[ + LaunchMeasurement.model_validate(measurement) + for measurement in measurements + ], + ), + volumes=[VerifiedVolume.model_validate(volume) for volume in volumes], + time=datetime.now().timestamp(), + metadata=metadata, + requirements=requirements, + payment=payment, + ) + + def get_message_type_value(message_type: Type[GenericMessage]) -> MessageType: """Returns the value of the 'type' field of a message type class.""" type_literal = message_type.__annotations__["type"] diff --git a/tests/unit/services/test_authorizations.py b/tests/unit/services/test_authorizations.py index f9322cee..3e2c662a 100644 --- a/tests/unit/services/test_authorizations.py +++ b/tests/unit/services/test_authorizations.py @@ -125,6 +125,9 @@ async def create_store(self, *args, **kwargs): async def create_program(self, *args, **kwargs): raise NotImplementedError + async def create_verifiable_program(self, *args, **kwargs): + raise NotImplementedError + async def create_instance(self, *args, **kwargs): raise NotImplementedError diff --git a/tests/unit/test_asynchronous.py b/tests/unit/test_asynchronous.py index 1221a9b0..975386e4 100644 --- a/tests/unit/test_asynchronous.py +++ b/tests/unit/test_asynchronous.py @@ -14,6 +14,7 @@ PostMessage, ProgramMessage, StoreMessage, + VerifiableProgramMessage, ) from aleph_message.models.execution.environment import ( HostRequirements, @@ -362,3 +363,64 @@ async def test_create_store_default_payment(mock_session_with_post_success): assert store_message.content.payment.type == PaymentType.hold assert store_message.content.payment.chain == Chain.ETH assert isinstance(store_message, StoreMessage) + + +V_PROGRAM_WORKLOAD = { + "ref": "cafecafecafecafecafecafecafecafecafecafecafecafecafecafecafecafe", + "hash_tree": "beefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef", + "roothash": "d" * 64, +} +V_PROGRAM_MEASUREMENTS = [ + {"platform": "sev_snp", "registers": {"launch": "e" * 96}, "vcpu_type": "EPYC-v4"} +] +V_PROGRAM_RUNTIME = "facefacefacefacefacefacefacefacefacefacefacefacefacefacefaceface" + + +@pytest.mark.asyncio +async def test_create_verifiable_program(mock_session_with_post_success): + async with mock_session_with_post_success as session: + message, message_status = await session.create_verifiable_program( + runtime=V_PROGRAM_RUNTIME, + workload=V_PROGRAM_WORKLOAD, + measurements=V_PROGRAM_MEASUREMENTS, + channel="TEST", + metadata={"tags": ["test"]}, + volumes=[{**V_PROGRAM_WORKLOAD, "comment": "weights"}], + ) + + assert mock_session_with_post_success.http_session.post.assert_called_once + assert isinstance(message, VerifiableProgramMessage) + assert message.type == MessageType.v_program + assert message.content.payment.type == PaymentType.credit + assert message.content.allow_amend is False + assert message.content.verification.backend == "sev_snp" + assert message.content.verification.measurements[0].registers.launch == "e" * 96 + assert message.content.workload.roothash == "d" * 64 + assert len(message.content.volumes) == 1 + + +@pytest.mark.asyncio +async def test_create_verifiable_program_rejects_non_credit_payment( + mock_session_with_post_success, +): + async with mock_session_with_post_success as session: + with pytest.raises(ValueError, match="credit-only"): + await session.create_verifiable_program( + runtime=V_PROGRAM_RUNTIME, + workload=V_PROGRAM_WORKLOAD, + measurements=V_PROGRAM_MEASUREMENTS, + payment=Payment(chain=Chain.ETH, type=PaymentType.hold), + ) + + +@pytest.mark.asyncio +async def test_create_verifiable_program_insufficient_funds( + mock_session_with_rejected_message, +): + async with mock_session_with_rejected_message as session: + with pytest.raises(InsufficientFundsError): + await session.create_verifiable_program( + runtime=V_PROGRAM_RUNTIME, + workload=V_PROGRAM_WORKLOAD, + measurements=V_PROGRAM_MEASUREMENTS, + ) From 152e4b95ab536ffb02b3e9b52c72a05edee4f2a0 Mon Sep 17 00:00:00 2001 From: Olivier Desenfans Date: Tue, 25 Aug 2026 15:46:08 +0200 Subject: [PATCH 2/2] refactor: address review on create_verifiable_program - create_instance now uses _raise_for_rejected_executable too - abstract create_verifiable_program raises the same import hint as siblings - fix no-op assert_called_once in the new test - add a test with pydantic model inputs and host requirements --- src/aleph/sdk/client/abstract.py | 4 ++- src/aleph/sdk/client/authenticated_http.py | 19 +------------ tests/unit/test_asynchronous.py | 33 +++++++++++++++++++++- 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/aleph/sdk/client/abstract.py b/src/aleph/sdk/client/abstract.py index c7af8496..eac4d116 100644 --- a/src/aleph/sdk/client/abstract.py +++ b/src/aleph/sdk/client/abstract.py @@ -585,7 +585,9 @@ async def create_verifiable_program( :param channel: Channel to use (Default: "TEST") :param storage_engine: Storage engine to use (Default: "storage") """ - raise NotImplementedError + raise NotImplementedError( + "Did you mean to import `AuthenticatedAlephHttpClient`?" + ) @abstractmethod async def create_instance( diff --git a/src/aleph/sdk/client/authenticated_http.py b/src/aleph/sdk/client/authenticated_http.py index 2ca4b419..4323bf2b 100644 --- a/src/aleph/sdk/client/authenticated_http.py +++ b/src/aleph/sdk/client/authenticated_http.py @@ -627,24 +627,7 @@ async def create_instance( if status in (MessageStatus.PROCESSED, MessageStatus.PENDING): return message, status # type: ignore - # get the reason for rejection - rejected_message = await self.get_message_error(message.item_hash) - assert rejected_message, "No rejected message found" - error_code = rejected_message["error_code"] - if error_code == 5: - # not enough balance - details = rejected_message["details"] - errors = details["errors"] - error = errors[0] - account_balance = float(error["account_balance"]) - required_balance = float(error["required_balance"]) - raise InsufficientFundsError( - token_type=TokenType.ALEPH, - required_funds=required_balance, - available_funds=account_balance, - ) - else: - raise ValueError(f"Unknown error code {error_code}: {rejected_message}") + await self._raise_for_rejected_executable(message.item_hash) async def forget( self, diff --git a/tests/unit/test_asynchronous.py b/tests/unit/test_asynchronous.py index 975386e4..02f73d81 100644 --- a/tests/unit/test_asynchronous.py +++ b/tests/unit/test_asynchronous.py @@ -19,10 +19,12 @@ from aleph_message.models.execution.environment import ( HostRequirements, HypervisorType, + LaunchMeasurement, MachineResources, NodeRequirements, TrustedExecutionEnvironment, ) +from aleph_message.models.execution.vprogram import VerifiedWorkload from aleph_message.status import MessageStatus from aleph.sdk.exceptions import InsufficientFundsError @@ -388,7 +390,7 @@ async def test_create_verifiable_program(mock_session_with_post_success): volumes=[{**V_PROGRAM_WORKLOAD, "comment": "weights"}], ) - assert mock_session_with_post_success.http_session.post.assert_called_once + mock_session_with_post_success.http_session.post.assert_called_once() assert isinstance(message, VerifiableProgramMessage) assert message.type == MessageType.v_program assert message.content.payment.type == PaymentType.credit @@ -424,3 +426,32 @@ async def test_create_verifiable_program_insufficient_funds( workload=V_PROGRAM_WORKLOAD, measurements=V_PROGRAM_MEASUREMENTS, ) + + +@pytest.mark.asyncio +async def test_create_verifiable_program_with_models_and_requirements( + mock_session_with_post_success, +): + """Pydantic models are accepted in place of mappings, and host + requirements are forwarded to the message content.""" + node_hash = ItemHash( + "beefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef" + ) + async with mock_session_with_post_success as session: + message, _ = await session.create_verifiable_program( + runtime=V_PROGRAM_RUNTIME, + workload=VerifiedWorkload.model_validate(V_PROGRAM_WORKLOAD), + measurements=[LaunchMeasurement.model_validate(V_PROGRAM_MEASUREMENTS[0])], + requirements=HostRequirements(node=NodeRequirements(node_hash=node_hash)), + runtime_comment="official runtime", + internet=False, + ) + + mock_session_with_post_success.http_session.post.assert_called_once() + assert isinstance(message, VerifiableProgramMessage) + assert message.content.requirements is not None + assert message.content.requirements.node is not None + assert message.content.requirements.node.node_hash == node_hash + assert message.content.runtime.comment == "official runtime" + assert message.content.environment.internet is False + assert message.content.verification.measurements[0].vcpu_type == "EPYC-v4"