From d112ae7f9f7aa357520f92f716eb705cc1032fce Mon Sep 17 00:00:00 2001 From: philogicae <38438271+philogicae@users.noreply.github.com> Date: Wed, 8 Jan 2025 22:30:08 +0200 Subject: [PATCH] Fix upgradability, add/fix persist/unpersist --- src/aleph_client/commands/help_strings.py | 3 + src/aleph_client/commands/program.py | 182 ++++++++++++++++++++-- tests/unit/test_program.py | 11 +- 3 files changed, 183 insertions(+), 13 deletions(-) diff --git a/src/aleph_client/commands/help_strings.py b/src/aleph_client/commands/help_strings.py index 48a8ccf5..e64e8bd6 100644 --- a/src/aleph_client/commands/help_strings.py +++ b/src/aleph_client/commands/help_strings.py @@ -64,3 +64,6 @@ PROGRAM_ENTRYPOINT = "Your program entrypoint. Example: `main:app` for Python programs, else `run.sh` for a script containing your launch command" PROGRAM_RUNTIME = "Hash of the runtime to use for your program. You can also create your own runtime and pin it. Currently defaults to `{runtime_id}` (Use `aleph program runtime-checker` to inspect it)" PROGRAM_BETA = "If true, you will be prompted to add message subscriptions to your program" +PROGRAM_UPDATABLE = "Allow program updates. By default, only the source code can be modified without requiring redeployement (same item hash). When enabled (set to True), this option allows to update any other field. However, such modifications will require a program redeployment (new item hash)" +PROGRAM_KEEP_CODE = "Keep the source code intact instead of deleting it" +PROGRAM_KEEP_PREV = "Keep the previous program intact instead of deleting it" diff --git a/src/aleph_client/commands/program.py b/src/aleph_client/commands/program.py index 697aa0aa..d5f2427c 100644 --- a/src/aleph_client/commands/program.py +++ b/src/aleph_client/commands/program.py @@ -73,6 +73,7 @@ async def upload( help=help_strings.PROGRAM_BETA, ), persistent: bool = False, + updatable: bool = typer.Option(False, help=help_strings.PROGRAM_UPDATABLE), skip_volume: bool = typer.Option(False, help=help_strings.SKIP_VOLUME), persistent_volume: Optional[List[str]] = typer.Option(None, help=help_strings.PERSISTENT_VOLUME), ephemeral_volume: Optional[List[str]] = typer.Option(None, help=help_strings.EPHEMERAL_VOLUME), @@ -90,7 +91,7 @@ async def upload( verbose: bool = True, debug: bool = False, ) -> Optional[str]: - """Register a program to run on aleph.im. For more information, see https://docs.aleph.im/computing/""" + """Register a program to run on aleph.im. For more information, see https://docs.aleph.im/computing""" setup_logging(debug) @@ -160,6 +161,7 @@ async def upload( program_ref=program_ref, entrypoint=entrypoint, metadata=dict(name=name), + allow_amend=updatable, runtime=runtime, storage_engine=StorageEnum.storage, channel=channel, @@ -223,7 +225,7 @@ async def update( verbose: bool = True, debug: bool = False, ): - """Update the code of an existing program""" + """Update the code of an existing program. The item hash will not change""" setup_logging(debug) @@ -247,7 +249,7 @@ async def update( typer.echo("Program does not exist") return 1 except ForgottenMessageError: - typer.echo("Program already forgotten") + typer.echo("Program has been forgotten") return 1 if program_message.sender != account.get_address(): typer.echo("You are not the owner of this program") @@ -318,7 +320,7 @@ async def update( async def delete( item_hash: str = typer.Argument(..., help="Item hash to unpersist"), reason: str = typer.Option("User deletion", help="Reason for deleting the program"), - delete_code: bool = typer.Option(True, help="Also delete the code"), + keep_code: bool = typer.Option(False, help=help_strings.PROGRAM_KEEP_CODE), private_key: Optional[str] = settings.PRIVATE_KEY_STRING, private_key_file: Optional[Path] = settings.PRIVATE_KEY_FILE, print_message: bool = typer.Option(False), @@ -347,7 +349,7 @@ async def delete( return 1 message, _ = await client.forget(hashes=[ItemHash(item_hash)], reason=reason) - if delete_code: + if not keep_code: try: code_volume: StoreMessage = await client.get_message( item_hash=existing_message.content.code.ref, message_type=StoreMessage @@ -446,7 +448,9 @@ async def list_programs( f"vCPU: [magenta3]{message.content.resources.vcpus}[/magenta3]\n", f"RAM: [magenta3]{message.content.resources.memory / 1_024:.2f} GiB[/magenta3]\n", "HyperV: [magenta3]Firecracker[/magenta3]\n", - f"Timeout: [magenta3]{message.content.resources.seconds}s[/magenta3]", + f"Timeout: [orange3]{message.content.resources.seconds}s[/orange3]\n", + f"Persistent: {'[green]Yes[/green]' if message.content.on.persistent else '[red]No[/red]'}\n", + f"Updatable: {'[green]Yes[/green]' if message.content.allow_amend else '[red]No[/red]'}", ] specifications = Text.from_markup("".join(specs)) volumes = "" @@ -486,23 +490,137 @@ async def list_programs( ) +@app.command() +async def persist( + item_hash: str = typer.Argument(..., help="Item hash to persist"), + keep_prev: bool = typer.Option( + False, + help=help_strings.PROGRAM_KEEP_PREV, + ), + private_key: Optional[str] = settings.PRIVATE_KEY_STRING, + private_key_file: Optional[Path] = settings.PRIVATE_KEY_FILE, + print_message: bool = typer.Option(False), + verbose: bool = True, + debug: bool = False, +): + """Recreate a non-persistent program as persistent""" + + setup_logging(debug) + + account = _load_account(private_key, private_key_file) + + async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: + try: + message: ProgramMessage = await client.get_message(item_hash=item_hash, message_type=ProgramMessage) + except MessageNotFoundError: + typer.echo("Program does not exist") + return 1 + except ForgottenMessageError: + typer.echo("Program has been forgotten") + return 1 + if message.sender != account.get_address(): + typer.echo("You are not the owner of this program") + return 1 + if not message.content.allow_amend: + typer.echo("Program is not updatable") + return 1 + if message.content.on.persistent: + typer.echo("Program is already persistent") + return 1 + + # Update content + content: ProgramContent = message.content.copy() + content.on.persistent = True + content.replaces = message.item_hash + + message, _status, _ = await client.submit( + content=content.dict(exclude_none=True), + message_type=message.type, + channel=message.channel, + ) + + if print_message: + typer.echo(f"{message.json(indent=4)}") + + # Delete previous non-persistent program + prev_label, prev_color = "INTACT", "orange3" + if not keep_prev: + await client.forget(hashes=[ItemHash(item_hash)], reason="Program persisted") + prev_label, prev_color = "DELETED", "red" + + if verbose: + hash_base32 = b32encode(b16decode(item_hash.upper())).strip(b"=").lower().decode() + func_url_1 = f"{settings.VM_URL_PATH.format(hash=item_hash)}" + func_url_2 = f"{settings.VM_URL_HOST.format(hash_base32=hash_base32)}" + console = Console() + infos = [ + Text.from_markup("Your program is now [green]persistent[/green]. It implies a new item hash.\n"), + Text.from_markup( + f"\n\n[{prev_color}]- Previous non-persistent program: {item_hash} -> {prev_label}[/{prev_color}]\n[green]- New persistent program: {message.item_hash}[/green]." + ), + Text.assemble( + "\n\nAvailable on:\n", + Text.from_markup( + f"↳ [bright_yellow][link={func_url_1}]{func_url_1}[/link][/bright_yellow]\n", + style="italic", + ), + Text.from_markup( + f"↳ [dark_olive_green2][link={func_url_2}]{func_url_2}[/link][/dark_olive_green2]", + style="italic", + ), + ), + ] + console.print( + Panel( + Text.assemble(*infos), + title="Program: Persist", + border_style="orchid", + expand=False, + title_align="left", + ) + ) + + @app.command() async def unpersist( item_hash: str = typer.Argument(..., help="Item hash to unpersist"), + keep_prev: bool = typer.Option( + False, + help=help_strings.PROGRAM_KEEP_PREV, + ), private_key: Optional[str] = settings.PRIVATE_KEY_STRING, private_key_file: Optional[Path] = settings.PRIVATE_KEY_FILE, + print_message: bool = typer.Option(False), + verbose: bool = True, debug: bool = False, ): - """Stop a persistent program by making it non-persistent""" + """Recreate a persistent program as non-persistent""" setup_logging(debug) account = _load_account(private_key, private_key_file) async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: - message: ProgramMessage = await client.get_message(item_hash=item_hash, message_type=ProgramMessage) - content: ProgramContent = message.content.copy() + try: + message: ProgramMessage = await client.get_message(item_hash=item_hash, message_type=ProgramMessage) + except MessageNotFoundError: + typer.echo("Program does not exist") + return 1 + except ForgottenMessageError: + typer.echo("Program has been forgotten") + return 1 + if message.sender != account.get_address(): + typer.echo("You are not the owner of this program") + return 1 + if not message.content.allow_amend: + typer.echo("Program is not updatable") + return 1 + if not message.content.on.persistent: + typer.echo("Program is already unpersistent") + return 1 + # Update content + content: ProgramContent = message.content.copy() content.on.persistent = False content.replaces = message.item_hash @@ -511,7 +629,47 @@ async def unpersist( message_type=message.type, channel=message.channel, ) - typer.echo(f"{message.json(indent=4)}") + + if print_message: + typer.echo(f"{message.json(indent=4)}") + + # Delete previous persistent program + prev_label, prev_color = "INTACT", "orange3" + if not keep_prev: + await client.forget(hashes=[ItemHash(item_hash)], reason="Program unpersisted") + prev_label, prev_color = "DELETED", "red" + + if verbose: + hash_base32 = b32encode(b16decode(item_hash.upper())).strip(b"=").lower().decode() + func_url_1 = f"{settings.VM_URL_PATH.format(hash=item_hash)}" + func_url_2 = f"{settings.VM_URL_HOST.format(hash_base32=hash_base32)}" + console = Console() + infos = [ + Text.from_markup("Your program is now [red]unpersistent[/red]. It implies a new item hash.\n"), + Text.from_markup( + f"\n\n[{prev_color}]- Previous persistent program: {item_hash} -> {prev_label}[/{prev_color}]\n[green]- New non-persistent program: {message.item_hash}[/green]." + ), + Text.assemble( + "\n\nAvailable on:\n", + Text.from_markup( + f"↳ [bright_yellow][link={func_url_1}]{func_url_1}[/link][/bright_yellow]\n", + style="italic", + ), + Text.from_markup( + f"↳ [dark_olive_green2][link={func_url_2}]{func_url_2}[/link][/dark_olive_green2]", + style="italic", + ), + ), + ] + console.print( + Panel( + Text.assemble(*infos), + title="Program: Unpersist", + border_style="orchid", + expand=False, + title_align="left", + ) + ) @app.command() @@ -523,7 +681,7 @@ async def logs( chain: Chain = typer.Option(None, help=help_strings.ADDRESS_CHAIN), debug: bool = False, ): - """Display the logs of a program. + """Display the logs of a program Will only show logs from the selected CRN""" @@ -613,7 +771,7 @@ async def runtime_checker( await delete( item_hash=program_hash, reason="Automatic deletion of the runtime checker program", - delete_code=True, + keep_code=True, private_key=private_key, private_key_file=private_key_file, print_message=False, diff --git a/tests/unit/test_program.py b/tests/unit/test_program.py index b9c1b519..f40977dd 100644 --- a/tests/unit/test_program.py +++ b/tests/unit/test_program.py @@ -55,6 +55,8 @@ def create_mock_program_message(mock_account): ], code=Dict(encoding="squashfs", entrypoint="main:app", ref=FAKE_STORE_HASH), runtime=Dict(ref=FAKE_STORE_HASH), + on=Dict(http=True, persistent=False), + allow_amend=False, ), ) return program @@ -199,7 +201,7 @@ async def delete_program(): print() # For better display when pytest -v -s await delete( item_hash=FAKE_PROGRAM_HASH, - delete_code=True, + keep_code=False, private_key=None, private_key_file=None, print_message=False, @@ -238,6 +240,13 @@ async def list_program(): await list_program() +@pytest.mark.asyncio +async def test_persist_program(): + mock_load_account = create_mock_load_account() + mock_account = mock_load_account.return_value + assert True + + @pytest.mark.asyncio async def test_unpersist_program(): mock_load_account = create_mock_load_account()