""" SporeStack CLI: `sporestack` """ import importlib.util import json import logging import os import sys import time from pathlib import Path from types import ModuleType from typing import TYPE_CHECKING, Any, Dict, Optional if sys.version_info[:2] >= (3, 8): # pragma: nocover from importlib.metadata import version as importlib_metadata_version else: # pragma: nocover # Python 3.7 doesn't have this. from importlib_metadata import version as importlib_metadata_version import typer def lazy_import(name: str) -> ModuleType: """ Lazily import a module. Helps speed up CLI performance. """ spec = importlib.util.find_spec(name) assert spec is not None assert spec.loader is not None loader = importlib.util.LazyLoader(spec.loader) spec.loader = loader module = importlib.util.module_from_spec(spec) sys.modules[name] = module loader.exec_module(module) return module # For mypy if TYPE_CHECKING: from . import api_client else: api_client = lazy_import("sporestack.api_client") HELP = """ SporeStack Python CLI Optional environment variables: SPORESTACK_ENDPOINT *or* SPORESTACK_USE_TOR_ENDPOINT TOR_PROXY (defaults to socks5h://127.0.0.1:9050 which is fine for most) """ cli = typer.Typer(help=HELP) logging.basicConfig(level=logging.INFO) DEFAULT_FLAVOR = "vps-1vcpu-1gb" WAITING_PAYMENT_TO_PROCESS = "Waiting for payment to process..." def get_api_endpoint() -> str: api_endpoint = os.getenv("SPORESTACK_ENDPOINT", api_client.CLEARNET_ENDPOINT) if os.getenv("SPORESTACK_USE_TOR_ENDPOINT", None) is not None: api_endpoint = api_client.TOR_ENDPOINT return api_endpoint def make_payment(currency: str, uri: str, usd: str) -> None: import segno premessage = """Payment URI: {} Pay *exactly* the specified amount. No more, no less. Pay within one hour at the very most. Resize your terminal and try again if QR code above is not readable. Press ctrl+c to abort.""" message = premessage.format(uri) qr = segno.make(uri) # This typer.echos. qr.terminal() typer.echo(message) typer.echo(f"Approximate price in USD: {usd}") input("[Press enter once you have made payment.]") @cli.command() def launch( hostname: str, days: int = typer.Option(...), ssh_key_file: Path = typer.Option(...), operating_system: str = typer.Option(...), flavor: str = DEFAULT_FLAVOR, currency: Optional[str] = None, settlement_token: Optional[str] = None, region: Optional[str] = None, ) -> None: """ Attempts to launch a server. """ from . import utils if settlement_token is not None: if currency is None or currency == "settlement": currency = "settlement" else: msg = "Cannot use non-settlement --currency with --settlement-token" typer.echo(msg, err=True) raise typer.Exit(code=2) if currency is None: typer.echo("--currency must be set.", err=True) raise typer.Exit(code=2) if machine_exists(hostname): typer.echo(f"{hostname} already created.") raise typer.Exit(code=1) ssh_key = ssh_key_file.read_text() machine_id = utils.random_machine_id() assert currency is not None response = api_client.launch( machine_id=machine_id, days=days, flavor=flavor, operating_system=operating_system, ssh_key=ssh_key, currency=currency, region=region, settlement_token=settlement_token, api_endpoint=get_api_endpoint(), retry=True, ) # This will be false at least the first time if paying with BTC or BCH. if response.payment.paid is False: assert response.payment.uri is not None make_payment( currency=currency, uri=response.payment.uri, usd=response.payment.usd, ) tries = 360 while tries > 0: tries = tries - 1 typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True) # FIXME: Wait one hour in a smarter way. # Waiting for payment to set in. time.sleep(10) response = api_client.launch( machine_id=machine_id, days=days, flavor=flavor, operating_system=operating_system, ssh_key=ssh_key, currency=currency, region=region, settlement_token=settlement_token, api_endpoint=get_api_endpoint(), retry=True, ) if response.payment.paid is True: break if response.created is False: tries = 360 while tries > 0: typer.echo("Waiting for server to build...", err=True) tries = tries + 1 # Waiting for server to spin up. time.sleep(10) response = api_client.launch( machine_id=machine_id, days=days, flavor=flavor, operating_system=operating_system, ssh_key=ssh_key, currency=currency, region=region, settlement_token=settlement_token, api_endpoint=get_api_endpoint(), retry=True, ) if response.created is True: break if response.created is False: typer.echo("Server creation failed, tries exceeded.", err=True) raise typer.Exit(code=1) created_dict = response.dict() created_dict["vm_hostname"] = hostname save_machine_info(created_dict) typer.echo(pretty_machine_info(created_dict), err=True) typer.echo(json.dumps(created_dict, indent=4)) @cli.command() def topup( hostname: str, days: int = typer.Option(...), currency: Optional[str] = None, settlement_token: Optional[str] = None, ) -> None: """ tops up an existing vm. """ if settlement_token is not None: if currency is None or currency == "settlement": currency = "settlement" else: msg = "Cannot use non-settlement --currency with --settlement-token" typer.echo(msg, err=True) raise typer.Exit(code=2) if currency is None: typer.echo("--currency must be set.", err=True) raise typer.Exit(code=2) if not machine_exists(hostname): typer.echo(f"{hostname} does not exist.") raise typer.Exit(code=1) machine_info = get_machine_info(hostname) machine_id = machine_info["machine_id"] assert currency is not None response = api_client.topup( machine_id=machine_id, days=days, currency=currency, api_endpoint=get_api_endpoint(), settlement_token=settlement_token, retry=True, ) # This will be false at least the first time if paying with anything # but settlement. if response.payment.paid is False: assert response.payment.uri is not None make_payment( currency=currency, uri=response.payment.uri, usd=response.payment.usd, ) tries = 360 while tries > 0: typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True) tries = tries - 1 # FIXME: Wait one hour in a smarter way. # Waiting for payment to set in. time.sleep(10) response = api_client.topup( machine_id=machine_id, days=days, currency=currency, api_endpoint=get_api_endpoint(), settlement_token=settlement_token, retry=True, ) if response.payment.paid is True: break machine_info["expiration"] = response.expiration save_machine_info(machine_info, overwrite=True) typer.echo(machine_info["expiration"]) def machine_info_path() -> Path: home = os.getenv("HOME") assert home is not None, "Unable to detect $HOME environment variable?" sporestack_dir = Path(home, ".sporestack") old_sporestack_dir = Path(home, ".sporestackv2") if old_sporestack_dir.exists(): typer.echo( "~/.sporestackv2 will be renamed to ~/.sporestack, this is backwards incompatible!!", # noqa: E501 err=True, ) if sporestack_dir.exists(): typer.echo( "~/.sporestackv2 AND ~/.sporestack detected. ABORTING! Contact support.", # noqa: E501 err=True, ) sys.exit(1) else: old_sporestack_dir.rename(sporestack_dir) # Make it, if it doesn't exist already. sporestack_dir.mkdir(exist_ok=True) return sporestack_dir def save_machine_info(machine_info: Dict[str, Any], overwrite: bool = False) -> None: """ Save info to disk. """ os.umask(0o0077) directory = machine_info_path() hostname = machine_info["vm_hostname"] json_file = directory.joinpath(f"{hostname}.json") if overwrite is False: assert json_file.exists() is False, f"{json_file} already exists." json_file.write_text(json.dumps(machine_info)) def get_machine_info(hostname: str) -> Dict[str, Any]: """ Get info from disk. """ directory = machine_info_path() json_file = directory.joinpath(f"{hostname}.json") if not json_file.exists(): raise ValueError(f"{hostname} does not exist in {directory} as {json_file}") machine_info = json.loads(json_file.read_bytes()) assert isinstance(machine_info, dict) if machine_info["vm_hostname"] != hostname: raise ValueError("hostname does not match filename.") return machine_info def pretty_machine_info(info: Dict[str, Any]) -> str: msg = "Hostname: {}\n".format(info["vm_hostname"]) msg += "Machine ID (keep this secret!): {}\n".format(info["machine_id"]) if "ipv6" in info["network_interfaces"][0]: msg += "IPv6: {}\n".format(info["network_interfaces"][0]["ipv6"]) if "ipv4" in info["network_interfaces"][0]: msg += "IPv4: {}\n".format(info["network_interfaces"][0]["ipv4"]) expiration = info["expiration"] human_expiration = time.strftime("%Y-%m-%d %H:%M:%S %z", time.localtime(expiration)) if "running" in info: msg += "Running: {}\n".format(info["running"]) msg += f"Expiration: {expiration} ({human_expiration})\n" time_to_live = expiration - int(time.time()) hours = time_to_live // 3600 msg += f"Server will be deleted in {hours} hours." return msg @cli.command() def list() -> None: """ List all locally known servers. """ from .exceptions import SporeStackUserError directory = machine_info_path() infos = [] for hostname_json in os.listdir(directory): hostname = hostname_json.split(".")[0] saved_vm_info = get_machine_info(hostname) try: upstream_vm_info = api_client.info( machine_id=saved_vm_info["machine_id"], ) saved_vm_info["expiration"] = upstream_vm_info.expiration saved_vm_info["running"] = upstream_vm_info.running infos.append(saved_vm_info) except SporeStackUserError as e: expiration = saved_vm_info["expiration"] human_expiration = time.strftime( "%Y-%m-%d %H:%M:%S %z", time.localtime(expiration) ) msg = hostname msg += f" expired ({expiration} {human_expiration}): " msg += str(e) typer.echo(msg) for info in infos: typer.echo() typer.echo(pretty_machine_info(info)) typer.echo() def machine_exists(hostname: str) -> bool: """ Check if the VM's JSON exists locally. """ return machine_info_path().joinpath(f"{hostname}.json").exists() @cli.command() def get_attribute(hostname: str, attribute: str) -> None: """ Returns an attribute about the VM. """ machine_info = get_machine_info(hostname) typer.echo(machine_info[attribute]) @cli.command() def info(hostname: str) -> None: """ Info on the VM """ machine_info = get_machine_info(hostname) machine_id = machine_info["machine_id"] typer.echo( api_client.info(machine_id=machine_id, api_endpoint=get_api_endpoint()).json() ) @cli.command() def start(hostname: str) -> None: """ Boots the VM. """ machine_info = get_machine_info(hostname) machine_id = machine_info["machine_id"] api_client.start(machine_id=machine_id, api_endpoint=get_api_endpoint()) typer.echo(f"{hostname} started.") @cli.command() def stop(hostname: str) -> None: """ Immediately kills the VM. """ machine_info = get_machine_info(hostname) machine_id = machine_info["machine_id"] api_client.stop(machine_id=machine_id, api_endpoint=get_api_endpoint()) typer.echo(f"{hostname} stopped.") @cli.command() def delete(hostname: str) -> None: """ Deletes the VM (most likely prematurely. """ machine_info = get_machine_info(hostname) machine_id = machine_info["machine_id"] api_client.delete(machine_id=machine_id, api_endpoint=get_api_endpoint()) # Also remove the .json file machine_info_path().joinpath(f"{hostname}.json").unlink() typer.echo(f"{hostname} was deleted.") @cli.command() def rebuild(hostname: str) -> None: """ Rebuilds the VM with the operating system and SSH key given at launch time. Will take a couple minutes to complete after the request is made. """ machine_info = get_machine_info(hostname) machine_id = machine_info["machine_id"] api_client.rebuild(machine_id=machine_id, api_endpoint=get_api_endpoint()) typer.echo(f"{hostname} rebuilding.") @cli.command() def settlement_token_enable( token: str, dollars: int = typer.Option(...), currency: str = typer.Option(...), ) -> None: """ Enables a new settlement token. Dollars is starting balance. """ response = api_client.token_enable( token=token, dollars=dollars, currency=currency, api_endpoint=get_api_endpoint(), retry=True, ) uri = response.payment.uri assert uri is not None usd = response.payment.usd make_payment(currency=currency, uri=uri, usd=usd) tries = 360 * 2 while tries > 0: typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True) tries = tries - 1 # FIXME: Wait two hours in a smarter way. # Waiting for payment to set in. time.sleep(10) response = api_client.token_enable( token=token, dollars=dollars, currency=currency, api_endpoint=get_api_endpoint(), retry=True, ) if response.payment.paid is True: typer.echo( f"{token} has been enabled with ${dollars}. Save it and don't lose it!" ) return raise ValueError(f"{token} did not get enabled in time.") @cli.command() def settlement_token_add( token: str, dollars: int = typer.Option(...), currency: str = typer.Option(...), ) -> None: """ Adds balance to an existing settlement token. """ response = api_client.token_add( token, dollars, currency=currency, api_endpoint=get_api_endpoint(), retry=True, ) uri = response.payment.uri assert uri is not None usd = response.payment.usd make_payment(currency=currency, uri=uri, usd=usd) tries = 360 * 2 while tries > 0: typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True) tries = tries - 1 # FIXME: Wait two hours in a smarter way. response = api_client.token_add( token, dollars, currency=currency, api_endpoint=get_api_endpoint(), retry=True, ) # Waiting for payment to set in. time.sleep(10) if response.payment.paid is True: typer.echo(f"Added {dollars} dollars to {token}") return raise ValueError(f"{token} did not get enabled in time.") @cli.command() def settlement_token_balance(token: str) -> None: """ Gets balance for a settlement token. """ typer.echo( api_client.token_balance(token=token, api_endpoint=get_api_endpoint()).usd ) @cli.command() def settlement_token_generate() -> None: """ Generates a settlement token that can be enabled. """ from . import utils typer.echo(utils.random_token()) @cli.command() def version() -> None: """ Returns the installed version. """ typer.echo(importlib_metadata_version(__package__)) @cli.command() def api_endpoint() -> None: """ Prints the selected API endpoint: Env var: SPORESTACK_ENDPOINT, or, SPORESTACK_USE_TOR=1 """ endpoint = get_api_endpoint() if ".onion" in endpoint: typer.echo(f"{endpoint} using {api_client._get_tor_proxy()}") return else: typer.echo(endpoint) return if __name__ == "__main__": cli()