Skip to content

Commit

Permalink
Improve Instance Creation and Add instances list (#192)
Browse files Browse the repository at this point in the history
* Improve instance creation prompts, add instance list command, update available default rootfs to be better understandable, all important specs are now asked for in the wizard

* Fix formatting and typing

* Minor fixes
  • Loading branch information
MHHukiewitz authored Dec 15, 2023
1 parent f8b8e57 commit 695dee6
Show file tree
Hide file tree
Showing 3 changed files with 189 additions and 76 deletions.
163 changes: 139 additions & 24 deletions src/aleph_client/commands/instance.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
import asyncio
import logging
from base64 import b16decode, b32encode
from pathlib import Path
from typing import List, Optional, Union
from typing import List, Optional, Tuple, Union

import typer
from aiohttp import ClientResponseError, ClientSession
from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient
from aleph.sdk.account import _load_account
from aleph.sdk.conf import settings as sdk_settings
from aleph.sdk.exceptions import ForgottenMessageError, MessageNotFoundError, InsufficientFundsError
from aleph.sdk.exceptions import (
ForgottenMessageError,
InsufficientFundsError,
MessageNotFoundError,
)
from aleph.sdk.query.filters import MessageFilter
from aleph.sdk.types import AccountFromPrivateKey, StorageEnum
from aleph_message.models import InstanceMessage, ItemHash, StoreMessage
from aleph_message.models import InstanceMessage, ItemHash, MessageType, StoreMessage
from rich import box
from rich.console import Console
from rich.prompt import Prompt
from rich.table import Table

from aleph_client.commands import help_strings
from aleph_client.commands.utils import (
default_prompt,
get_or_prompt_volumes,
setup_logging,
validated_int_prompt,
Expand Down Expand Up @@ -56,8 +66,8 @@ async def create(
),
print_messages: bool = typer.Option(False),
rootfs: str = typer.Option(
settings.DEFAULT_ROOTFS_ID,
help="Hash of the rootfs to use for your instance. Defaults to aleph debian with Python3.8 and node. You can also create your own rootfs and pin it",
"Ubuntu 22",
help="Hash of the rootfs to use for your instance. Defaults to Ubuntu 22. You can also create your own rootfs and pin it",
),
rootfs_name: str = typer.Option(
settings.DEFAULT_ROOTFS_NAME,
Expand Down Expand Up @@ -93,7 +103,7 @@ def validate_ssh_pubkey_file(file: Union[str, Path]) -> Path:
return file

try:
validate_ssh_pubkey_file(ssh_pubkey_file)
ssh_pubkey_file = validate_ssh_pubkey_file(ssh_pubkey_file)
except ValueError:
ssh_pubkey_file = Path(
validated_prompt(
Expand All @@ -106,7 +116,25 @@ def validate_ssh_pubkey_file(file: Union[str, Path]) -> Path:

account: AccountFromPrivateKey = _load_account(private_key, private_key_file)

rootfs = default_prompt("Hash of the rootfs to use for your instance", rootfs)
os_map = {
settings.UBUNTU_22_ROOTFS_ID: "Ubuntu 22",
settings.DEBIAN_12_ROOTFS_ID: "Debian 12",
settings.DEBIAN_11_ROOTFS_ID: "Debian 11",
}

rootfs = Prompt.ask(
f"Do you want to use a custom rootfs or one of the following prebuilt ones?",
default=rootfs,
choices=[*os_map.values(), "custom"],
)

if rootfs == "custom":
rootfs = validated_prompt(
f"Enter the item hash of the rootfs to use for your instance",
lambda x: len(x) == 64,
)
else:
rootfs = next(k for k, v in os_map.items() if v == rootfs)

async with AlephHttpClient(api_server=sdk_settings.API_HOST) as client:
rootfs_message: StoreMessage = await client.get_message(
Expand All @@ -120,12 +148,21 @@ def validate_ssh_pubkey_file(file: Union[str, Path]) -> Path:
if rootfs_size is None and rootfs_message.content.size:
rootfs_size = rootfs_message.content.size

rootfs_name = default_prompt(
f"Name of the rootfs to use for your instance", default=rootfs_name
rootfs_name = Prompt.ask(
f"Name of the rootfs to use for your instance",
default=os_map.get(rootfs, rootfs_name),
)

vcpus = validated_int_prompt(
f"Number of virtual cpus to allocate", vcpus, min_value=1, max_value=4
)

memory = validated_int_prompt(
f"Maximum memory allocation on vm in MiB", memory, min_value=256, max_value=8000
)

rootfs_size = validated_int_prompt(
f"Size in MiB?", rootfs_size, min_value=2000, max_value=100000
f"Disk size in MiB", rootfs_size, min_value=2000, max_value=100000
)

volumes = get_or_prompt_volumes(
Expand Down Expand Up @@ -161,19 +198,14 @@ def validate_ssh_pubkey_file(file: Union[str, Path]) -> Path:
typer.echo(f"{message.json(indent=4)}")

item_hash: ItemHash = message.item_hash
hash_base32 = (
b32encode(b16decode(item_hash.upper())).strip(b"=").lower().decode()
)

typer.echo(
f"\nYour instance has been deployed on aleph.im\n\n"
f"Your SSH key has been added to the instance. You can connect in a few minutes to it using:\n"
# TODO: Resolve to IPv6 address
f" ssh -i {ssh_pubkey_file} root@{hash_base32}.aleph.sh\n\n"
"Also available on:\n"
f" {settings.VM_URL_PATH.format(hash=item_hash)}\n"
"Visualise on:\n https://explorer.aleph.im/address/"
f"{message.chain}/{message.sender}/message/INSTANCE/{item_hash}\n"
console = Console()
console.print(
f"\nYour instance {item_hash} has been deployed on aleph.im\n"
f"Your SSH key has been added to the instance. You can connect in a few minutes to it using:\n\n"
f" ssh root@<ipv6 address>\n\n"
f"Run the following command to get the IPv6 address of your instance:\n\n"
f" aleph instance list\n\n"
)


Expand All @@ -185,6 +217,7 @@ async def delete(
),
private_key: Optional[str] = sdk_settings.PRIVATE_KEY_STRING,
private_key_file: Optional[Path] = sdk_settings.PRIVATE_KEY_FILE,
print_message: bool = typer.Option(False),
debug: bool = False,
):
"""Delete an instance, unallocating all resources associated with it. Immutable volumes will not be deleted."""
Expand All @@ -211,4 +244,86 @@ async def delete(
raise typer.Exit(code=1)

message, status = await client.forget(hashes=[item_hash], reason=reason)
typer.echo(f"{message.json(indent=4)}")
if print_message:
typer.echo(f"{message.json(indent=4)}")

typer.echo(
f"Instance {item_hash} has been deleted. It will be removed by the scheduler in a few minutes."
)


async def _get_ipv6_address(message: InstanceMessage) -> Tuple[str, str]:
async with ClientSession() as session:
try:
resp = await session.get(
f"https://scheduler.api.aleph.cloud/api/v0/allocation/{message.item_hash}"
)
resp.raise_for_status()
status = await resp.json()
return status["vm_hash"], status["vm_ipv6"]
except ClientResponseError:
return message.item_hash, "Not available (yet)"


async def _show_instances(messages: List[InstanceMessage]):
table = Table(box=box.SIMPLE_HEAVY)
table.add_column("Item Hash", style="cyan")
table.add_column("Vcpus", style="magenta")
table.add_column("Memory", style="magenta")
table.add_column("Disk size", style="magenta")
table.add_column("IPv6 address", style="yellow")

scheduler_responses = dict(
await asyncio.gather(*[_get_ipv6_address(message) for message in messages])
)

for message in messages:
table.add_row(
message.item_hash,
str(message.content.resources.vcpus),
str(message.content.resources.memory),
str(message.content.rootfs.size_mib),
scheduler_responses[message.item_hash],
)
console = Console()
console.print(table)
console.print(f"To connect to an instance, use:\n\n" f" ssh root@<ipv6 address>\n")


@app.command()
async def list(
address: Optional[str] = typer.Option(None, help="Owner address of the instance"),
private_key: Optional[str] = typer.Option(
sdk_settings.PRIVATE_KEY_STRING, help=help_strings.PRIVATE_KEY
),
private_key_file: Optional[Path] = typer.Option(
sdk_settings.PRIVATE_KEY_FILE, help=help_strings.PRIVATE_KEY_FILE
),
json: bool = typer.Option(
default=False, help="Print as json instead of rich table"
),
debug: bool = False,
):
"""List all instances associated with your private key"""

setup_logging(debug)

if address is None:
account = _load_account(private_key, private_key_file)
address = account.get_address()

async with AlephHttpClient(api_server=sdk_settings.API_HOST) as client:
resp = await client.get_messages(
message_filter=MessageFilter(
message_types=[MessageType.instance],
addresses=[address],
),
page_size=100,
)
if not resp:
typer.echo("No instances found")
raise typer.Exit(code=1)
if json:
typer.echo(resp.json(indent=4))
else:
await _show_instances(resp.messages)
92 changes: 42 additions & 50 deletions src/aleph_client/commands/utils.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import logging
from datetime import datetime
from typing import Callable, Dict, List, Optional, TypeVar, Union
from typing import Callable, Dict, List, Optional, TypeVar, Union, Any

import typer
from aleph.sdk.types import GenericMessage
from pygments import highlight
from pygments.formatters.terminal256 import Terminal256Formatter
from pygments.lexers import JsonLexer
from rich.prompt import IntPrompt, Prompt, PromptError
from typer import echo


Expand Down Expand Up @@ -42,37 +43,18 @@ def setup_logging(debug: bool = False):
logging.basicConfig(level=level)


def yes_no_input(text: str, default: Optional[bool] = None):
while True:
if default is True:
response = input(f"{text} [Y/n] ")
elif default is False:
response = input(f"{text} [y/N] ")
else:
response = input(f"{text} ")

if response.lower() in ("y", "yes"):
return True
elif response.lower() in ("n", "no"):
return False
elif response == "" and default is not None:
return default
else:
if default is None:
echo("Please enter 'y', 'yes', 'n' or 'no'")
else:
echo("Please enter 'y', 'yes', 'n', 'no' or nothing")
continue
def yes_no_input(text: str, default: bool) -> bool:
return Prompt.ask(text, choices=["y", "n"], default=default) == "y"


def prompt_for_volumes():
while yes_no_input("Add volume ?", default=False):
comment = input("Description: ") or None
mount = input("Mount: ")
persistent = yes_no_input("Persist on VM host ?", default=False)
mount = Prompt.ask("Mount path: ")
comment = Prompt.ask("Comment: ")
persistent = yes_no_input("Persist on VM host?", default=False)
if persistent:
name = input("Volume name: ")
size_mib = int(input("Size in MiB: "))
name = Prompt.ask("Name: ")
size_mib = validated_int_prompt("Size (MiB): ", min_value=1)
yield {
"comment": comment,
"mount": mount,
Expand All @@ -81,7 +63,7 @@ def prompt_for_volumes():
"size_mib": size_mib,
}
else:
ref = input("Ref: ")
ref = Prompt.ask("Item hash: ")
use_latest = yes_no_input("Use latest version ?", default=True)
yield {
"comment": comment,
Expand Down Expand Up @@ -154,27 +136,25 @@ def str_to_datetime(date: Optional[str]) -> Optional[datetime]:
T = TypeVar("T")


def default_prompt(
prompt: str,
default: str,
) -> str:
return input(prompt + (f" [default: {default}]" if default else "")) or default


def validated_prompt(
prompt: str,
validator: Callable[[str], T],
default: Optional[T] = None,
) -> T:
validator: Callable[[str], Any],
default: Optional[str] = None,
) -> str:
while True:
value = input(prompt + (f" [default: {default}]" if default else ""))
if value == "" and default is not None:
return default
try:
return validator(value)
except ValueError as e:
echo(f"Invalid input: {e}\nTry again.")
value = Prompt.ask(
prompt,
default=default,
)
except PromptError:
echo(f"Invalid input: {value}\nTry again.")
continue
if value is None and default is not None:
return default
if validator(str(value)):
return str(value)
echo(f"Invalid input: {value}\nTry again.")


def validated_int_prompt(
Expand All @@ -183,12 +163,24 @@ def validated_int_prompt(
min_value: Optional[int] = None,
max_value: Optional[int] = None,
) -> int:
def validator(value: str) -> int:
value = int(value)
while True:
try:
value = IntPrompt.ask(
prompt + f" [min: {min_value or '-'}, max: {max_value or '-'}]",
default=default,
)
except PromptError:
echo(f"Invalid input: {value}\nTry again.")
continue
if value is None:
if default is not None:
return default
else:
value = 0
if min_value is not None and value < min_value:
raise ValueError(f"Value must be greater than or equal to {min_value}")
echo(f"Invalid input: {value}\nTry again.")
continue
if max_value is not None and value > max_value:
raise ValueError(f"Value must be less than or equal to {max_value}")
echo(f"Invalid input: {value}\nTry again.")
continue
return value

return validated_prompt(prompt, validator, default)
10 changes: 8 additions & 2 deletions src/aleph_client/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,14 @@ class Settings(BaseSettings):
DEFAULT_RUNTIME_ID: str = (
"bd79839bf96e595a06da5ac0b6ba51dea6f7e2591bb913deccded04d831d29f4"
)
DEFAULT_ROOTFS_ID: str = (
"549ec451d9b099cad112d4aaa2c00ac40fb6729a92ff252ff22eef0b5c3cb613"
DEBIAN_11_ROOTFS_ID: str = (
"887957042bb0e360da3485ed33175882ce72a70d79f1ba599400ff4802b7cee7"
)
DEBIAN_12_ROOTFS_ID: str = (
"6e30de68c6cedfa6b45240c2b51e52495ac6fb1bd4b36457b3d5ca307594d595"
)
UBUNTU_22_ROOTFS_ID: str = (
"77fef271aa6ff9825efa3186ca2e715d19e7108279b817201c69c34cedc74c27"
)
DEFAULT_ROOTFS_SIZE: int = 2_000
DEFAULT_ROOTFS_NAME: str = "main-rootfs"
Expand Down

0 comments on commit 695dee6

Please sign in to comment.