sporestack-python/src/sporestack/cli.py

1283 lines
39 KiB
Python

"""
SporeStack CLI: `sporestack`
"""
import json
import logging
import os
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
import typer
from ._models import Currency
if sys.version_info >= (3, 9): # pragma: nocover
from typing import Annotated
else: # pragma: nocover
from typing_extensions import Annotated
if TYPE_CHECKING:
from . import api
from .api_client import APIClient
from .models import Invoice
HELP = """
SporeStack Python CLI
Optional environment variables:
SPORESTACK_ENDPOINT
*or*
SPORESTACK_USE_TOR_ENDPOINT
TOR_PROXY (defaults to socks5://127.0.0.1:9050 which is fine for most)
"""
_home = os.getenv("HOME", None)
assert _home is not None, "Unable to detect $HOME environment variable?"
HOME = Path(_home)
SPORESTACK_DIR = Path(os.getenv("SPORESTACK_DIR", HOME / ".sporestack"))
# Try to protect files in ~/.sporestack
os.umask(0o0077)
cli = typer.Typer(help=HELP)
HOME = Path(_home)
token_cli = typer.Typer(help="Commands to interact with SporeStack tokens.")
cli.add_typer(token_cli, name="token")
server_cli = typer.Typer(help="Commands to interact with SporeStack servers.")
cli.add_typer(server_cli, name="server")
log = logging.getLogger(__name__)
_log_level = os.getenv("LOG_LEVEL", "warning").upper()
_numeric_log_level = getattr(logging, _log_level, None)
if _numeric_log_level is None:
raise ValueError(f"LOG_LEVEL: {_log_level} is invalid. Aborting!")
assert isinstance(_numeric_log_level, int)
logging.basicConfig(level=_numeric_log_level)
DEFAULT_TOKEN = "primary"
DEFAULT_FLAVOR = "vps-1vcpu-1gb"
# Users may have a different key file, but this is the most common.
DEFAULT_SSH_KEY_FILE = HOME / ".ssh" / "id_rsa.pub"
DEFAULT_TOKEN_SSH_KEY_PRIVATE = Path("id_ed25519")
DEFAULT_TOKEN_SSH_KEY_PUBLIC = DEFAULT_TOKEN_SSH_KEY_PRIVATE.with_suffix(".pub")
# On disk format
TOKEN_VERSION = 1
WAITING_PAYMENT_TO_PROCESS = "Waiting for payment to process..."
def get_api_endpoint() -> str:
from .api_client import CLEARNET_ENDPOINT, TOR_ENDPOINT
api_endpoint = os.getenv("SPORESTACK_ENDPOINT", CLEARNET_ENDPOINT)
if os.getenv("SPORESTACK_USE_TOR_ENDPOINT", None) is not None:
api_endpoint = TOR_ENDPOINT
return api_endpoint
def get_api_client() -> "APIClient":
from .api_client import APIClient
return APIClient(api_endpoint=get_api_endpoint())
def invoice_qr(invoice: "Invoice") -> None:
import segno
qr = segno.make(invoice.payment_uri)
qr.terminal()
def normalize_ssh_key_file(ssh_key_file: Union[Path, None], token: str) -> Path:
if ssh_key_file is None:
token_specific_path = ssh_key_path(token)
token_specific_key = token_specific_path / DEFAULT_TOKEN_SSH_KEY_PUBLIC
if token_specific_key.exists():
ssh_key_file = token_specific_key
elif DEFAULT_SSH_KEY_FILE.exists():
ssh_key_file = DEFAULT_SSH_KEY_FILE
if ssh_key_file is None:
typer.echo(
"No SSH key specified with --ssh-key-file, nor was "
f"{token_specific_key} or {DEFAULT_SSH_KEY_FILE} found.",
err=True,
)
typer.echo("You can generate a SSH key with `ssh-key-gen`", err=True)
raise typer.Exit(code=1)
return ssh_key_file
@server_cli.command()
def launch(
days: Annotated[
int,
typer.Option(
min=1,
max=90,
help=(
"Initially fund the server to run for this many days. Use "
"--autorenew if you don't want it to expire."
),
show_default=False,
),
],
operating_system: Annotated[
str,
typer.Option(
help=(
"Example: debian-12 (Run `sporestack server operating-systems` for "
"more options.)"
),
show_default=False,
),
],
hostname: Annotated[
str,
typer.Option(
help=(
"Give the server a hostname to help remember what it's for. "
"(Note: This is visible to us.)"
)
),
] = "",
ssh_key_file: Annotated[
Union[Path, None],
typer.Option(
help=(
"SSH key that the new server will allow to login as root. Defaults "
"to the token-specific SSH key, or ~/.ssh/id_rsa.pub if the former "
"was not found."
),
show_default=False,
),
] = None,
flavor: Annotated[
str, typer.Option(help="Run `sporestack server flavors` to see more options.")
] = DEFAULT_FLAVOR,
token: Annotated[
str, typer.Option(help="Which token to launch the server with.")
] = DEFAULT_TOKEN,
region: Annotated[
Optional[str],
typer.Option(
help=(
"Leave unset for random region selection. Or run `sporestack server "
"regions` for options."
),
show_default=False,
),
] = None,
quote: bool = typer.Option(True, help="Require manual price confirmation."),
autorenew: bool = typer.Option(
False, help="Automatically renew server. (--days 7) recommended if using this."
),
wait: bool = typer.Option(
True, help="Wait for server to be assigned an IP address."
),
) -> None:
"""Launch a server on SporeStack."""
typer.echo(f"Launching server with token {token}...", err=True)
_token = load_token(token)
ssh_key_file = normalize_ssh_key_file(ssh_key_file=ssh_key_file, token=token)
typer.echo(f"Using SSH key: {ssh_key_file}")
from .client import Client
client = Client(
api_client=get_api_client(),
client_token=_token,
ssh_key=ssh_key_file.read_text(),
)
if quote:
quote_response = client.server_quote(days=days, flavor=flavor)
msg = f"Is {quote_response.usd} for {days} day(s) of {flavor} okay?"
typer.echo(msg, err=True)
input("[Press ctrl+c to cancel, or enter to accept.]")
if autorenew:
typer.echo(
"Server will be automatically renewed (from this token) to one week of expiration.", # noqa: E501
err=True,
)
typer.echo(
"If using this feature, watch your token balance and server expiration closely!", # noqa: E501
err=True,
)
server = client.token.launch_server(
days=days,
flavor=flavor,
operating_system=operating_system,
region=region,
hostname=hostname,
autorenew=autorenew,
)
if wait:
tries = 60
while tries > 0:
response = server.info()
if response.deleted_at > 0:
typer.echo(
"Server creation failed, was deleted while waiting.", err=True
)
raise typer.Exit(code=1)
if response.ipv4 != "":
break
typer.echo("Waiting for server to build...", err=True)
tries = tries + 1
# Waiting for server to spin up.
time.sleep(10)
if response.ipv4 == "":
typer.echo("Server creation failed, tries exceeded.", err=True)
raise typer.Exit(code=1)
else:
print_machine_info(response)
if not wait:
print_machine_info(server.info())
return
typer.echo("Consider adding the following to ~/.ssh/config...")
config = (
"\nHost {host}\n"
"\tHostname {hostname}\n"
f"\tIdentityFile {str(ssh_key_file).strip('.pub')}\n"
"\tUser root\n"
"\t # Remove this comment if you wish to connect via Tor. "
"ProxyCommand nc -x localhost:9050 %h %p\n"
)
typer.echo("If you wish to connect with IPv4:")
typer.echo(
config.format(
host=hostname if hostname != "" else response.ipv4, hostname=response.ipv4
)
)
typer.echo("Or if you wish to connect with IPv6:")
typer.echo(
config.format(
host=hostname if hostname != "" else response.ipv6, hostname=response.ipv6
)
)
msg = (
"If you've done that, you should be able to run `ssh {host}` "
"to connect to the server."
)
if hostname != "":
typer.echo(msg.format(host=hostname))
else:
typer.echo(msg.format(host=f"({response.ipv4} or {response.ipv6})"))
@server_cli.command()
def topup(
hostname: str = "",
machine_id: str = "",
days: int = typer.Option(...),
token: str = DEFAULT_TOKEN,
) -> None:
"""Extend an existing SporeStack server's lifetime."""
from .api_client import APIClient
api_client = APIClient(api_endpoint=get_api_endpoint())
machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token)
_token = load_token(token)
api_client.server_topup(
machine_id=machine_id,
days=days,
token=_token,
)
typer.echo(f"Server topped up for {days} day(s)")
def server_info_path() -> Path:
# Put servers in a subdirectory
servers_dir = SPORESTACK_DIR / "servers"
# Make it, if it doesn't exist already.
SPORESTACK_DIR.mkdir(exist_ok=True)
servers_dir.mkdir(exist_ok=True)
return servers_dir
def token_path() -> Path:
token_dir = SPORESTACK_DIR / "tokens"
# Make it, if it doesn't exist already.
token_dir.mkdir(exist_ok=True, parents=True)
return token_dir
def ssh_key_path(token: str) -> Path:
ssh_key_dir = SPORESTACK_DIR / "sshkey" / token
# Make it, if it doesn't exist already.
ssh_key_dir.mkdir(exist_ok=True, parents=True)
return ssh_key_dir
def get_machine_info(hostname: str) -> Dict[str, Any]:
"""
Get info from disk.
"""
directory = server_info_path()
json_file = directory / 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 = "Machine ID (keep this secret!): {}\n".format(info["machine_id"])
if "vm_hostname" in info:
msg += "Hostname: {}\n".format(info["vm_hostname"])
elif "hostname" in info:
msg += "Hostname: {}\n".format(info["hostname"])
if "network_interfaces" in info:
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"])
else:
if "ipv6" in info:
msg += "IPv6: {}\n".format(info["ipv6"])
if "ipv4" in info:
msg += "IPv4: {}\n".format(info["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
def epoch_to_human(epoch: int) -> str:
return time.strftime("%Y-%m-%d %H:%M:%S %z", time.localtime(epoch))
def print_machine_info(info: "api.ServerInfo.Response") -> None:
from rich.console import Console
from rich.panel import Panel
console = Console(width=None if sys.stdout.isatty() else 10**9)
output = ""
output = ""
if info.ipv6 != "":
output += f"IPv6: {info.ipv6}\n"
else:
output += "IPv6: (Not yet assigned)\n"
if info.ipv4 != "":
output += f"IPv4: {info.ipv4}\n"
else:
output += "IPv4: (Not yet assigned)\n"
output += f"Region: {info.region}\n"
output += f"Flavor: {info.flavor.slug}\n"
output += f"Token (keep this secret!): {info.token}\n"
if info.deleted_at != 0:
output += f"Server deleted at: {epoch_to_human(info.deleted_at)}\n"
if info.deleted_by is not None:
output += f"Server deleted by: {info.deleted_by.value}\n"
if info.forgotten_at is not None:
output += f"Server forgotten at: {info.forgotten_at}\n"
else:
msg = f"Running: {info.running}\n"
if info.suspended_at is not None:
msg = (
"Running: Server is powered off because it is [bold]suspended[/bold].\n"
)
output += msg
time_to_live = info.expiration - int(time.time())
hours = time_to_live // 3600
output += f"Server will be deleted in {hours} hours.\n"
output += f"Expiration: {epoch_to_human(info.expiration)}\n"
output += f"Autorenew: {info.autorenew}"
title = f"Machine ID: [italic]{info.machine_id}[/italic] "
if info.hostname != "":
title += f"[bold]({info.hostname})[/bold]"
else:
title += "(No hostname set)"
if info.autorenew:
subtitle = "Server is set to automatically renew. Watch your token balance!"
else:
subtitle = (
f"Server will expire: [italic]{epoch_to_human(info.expiration)}[/italic]"
)
panel = Panel(output, title=title, subtitle=subtitle)
console.print(panel)
@server_cli.command(name="list")
def server_list(
token: str = DEFAULT_TOKEN,
local: Annotated[
bool, typer.Option(help="List older servers not associated to token.")
] = False,
show_forgotten: Annotated[
bool, typer.Option(help="Show deleted and forgotten servers.")
] = False,
) -> None:
"""Lists a token's servers."""
_token = load_token(token)
from rich.console import Console
from rich.table import Table
from .api_client import APIClient
from .exceptions import SporeStackUserError
console = Console(width=None if sys.stdout.isatty() else 10**9)
table = Table(
title=f"Servers for {token} ({_token})",
show_header=True,
header_style="bold magenta",
caption=(
"For more details on a server, run "
"`sporestack server info --machine-id (machine id)`"
),
)
api_client = APIClient(api_endpoint=get_api_endpoint())
server_infos = api_client.servers_launched_from_token(token=_token).servers
machine_id_hostnames = {}
if local:
directory = server_info_path()
for hostname_json in os.listdir(directory):
hostname = hostname_json.split(".")[0]
saved_vm_info = get_machine_info(hostname)
machine_id_hostnames[saved_vm_info["machine_id"]] = hostname
printed_machine_ids = []
table.add_column("Machine ID [bold](Secret!)[/bold]", style="dim")
table.add_column("Hostname")
table.add_column("IPv4")
table.add_column("IPv6")
table.add_column("Expires At")
table.add_column("Autorenew")
for info in server_infos:
if not show_forgotten and info.forgotten_at is not None:
continue
typer.echo()
hostname = info.hostname
if hostname == "":
if info.machine_id in machine_id_hostnames:
hostname = machine_id_hostnames[info.machine_id]
info.hostname = hostname
expiration = epoch_to_human(info.expiration)
if info.deleted_at:
expiration = f"[bold]Deleted[/bold] at {epoch_to_human(info.deleted_at)}"
table.add_row(
info.machine_id,
info.hostname,
info.ipv4,
info.ipv6,
expiration,
str(info.autorenew),
)
if info.suspended_at is not None:
typer.echo(
f"Warning: {info.machine_id} was suspended at {info.suspended_at}!",
err=True,
)
printed_machine_ids.append(info.machine_id)
console.print(table)
if local:
for hostname_json in os.listdir(directory):
hostname = hostname_json.split(".")[0]
saved_vm_info = get_machine_info(hostname)
machine_id = saved_vm_info["machine_id"]
if machine_id in printed_machine_ids:
continue
try:
upstream_vm_info = api_client.server_info(
machine_id=saved_vm_info["machine_id"]
)
saved_vm_info["expiration"] = upstream_vm_info.expiration
saved_vm_info["running"] = upstream_vm_info.running
typer.echo()
typer.echo(pretty_machine_info(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(saved_vm_info["expiration"])
)
msg = hostname
msg += f" expired ({expiration} {human_expiration}): "
msg += str(e)
typer.echo(msg)
typer.echo()
def _get_machine_id(machine_id: str, hostname: str, token: str) -> str:
usage = "--hostname *OR* --machine-id must be set."
if machine_id != "" and hostname != "":
typer.echo(usage, err=True)
raise typer.Exit(code=2)
if machine_id != "":
return machine_id
if hostname == "":
typer.echo(usage, err=True)
raise typer.Exit(code=2)
try:
machine_id = get_machine_info(hostname)["machine_id"]
assert isinstance(machine_id, str)
return machine_id
except Exception:
pass
_token = load_token(token)
from .api_client import APIClient
api_client = APIClient(api_endpoint=get_api_endpoint())
candidates = []
for server in api_client.servers_launched_from_token(token=_token).servers:
if server.forgotten_at is not None:
continue
if server.hostname == hostname:
candidates.append(server)
if len(candidates) == 1:
return candidates[0].machine_id
remaining_candidates = []
for candidate in candidates:
if candidate.deleted_at == 0:
remaining_candidates.append(candidate)
if len(remaining_candidates) == 1:
return remaining_candidates[0].machine_id
elif len(remaining_candidates) > 1:
typer.echo(
"Too many servers match that hostname. Please use --machine-id, instead.",
err=True,
)
raise typer.Exit(code=1)
typer.echo(
f"Could not find any servers matching the hostname: {hostname}", err=True
)
raise typer.Exit(code=1)
@server_cli.command(name="info")
def server_info(
hostname: str = "", machine_id: str = "", token: str = DEFAULT_TOKEN
) -> None:
"""Show information about the server."""
machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token)
from .api_client import APIClient
api_client = APIClient(api_endpoint=get_api_endpoint())
print_machine_info(api_client.server_info(machine_id=machine_id))
@server_cli.command(name="json")
def server_info_json(
hostname: str = "", machine_id: str = "", token: str = DEFAULT_TOKEN
) -> None:
"""Info on the server, in JSON format."""
machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token)
from .api_client import APIClient
api_client = APIClient(api_endpoint=get_api_endpoint())
typer.echo(api_client.server_info(machine_id=machine_id).json())
@server_cli.command()
def start(hostname: str = "", machine_id: str = "", token: str = DEFAULT_TOKEN) -> None:
"""
Boots the VM.
"""
machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token)
from .api_client import APIClient
api_client = APIClient(api_endpoint=get_api_endpoint())
api_client.server_start(machine_id=machine_id)
typer.echo(f"{hostname} started.")
@server_cli.command()
def stop(hostname: str = "", machine_id: str = "", token: str = DEFAULT_TOKEN) -> None:
"""Power off the server. (Not a graceful shutdown)"""
machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token)
from .api_client import APIClient
api_client = APIClient(api_endpoint=get_api_endpoint())
api_client.server_stop(machine_id=machine_id)
typer.echo(f"{hostname} stopped.")
@server_cli.command()
def autorenew_enable(
hostname: str = "", machine_id: str = "", token: str = DEFAULT_TOKEN
) -> None:
"""Enable autorenew on a server."""
machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token)
from .api_client import APIClient
api_client = APIClient(api_endpoint=get_api_endpoint())
api_client.autorenew_enable(machine_id=machine_id)
typer.echo("Autorenew enabled.")
@server_cli.command()
def autorenew_disable(
hostname: str = "", machine_id: str = "", token: str = DEFAULT_TOKEN
) -> None:
"""
Disable autorenew on a server.
"""
machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token)
from .api_client import APIClient
api_client = APIClient(api_endpoint=get_api_endpoint())
api_client.autorenew_disable(machine_id=machine_id)
typer.echo("Autorenew disabled.")
@server_cli.command()
def delete(
hostname: str = "", machine_id: str = "", token: str = DEFAULT_TOKEN
) -> None:
"""Delete the server before its expiration."""
machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token)
from .api_client import APIClient
api_client = APIClient(api_endpoint=get_api_endpoint())
api_client.server_delete(machine_id=machine_id)
# Also remove the .json file
server_info_path().joinpath(f"{hostname}.json").unlink(missing_ok=True)
typer.echo(f"{machine_id} was destroyed.")
@server_cli.command()
def forget(
hostname: str = "", machine_id: str = "", token: str = DEFAULT_TOKEN
) -> None:
"""Forget about a deleted server so that it doesn't show up in server list."""
machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token)
from .api_client import APIClient
api_client = APIClient(api_endpoint=get_api_endpoint())
api_client.server_forget(machine_id=machine_id)
typer.echo(f"{machine_id} was forgotten.")
@server_cli.command()
def update_hostname(
machine_id: str,
hostname: Annotated[str, typer.Option()],
) -> None:
"""Update a server's hostname, given its machine ID."""
from .client import Server
server = Server(machine_id=machine_id, api_client=get_api_client())
current_hostname = server.info().hostname
server.update(hostname=hostname)
if current_hostname == "":
typer.echo(f"{machine_id}'s hostname was set to {hostname}.")
else:
typer.echo(
f"{machine_id}'s hostname was updated from {current_hostname} to "
f"{hostname}."
)
@server_cli.command()
def rebuild(
hostname: str = "", machine_id: str = "", token: str = DEFAULT_TOKEN
) -> 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_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token)
from .api_client import APIClient
api_client = APIClient(api_endpoint=get_api_endpoint())
api_client.server_rebuild(machine_id=machine_id)
typer.echo(f"{hostname} rebuilding.")
@server_cli.command()
def flavors() -> None:
"""Shows available flavors."""
from rich.console import Console
from rich.table import Table
from ._cli_utils import cents_to_usd, gb_string, mb_string, tb_string
from .api_client import APIClient
console = Console(width=None if sys.stdout.isatty() else 10**9)
table = Table(show_header=True, header_style="bold magenta")
table.add_column("Flavor Slug (--flavor)")
table.add_column("vCPU Cores")
table.add_column("Memory")
table.add_column("Disk")
table.add_column("Bandwidth (per month)")
table.add_column("Price per day")
table.add_column("Price per month (30 days)")
api_client = APIClient(api_endpoint=get_api_endpoint())
flavors = api_client.flavors().flavors
for flavor_slug in flavors:
flavor = flavors[flavor_slug]
price_per_30_days = flavor.price * 30
table.add_row(
flavor_slug,
str(flavor.cores),
mb_string(flavor.memory),
gb_string(flavor.disk),
tb_string(flavor.bandwidth_per_month),
f"[green]{cents_to_usd(flavor.price)}[/green]",
f"[green]{cents_to_usd(price_per_30_days)}[/green]",
)
console.print(table)
@server_cli.command()
def operating_systems() -> None:
"""Show available operating systems."""
from rich.console import Console
from rich.table import Table
from .api_client import APIClient
console = Console()
table = Table(show_header=True, header_style="bold magenta")
api_client = APIClient(api_endpoint=get_api_endpoint())
table.add_column("Operating System (--operating-system)")
os_list = api_client.operating_systems().operating_systems
for operating_system in os_list:
table.add_row(operating_system)
console.print(table)
@server_cli.command()
def regions() -> None:
"""Shows regions that servers can be launched in."""
from rich.console import Console
from rich.table import Table
from .api_client import APIClient
console = Console()
table = Table(show_header=True, header_style="bold magenta")
table.add_column("Region Slug (--region)")
table.add_column("Region Name")
api_client = APIClient(api_endpoint=get_api_endpoint())
regions = api_client.regions().regions
for region in regions:
table.add_row(region, regions[region].name)
console.print(table)
def load_token(token: str) -> str:
token_file = token_path().joinpath(f"{token}.json")
if not token_file.exists():
msg = f"Token '{token}' ({token_file}) does not exist. Create it with:\n"
msg += f"sporestack token create {token} --dollars 20 --currency xmr\n"
msg += "(Can do more than $20, or a different currency, like btc.)\n"
msg += (
"With the token credited, you can launch servers, renew existing ones, etc."
)
typer.echo(msg, err=True)
raise typer.Exit(code=1)
token_data = json.loads(token_file.read_text())
assert token_data["version"] == 1
assert isinstance(token_data["key"], str)
return token_data["key"]
def save_token(token: str, key: str) -> None:
token_file = token_path().joinpath(f"{token}.json")
if token_file.exists():
msg = f"Token '{token}' already exists in {token_file}. Aborting!"
typer.echo(msg, err=True)
raise typer.Exit(code=1)
token_data = {"version": TOKEN_VERSION, "name": token, "key": key}
token_file.write_text(json.dumps(token_data))
@token_cli.command(name="create")
def token_create(
dollars: Annotated[
int,
typer.Option(help="How many dollars to add to the token.", show_default=False),
],
currency: Annotated[
Currency,
typer.Option(help="Which cryptocurrency to pay with.", show_default=False),
],
token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN,
wait: Annotated[
bool, typer.Option(help="Wait for the payment to be confirmed.")
] = True,
qr: Annotated[
bool, typer.Option(help="Show a QR code for the payment URI.")
] = True,
) -> None:
"""Enables a new token."""
from . import utils
if Path(SPORESTACK_DIR / "tokens" / f"{token}.json").exists():
typer.echo("Token already created! Did you mean to `topup`?", err=True)
raise typer.Exit(1)
_token = utils.random_token()
typer.echo(f"Generated key {_token} for use with token {token}", err=True)
save_token(token, _token)
token_add(
token=_token,
dollars=dollars,
currency=currency,
wait=wait,
token_name=token,
qr=qr,
)
typer.echo(f"{token}'s key is {_token}.")
typer.echo("Save it, don't share it, and don't lose it!")
typer.echo()
typer.echo("Optional: Make a SSH key just for this token.")
token_ssh_key_path = ssh_key_path(token) / DEFAULT_TOKEN_SSH_KEY_PRIVATE
typer.echo(f'Run: ssh-keygen -C "" -t ed25519 -f "{token_ssh_key_path}"')
typer.echo(
"If you do this, servers launched from that token will default to use "
"that key and you won't have to pass --ssh-key-file every time you "
"launch a server!"
)
@token_cli.command(name="import")
def token_import(
name: str = typer.Argument(DEFAULT_TOKEN),
key: str = typer.Option(...),
) -> None:
"""Imports a token under the given name."""
save_token(name, key)
def token_add(
token: str, dollars: int, currency: Currency, wait: bool, token_name: str, qr: bool
) -> None:
from httpx import HTTPError
from .api_client import APIClient
from .client import Client
from .exceptions import SporeStackServerError
api_client = APIClient(api_endpoint=get_api_endpoint())
client = Client(api_client=api_client, client_token=token)
invoice = client.token.add(dollars, currency=currency)
if qr:
invoice_qr(invoice)
typer.echo()
typer.echo(
"Resize your terminal and try again if QR code above is not readable."
)
typer.echo()
invoice_panel(invoice, token=token, token_name=token_name)
typer.echo("Pay *exactly* the specified amount. No more, no less.")
if not wait:
typer.echo("--no-wait: Not waiting for payment to be confirmed.", err=True)
typer.echo(
(
f"Check status with: sporestack token invoice {token_name} "
f"--invoice-id {invoice.id}"
),
err=True,
)
return
typer.echo("Press ctrl+c to abort.")
while invoice.expired is False or invoice.paid is False:
try:
invoice = client.token.invoice(invoice=invoice.id)
except (SporeStackServerError, HTTPError):
typer.echo("Received 500 HTTP status, will try again.", err=True)
continue
if invoice.paid:
typer.echo(
f"Added ${dollars} to {token_name} ({token}) with TXID {invoice.txid}"
)
return
typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True)
time.sleep(60)
if invoice.expired:
raise ValueError("Invoice has expired.")
@token_cli.command(name="topup")
def token_topup(
currency: Annotated[
Currency,
typer.Option(help="Which cryptocurrency to pay with.", show_default=False),
],
dollars: Annotated[
int,
typer.Option(help="How many dollars to add to the token.", show_default=False),
],
token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN,
wait: Annotated[
bool, typer.Option(help="Wait for the payment to be confirmed.")
] = True,
qr: Annotated[
bool, typer.Option(help="Show a QR code for the payment URI.")
] = True,
) -> None:
"""Adds balance to an existing token."""
real_token = load_token(token)
token_add(
token=real_token,
dollars=dollars,
currency=currency,
wait=wait,
token_name=token,
qr=qr,
)
@token_cli.command()
def balance(token: str = typer.Argument(DEFAULT_TOKEN)) -> None:
"""Shows a token's balance."""
_token = load_token(token)
from .api_client import APIClient
api_client = APIClient(api_endpoint=get_api_endpoint())
typer.echo(api_client.token_info(token=_token).balance_usd)
@token_cli.command(name="info")
def token_info(token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN) -> None:
"""
Show information about a token, including balance.
Burn Rate is calculated per day of servers set to autorenew.
Days Remaining is for servers set to autorenew, given the remaining balance.
"""
_token = load_token(token)
from rich import print
from .api_client import APIClient
from .client import Client
api_client = APIClient(api_endpoint=get_api_endpoint())
client = Client(api_client=api_client, client_token=_token)
info = client.token.info()
print(f"[bold]Token Information for {token} ({_token})[/bold]")
print(f"Balance: [green]{info.balance_usd}")
print(f"Total Servers (not deleted): {info.servers}")
print(f"Servers set to autorenew: {info.autorenew_servers}")
print(f"Suspended servers: {info.suspended_servers}")
print(
f"Burn Rate: [red]{info.burn_rate_usd}[/red] "
"(per day of servers set to autorenew)"
)
print(
f"Days Remaining: {info.days_remaining} "
"(for servers set to autorenew, given the remaining balance)"
)
@token_cli.command()
def servers(token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN) -> None:
"""Use sporestack server list --token TOKEN instead!"""
_token = load_token(token)
from .api_client import APIClient
api_client = APIClient(api_endpoint=get_api_endpoint())
typer.echo(api_client.servers_launched_from_token(token=_token))
@token_cli.command(name="list")
def token_list() -> None:
"""List tokens."""
from rich.console import Console
from rich.table import Table
console = Console(width=None if sys.stdout.isatty() else 10**9)
token_dir = token_path()
table = Table(
show_header=True,
header_style="bold magenta",
caption=f"These tokens are stored in {token_dir}",
)
table.add_column("Name")
table.add_column("Token (this is a globally unique [bold]secret[/bold])")
for token_file in token_dir.glob("*.json"):
token = token_file.stem
key = load_token(token)
table.add_row(token, key)
console.print(table)
@token_cli.command(name="invoices")
def token_invoices(token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN) -> None:
"""List invoices."""
_token = load_token(token)
from rich.console import Console
from rich.table import Table
from ._cli_utils import cents_to_usd
from .api_client import APIClient
from .client import Client
api_client = APIClient(api_endpoint=get_api_endpoint())
client = Client(api_client=api_client, client_token=_token)
console = Console(width=None if sys.stdout.isatty() else 10**9)
table = Table(
title=f"Invoices for {token} ({_token})",
show_header=True,
header_style="bold magenta",
)
table.add_column("ID")
table.add_column("Amount")
table.add_column("Created At")
table.add_column("Paid At")
table.add_column("URI")
table.add_column("TXID")
for invoice in client.token.invoices():
if invoice.paid:
paid = epoch_to_human(invoice.paid)
else:
if invoice.expired:
paid = "[bold]Expired[/bold]"
else:
paid = f"Unpaid. Expires: {epoch_to_human(invoice.expires)}"
table.add_row(
str(invoice.id),
cents_to_usd(invoice.amount),
epoch_to_human(invoice.created),
paid,
invoice.payment_uri,
invoice.txid,
)
console.print(table)
def invoice_panel(invoice: "Invoice", token: str, token_name: str) -> None:
from rich import print
from rich.panel import Panel
if invoice.paid != 0:
subtitle = f"[bold]Paid[/bold] with TXID: {invoice.txid}"
elif invoice.expired:
subtitle = "[bold]Expired[/bold]"
else:
subtitle = f"Unpaid. Expires: {epoch_to_human(invoice.expires)}"
content = (
f"Invoice created: {epoch_to_human(invoice.created)}\n"
f"Payment URI: [link={invoice.payment_uri}]{invoice.payment_uri}[/link]\n"
f"Cryptocurrency: {invoice.cryptocurrency.value.upper()}\n"
f"Cryptocurrency rate: [green]${invoice.fiat_per_coin}[/green]\n"
f"Dollars to add to token: [green]${invoice.amount // 100}[/green]"
)
panel = Panel(
content,
title=(
f"SporeStack Invoice ID [italic]{invoice.id}[/italic] "
f"for token [bold]{token_name}[/bold] ([italic]{token}[/italic])"
),
subtitle=subtitle,
)
print(panel)
@token_cli.command(name="invoice")
def token_invoice(
token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN,
invoice_id: str = typer.Option(help="Invoice's ID."),
qr: bool = typer.Option(False, help="Show a QR code for the payment URI."),
) -> None:
"""Show a particular invoice."""
_token = load_token(token)
from .api_client import APIClient
from .client import Client
api_client = APIClient(api_endpoint=get_api_endpoint())
client = Client(api_client=api_client, client_token=_token)
invoice = client.token.invoice(invoice_id)
if qr:
invoice_qr(invoice)
typer.echo()
invoice_panel(invoice, token=_token, token_name=token)
@token_cli.command()
def messages(token: str = typer.Argument(DEFAULT_TOKEN)) -> None:
"""Show support messages."""
token = load_token(token)
from .api_client import APIClient
from .client import Client
api_client = APIClient(api_endpoint=get_api_endpoint())
client = Client(api_client=api_client, client_token=token)
for message in client.token.messages():
typer.echo()
typer.echo(message.message)
typer.echo()
typer.echo(f"Sent at {message.sent_at}, by {message.sender.value}")
@token_cli.command()
def send_message(
token: str = typer.Argument(DEFAULT_TOKEN), message: str = typer.Option(...)
) -> None:
"""Send a support message."""
token = load_token(token)
from .api_client import APIClient
from .client import Client
api_client = APIClient(api_endpoint=get_api_endpoint())
client = Client(api_client=api_client, client_token=token)
client.token.send_message(message)
@cli.command()
def version() -> None:
"""Returns the installed version."""
from . import __version__
typer.echo(__version__)
@cli.command()
def api_changelog() -> None:
"""Shows the API changelog."""
from rich.console import Console
from rich.markdown import Markdown
from .api_client import APIClient
api_client = APIClient(api_endpoint=get_api_endpoint())
console = Console()
console.print(Markdown(api_client.changelog()))
# TODO
# @cli.command()
# def cli_changelog() -> None:
# """Shows the Python library/CLI changelog."""
@cli.command()
def api_endpoint() -> None:
"""
Prints the selected API endpoint: Env var: SPORESTACK_ENDPOINT,
or, SPORESTACK_USE_TOR_ENDPOINT=1
"""
from . import api_client
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()