diff --git a/setup.cfg b/setup.cfg index 55d26ee2..9ff3d051 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,6 +42,7 @@ install_requires = python-magic pygments rich + aiodns # The usage of test_requires is discouraged, see `Dependency Management` docs # tests_require = pytest; pytest-cov # Require a specific Python version, e.g. Python 2.7 or >= 3.4 diff --git a/src/aleph_client/__main__.py b/src/aleph_client/__main__.py index 7a29bdeb..aa11dcab 100644 --- a/src/aleph_client/__main__.py +++ b/src/aleph_client/__main__.py @@ -5,23 +5,10 @@ import typer from aleph_client.utils import AsyncTyper -from .commands import about, account, aggregate, files, message, program, node - -app = AsyncTyper() +from .commands import about, account, aggregate, domain, files, message, node, program -@app.callback() -def common( - ctx: typer.Context, - version: bool = typer.Option( - None, "--version", callback=about.get_version, help="Show Aleph CLI Version" - ), - v: bool = typer.Option( - None, "-v", callback=about.get_version, help="Show Aleph CLI Version" - ), -): - pass - +app = AsyncTyper() app.add_typer(account.app, name="account", help="Manage account") app.add_typer( @@ -41,6 +28,7 @@ def common( app.add_typer(about.app, name="about", help="Display the informations of Aleph CLI") app.add_typer(node.app, name="node", help="Get node info on aleph.im network") +app.add_typer(domain.app, name="domain", help="Manage custom Domain (dns) on aleph.im") if __name__ == "__main__": app() diff --git a/src/aleph_client/commands/aggregate.py b/src/aleph_client/commands/aggregate.py index f15374d5..efe149e8 100644 --- a/src/aleph_client/commands/aggregate.py +++ b/src/aleph_client/commands/aggregate.py @@ -17,8 +17,6 @@ app = AsyncTyper() -from aleph_client.commands.utils import colorful_message_json - @app.command() async def forget( @@ -131,5 +129,3 @@ async def get( typer.echo(json.dumps(aggregates, indent=4, default=extended_json_encoder)) else: typer.echo("No aggregates found for the given key and content.") - - diff --git a/src/aleph_client/commands/domain.py b/src/aleph_client/commands/domain.py new file mode 100644 index 00000000..e899ac2e --- /dev/null +++ b/src/aleph_client/commands/domain.py @@ -0,0 +1,324 @@ +from pathlib import Path +from time import sleep +from typing import Optional, cast + +import typer +from aleph.sdk.account import _load_account +from aleph.sdk.client import AlephHttpClient, AuthenticatedAlephHttpClient +from aleph.sdk.conf import settings as sdk_settings +from aleph.sdk.domain import ( + DomainValidator, + Hostname, + TargetType, + get_target_type, + hostname_from_url, +) +from aleph.sdk.exceptions import DomainConfigurationError +from aleph.sdk.query.filters import MessageFilter +from aleph.sdk.types import AccountFromPrivateKey +from aleph_message.models import AggregateMessage, MessageType +from rich.console import Console +from rich.prompt import Confirm, Prompt +from rich.table import Table + +from aleph_client.commands import help_strings +from aleph_client.utils import AsyncTyper + +app = AsyncTyper() + + +async def get_aggregate_domain_info(account, fqdn): + async with AlephHttpClient(api_server=sdk_settings.API_HOST) as client: + aggregates = await client.get_messages( + message_filter=MessageFilter( + addresses=[str(account.get_address())], + message_types=[MessageType.aggregate], + ), + page=1, + page_size=1000, + ) + + for message in aggregates.messages: + aggregate = cast(AggregateMessage, message) + if aggregate.content.key == "domains": + for domain, info in aggregate.content.content.items(): + if domain == fqdn: + return {"timestamp": aggregate.content.time, "info": info} + return None + + +async def check_domain_records(fqdn, target, owner): + domain_validator = DomainValidator() + try: + status = await domain_validator.check_domain(fqdn, target, owner) + except DomainConfigurationError as msg: + help_, err, status = msg.args[0] + + return status + + +async def detach_resource(account: AccountFromPrivateKey, fqdn: Hostname): + domain_info = await get_aggregate_domain_info(account, fqdn) + console = Console() + + table = Table(title=f"Detach resource of: {fqdn}") + table.add_column("Current resource", justify="right", style="red", no_wrap=True) + table.add_column("New resource", justify="right", style="green", no_wrap=True) + table.add_column("Resource type", style="magenta") + + if domain_info is not None and domain_info.get("info"): + current_resource = domain_info["info"]["message_id"] + else: + current_resource = "null" + + resource_type = await get_target_type(fqdn) + table.add_row( + f"{current_resource[:16]}...{current_resource[-16:]}", "", resource_type + ) + + console.print(table) + + if Confirm.ask("Continue"): + """Update aggregate message""" + + async with AuthenticatedAlephHttpClient( + account=account, api_server=sdk_settings.API_HOST + ) as client: + aggregate_content = {fqdn: None} + + aggregate_message, message_status = await client.create_aggregate( + key="domains", content=aggregate_content, channel="ALEPH-CLOUDSOLUTIONS" + ) + + console.log("[green bold]Resource detached!") + console.log( + f"Visualise on: https://explorer.aleph.im/address/ETH/{account.get_address()}/message/AGGREGATE/{aggregate_message.item_hash}" + ) + + +async def attach_resource( + account: AccountFromPrivateKey, fqdn: Hostname, item_hash: Optional[str] = None +): + domain_info = await get_aggregate_domain_info(account, fqdn) + console = Console() + + while not item_hash: + item_hash = Prompt.ask("Enter Hash reference of the resource to attach") + + table = Table(title=f"Attach resource to: {fqdn}") + table.add_column("Current resource", justify="right", style="red", no_wrap=True) + table.add_column("New resource", justify="right", style="green", no_wrap=True) + table.add_column("Resource type", style="magenta") + + """ + Detect target type on the fly to be able to switch to another type + """ + resource_type = await get_target_type(fqdn) + + if domain_info is not None and domain_info.get("info"): + current_resource = domain_info["info"]["message_id"] + else: + current_resource = "null" + + table.add_row( + f"{current_resource[:16]}...{current_resource[-16:]}", + f"{item_hash[:16]}...{item_hash[-16:]}", + resource_type, + ) + + console.print(table) + + if Confirm.ask("Continue"): + """Create aggregate message""" + + async with AuthenticatedAlephHttpClient( + account=account, api_server=sdk_settings.API_HOST + ) as client: + aggregate_content = { + fqdn: { + "message_id": item_hash, + "type": resource_type, + # console page compatibility + "programType": resource_type, + } + } + + aggregate_message, message_status = await client.create_aggregate( + key="domains", content=aggregate_content, channel="ALEPH-CLOUDSOLUTIONS" + ) + + console.log("[green bold]Resource attached!") + console.log( + f"Visualise on: https://explorer.aleph.im/address/ETH/{account.get_address()}/message/AGGREGATE/{aggregate_message.item_hash}" + ) + + +@app.command() +async def add( + 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 + ), + fqdn: str = typer.Argument(..., help=help_strings.CUSTOM_DOMAIN_NAME), + target: Optional[TargetType] = typer.Option( + None, help=help_strings.CUSTOM_DOMAIN_TARGET_TYPES + ), + item_hash: Optional[str] = typer.Option( + None, help=help_strings.CUSTOM_DOMAIN_ITEM_HASH + ), + owner: Optional[str] = typer.Option( + None, help=help_strings.CUSTOM_DOMAIN_OWNER_ADDRESS + ), +): + """Add and link a Custom Domain.""" + account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + + console = Console() + domain_validator = DomainValidator() + fqdn = hostname_from_url(fqdn) + + if target is None: + target = Prompt.ask( + "Select a target resource type", + choices=[TargetType.IPFS, TargetType.PROGRAM, TargetType.INSTANCE], + ) + + table = Table(title=f"Required DNS entries for: {fqdn}") + + table.add_column("RECORD ID", justify="right", style="cyan", no_wrap=True) + table.add_column("DNS TYPE", justify="right", style="cyan", no_wrap=True) + table.add_column("DNS NAME", style="magenta") + table.add_column("DNS VALUE", justify="right", style="green") + + owner = owner or account.get_address() + dns_rules = domain_validator.get_required_dns_rules(fqdn, target, owner) + for rule_id, rule in enumerate(dns_rules): + table.add_row( + str(rule_id), rule.dns["type"], rule.dns["name"], rule.dns["value"] + ) + + console.print(table) + + msg_status = "[bold green]Detecting dns..." + + with console.status(msg_status) as status: + max_retries = 5 + while dns_rules: + rule = dns_rules[0] + """Get rules check status""" + checks = await check_domain_records(fqdn, target, owner) + completed_rules = [] + for index, rule in enumerate(dns_rules): + if checks[rule.name] is True: + """Pass configured rules""" + completed_rules.append(rule) + console.log(f"record: {index} [bold green] OK") + + for _rule in completed_rules: + dns_rules.remove(_rule) + completed_rules = [] + + if dns_rules: + rule = dns_rules[0] + console.log(f"[green]{rule.info}") + status.update(f"{msg_status} [bold red]{rule.on_error}") + + max_retries -= 1 + sleep(10) + + if max_retries == 0: + status.stop() + continue_ = Confirm.ask("Continue?") + if continue_: + status.start() + max_retries = 5 + else: + raise typer.Exit() + + """Attach option""" + if Confirm.ask(f"Attach ressource to [bold green]{fqdn}"): + await attach_resource(account, fqdn, item_hash) + + raise typer.Exit() + + +@app.command() +async def attach( + 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 + ), + fqdn: str = typer.Argument(..., help=help_strings.CUSTOM_DOMAIN_NAME), + item_hash: Optional[str] = typer.Option( + None, help=help_strings.CUSTOM_DOMAIN_ITEM_HASH + ), +): + """Attach resource to a Custom Domain.""" + account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + + await attach_resource(account, Hostname(fqdn), item_hash) + raise typer.Exit() + + +@app.command() +async def detach( + 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 + ), + fqdn: str = typer.Argument(..., help=help_strings.CUSTOM_DOMAIN_NAME), +): + """Unlink Custom Domain.""" + account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + + await detach_resource(account, Hostname(fqdn)) + raise typer.Exit() + + +@app.command() +async def info( + 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 + ), + fqdn: str = typer.Argument(..., help=help_strings.CUSTOM_DOMAIN_NAME), +): + """Show Custom Domain Details.""" + account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + + console = Console() + domain_validator = DomainValidator() + + domain_info = await get_aggregate_domain_info(account, fqdn) + if domain_info is None or domain_info.get("info") is None: + console.log(f"Domain: {fqdn} not configured") + raise typer.Exit() + + table = Table(title=f"Domain info: {fqdn}") + table.add_column("Resource type", justify="right", style="cyan", no_wrap=True) + table.add_column("Attached resource", justify="right", style="cyan", no_wrap=True) + table.add_column("Target resource", justify="right", style="cyan", no_wrap=True) + + resource_type = TargetType(domain_info["info"]["type"]) + final_resource = "Unknown" + + if resource_type == TargetType.IPFS: + final_resource = "" + elif resource_type == TargetType.PROGRAM: + final_resource = domain_info["info"]["message_id"] + if resource_type == TargetType.INSTANCE: + ips = await domain_validator.get_ipv6_addresses(Hostname(fqdn)) + final_resource = ",".join([str(ip) for ip in ips]) + + table.add_row(resource_type, domain_info["info"]["message_id"], final_resource) + + console.print(table) + raise typer.Exit() diff --git a/src/aleph_client/commands/help_strings.py b/src/aleph_client/commands/help_strings.py index f8617806..a4c14c40 100644 --- a/src/aleph_client/commands/help_strings.py +++ b/src/aleph_client/commands/help_strings.py @@ -4,3 +4,7 @@ PRIVATE_KEY_FILE = "Path to your private key file" REF = "Checkout https://aleph-im.gitbook.io/aleph-js/api-resources-reference/posts" SIGNABLE_MESSAGE = "Message to sign" +CUSTOM_DOMAIN_TARGET_TYPES = "IPFS|PROGRAM|INSTANCE" +CUSTOM_DOMAIN_OWNER_ADDRESS = "Owner address, default current account" +CUSTOM_DOMAIN_NAME = "Domain name. ex: aleph.im" +CUSTOM_DOMAIN_ITEM_HASH = "Item hash" diff --git a/src/aleph_client/commands/message.py b/src/aleph_client/commands/message.py index 617963a4..385cdeab 100644 --- a/src/aleph_client/commands/message.py +++ b/src/aleph_client/commands/message.py @@ -11,8 +11,8 @@ 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.query.responses import MessagesResponse from aleph.sdk.query.filters import MessageFilter +from aleph.sdk.query.responses import MessagesResponse from aleph.sdk.types import AccountFromPrivateKey, StorageEnum from aleph.sdk.utils import extended_json_encoder from aleph_message.models import AlephMessage, ItemHash, MessageType, ProgramMessage @@ -66,9 +66,11 @@ async def find( parsed_channels = channels.split(",") if channels else None parsed_chains = chains.split(",") if chains else None - message_types = [ - MessageType(message_type) for message_type in parsed_message_types - ] if parsed_message_types else None + message_types = ( + [MessageType(message_type) for message_type in parsed_message_types] + if parsed_message_types + else None + ) start_time = str_to_datetime(start_date) end_time = str_to_datetime(end_date) @@ -259,8 +261,9 @@ async def watch( original: AlephMessage = await client.get_message(item_hash=ref) async for message in client.watch_messages( message_filter=MessageFilter( - refs=[ref], addresses=[original.content.address] - )): + refs=[ref], addresses=[original.content.address] + ) + ): typer.echo(f"{message.json(indent=indent)}") diff --git a/src/aleph_client/commands/program.py b/src/aleph_client/commands/program.py index d7650427..68336860 100644 --- a/src/aleph_client/commands/program.py +++ b/src/aleph_client/commands/program.py @@ -28,7 +28,7 @@ yes_no_input, ) from aleph_client.conf import settings -from aleph_client.utils import create_archive, AsyncTyper +from aleph_client.utils import AsyncTyper, create_archive logger = logging.getLogger(__name__) app = AsyncTyper() @@ -67,24 +67,24 @@ def upload( persistent: bool = False, persistent_volume: Optional[List[str]] = typer.Option( None, - help="""Takes 3 parameters - A persistent volume is allocated on the host machine at any time - eg: Use , to seperate the parameters and no spaces + help="""Takes 3 parameters + A persistent volume is allocated on the host machine at any time + eg: Use , to seperate the parameters and no spaces --persistent_volume persistence=host,name=my-volume,size=100 ./my-program main:app """, ), ephemeral_volume: Optional[List[str]] = typer.Option( None, - help="""Takes 1 parameter Only - Ephemeral volumes can move and be removed by the host,Garbage collected basically, when the VM isn't running - eg: Use , to seperate the parameters and no spaces + help="""Takes 1 parameter Only + Ephemeral volumes can move and be removed by the host,Garbage collected basically, when the VM isn't running + eg: Use , to seperate the parameters and no spaces --ephemeral-volume size_mib=100 ./my-program main:app """, ), immutable_volume: Optional[List[str]] = typer.Option( None, - help="""Takes 3 parameters - Immutable volume is one whose contents do not change - eg: Use , to seperate the parameters and no spaces + help="""Takes 3 parameters + Immutable volume is one whose contents do not change + eg: Use , to seperate the parameters and no spaces --immutable-volume ref=25a393222692c2f73489dc6710ae87605a96742ceef7b91de4d7ec34bb688d94,use_latest=true,mount=/mnt/volume ./my-program main:app """, ), diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index 21dbc044..5d367f7c 100644 --- a/src/aleph_client/utils.py +++ b/src/aleph_client/utils.py @@ -2,7 +2,7 @@ import inspect import logging import os -from functools import wraps, partial +from functools import partial, wraps from pathlib import Path from shutil import make_archive from typing import Tuple, Type diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index 28561a1d..8422fb81 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -2,4 +2,4 @@ def test_version(): - assert __version__ != "" \ No newline at end of file + assert __version__ != ""