v10.5.0: Added `sporestack token invoices` command

This commit is contained in:
SporeStack 2023-05-12 20:05:38 +00:00
parent 5ff095af3f
commit 16c790728d
10 changed files with 283 additions and 76 deletions

View File

@ -15,6 +15,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Nothing yet. - Nothing yet.
## [10.5.0 - 2023-05-12]
## Changed
- Use fancy table output for `sporestack server list`.
## Added
- `sporestack token invoices` command.
## [10.4.0 - 2023-05-12] ## [10.4.0 - 2023-05-12]
## Changed ## Changed

View File

@ -8,27 +8,31 @@
## Installation ## Installation
* `pip install sporestack` * `pip install sporestack` (Run `pip install 'sporestack[cli]'` if you wish to use the CLI features and not just the Python library.)
* Recommended: Create a virtual environment, first, and use it inside there. * Recommended: Create a virtual environment, first, and use it inside there.
* Something else to consider: Installing [rich](https://github.com/Textualize/rich) (`pip install rich`) in the same virtual environment will make `--help`-style output prettier. * Something else to consider: Installing [rich](https://github.com/Textualize/rich) (`pip install rich`) in the same virtual environment will make `--help`-style output prettier.
## Running without installing ## Running without installing
* Make sure `pipx` is installed. * Make sure `pipx` is installed.
* `pipx run sporestack` * `pipx run 'sporestack[cli]'`
* Make sure you're on the latest stable version comparing `sporestack version` with git tags in this repository, or releases on [PyPI](https://pypi.org/project/sporestack/). * Make sure you're on the latest stable version comparing `sporestack version` with git tags in this repository, or releases on [PyPI](https://pypi.org/project/sporestack/).
## Usage ## Usage
* `sporestack token create --dollars 20 --currency xmr # Can use btc as well.` * `sporestack token create --dollars 20 --currency xmr # Can use btc as well.`
* `sporestack token list` * `sporestack token list`
* `sporestack token balance` * `sporestack token info`
* `sporestack server launch SomeHostname --operating-system debian-11 --days 1 # Will use ~/.ssh/id_rsa.pub as your SSH key, by default` * `sporestack server launch --hostname SomeHostname --operating-system debian-11 --days 1 # Will use ~/.ssh/id_rsa.pub as your SSH key, by default`
(You may also want to consider passing `--region` to have a non-random region. This will use the "primary" token by default, which is the default when you run `sporestack token create`.) (You may also want to consider passing `--region` to have a non-random region. This will use the "primary" token by default, which is the default when you run `sporestack token create`.)
* `sporestack server stop SomeHostname` * `sporestack server stop --hostname SomeHostname`
* `sporestack server start SomeHostname` * `sporestack server stop --machine-id ss_m_... # Or use --machine-id to be more pedantic.`
* `sporestack server start --hostname SomeHostname`
* `sporestack server autorenew-enable --hostname SomeHostname`
* `sporestack server autorenew-disable --hostname SomeHostname`
* `sporestack server list` * `sporestack server list`
* `sporestack server remove SomeHostname # If expired` * `sporestack server delete --hostname SomeHostname`
* `sporestack server remove --hostname SomeHostname # If expired`
## Notes ## Notes

View File

@ -21,7 +21,6 @@ sporestack api-endpoint
sporestack api-endpoint | grep "$SPORESTACK_ENDPOINT" sporestack api-endpoint | grep "$SPORESTACK_ENDPOINT"
sporestack token list sporestack token list
sporestack token list 2>&1 | wc -l | grep '2$'
sporestack token import importediminvalid --key "imaninvalidkey" sporestack token import importediminvalid --key "imaninvalidkey"
sporestack token list | grep importediminvalid sporestack token list | grep importediminvalid
@ -52,6 +51,7 @@ sporestack token balance realtestingtoken | grep -F '$'
sporestack token info realtestingtoken sporestack token info realtestingtoken
sporestack token messages realtestingtoken sporestack token messages realtestingtoken
sporestack token servers realtestingtoken sporestack token servers realtestingtoken
sporestack token invoices realtestingtoken
sporestack server list --token realtestingtoken sporestack server list --token realtestingtoken
sporestack server launch --no-quote --token realtestingtoken --operating-system debian-11 --days 1 --hostname sporestackpythonintegrationtestdelme sporestack server launch --no-quote --token realtestingtoken --operating-system debian-11 --days 1 --hostname sporestackpythonintegrationtestdelme

View File

@ -57,7 +57,7 @@ dependencies = [
"pydantic", "pydantic",
"httpx[socks]", "httpx[socks]",
"segno", "segno",
"typer", "typer>=0.9.0",
"rich", "rich",
] ]

View File

@ -2,4 +2,4 @@
__all__ = ["api", "api_client", "exceptions"] __all__ = ["api", "api_client", "exceptions"]
__version__ = "10.4.0" __version__ = "10.5.0"

View File

@ -1,13 +1,19 @@
"""SporeStack API request/response models""" """SporeStack API request/response models"""
import sys
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import Dict, List, Optional, Union from typing import Dict, List, Optional, Union
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from .models import Flavor, OperatingSystem, Payment, Region from .models import Flavor, Invoice, OperatingSystem, Payment, Region
if sys.version_info >= (3, 9): # pragma: nocover
from typing import Annotated
else: # pragma: nocover
from typing_extensions import Annotated
class TokenAdd: class TokenAdd:
@ -16,12 +22,14 @@ class TokenAdd:
class Request(BaseModel): class Request(BaseModel):
currency: str currency: str
"""BREAKING: This will change to models.Currency in version 11."""
dollars: int dollars: int
affiliate_token: Union[str, None] = None affiliate_token: Union[str, None] = None
class Response(BaseModel): class Response(BaseModel):
token: str token: Annotated[str, Field(deprecated=True)]
payment: Payment payment: Annotated[Payment, Field(deprecated=True)]
invoice: Invoice
class TokenBalance: class TokenBalance:
@ -29,7 +37,7 @@ class TokenBalance:
method = "GET" method = "GET"
class Response(BaseModel): class Response(BaseModel):
token: str token: Annotated[str, Field(deprecated=True)]
cents: int cents: int
usd: str usd: str
@ -41,16 +49,18 @@ class ServerQuote:
"""Takes days and flavor as parameters.""" """Takes days and flavor as parameters."""
class Response(BaseModel): class Response(BaseModel):
cents: int = Field( cents: Annotated[
default=..., ge=1, title="Cents", description="(US) cents", example=1_000_00 int, Field(ge=1, title="Cents", description="(US) cents", example=1_000_00)
) ]
usd: str = Field( usd: Annotated[
default=..., str,
min_length=5, Field(
title="USD", min_length=5,
description="USD in $1,000.00 format", title="USD",
example="$1,000.00", description="USD in $1,000.00 format",
) example="$1,000.00",
),
]
class ServerLaunch: class ServerLaunch:

View File

@ -7,7 +7,7 @@ import httpx
from pydantic import parse_obj_as from pydantic import parse_obj_as
from . import __version__, api, exceptions from . import __version__, api, exceptions
from .models import TokenInfo from .models import Invoice, TokenInfo
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -300,3 +300,11 @@ class APIClient:
url = self.api_endpoint + f"/token/{token}/messages" url = self.api_endpoint + f"/token/{token}/messages"
response = self._httpx_client.post(url=url, json={"message": message}) response = self._httpx_client.post(url=url, json={"message": message})
_handle_response(response) _handle_response(response)
def token_invoices(self, token: str) -> List[Invoice]:
"""Get token invoices."""
url = self.api_endpoint + f"/token/{token}/invoices"
response = self._httpx_client.get(url=url)
_handle_response(response)
return parse_obj_as(List[Invoice], response.json())

View File

@ -5,14 +5,21 @@ SporeStack CLI: `sporestack`
import json import json
import logging import logging
import os import os
import sys
import time import time
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Optional from typing import TYPE_CHECKING, Any, Dict, Optional
import typer import typer
if sys.version_info >= (3, 9): # pragma: nocover
from typing import Annotated
else: # pragma: nocover
from typing_extensions import Annotated
if TYPE_CHECKING: if TYPE_CHECKING:
from . import api from . import api
from .models import Invoice
HELP = """ HELP = """
@ -71,15 +78,21 @@ def get_api_endpoint() -> str:
return api_endpoint return api_endpoint
def make_payment(currency: str, uri: str, usd: str) -> None: def make_payment(invoice: "Invoice") -> None:
import segno import segno
premessage = """Payment URI: {} from ._cli_utils import cents_to_usd
Pay *exactly* the specified amount. No more, no less. Pay within
one hour at the very most. uri = invoice.payment_uri
usd = cents_to_usd(invoice.amount)
expires = epoch_to_human(invoice.expires)
message = f"""Invoice: {invoice.id}
Invoice expires: {expires} (payment must be confirmed by this time)
Payment URI: {uri}
Pay *exactly* the specified amount. No more, no less.
Resize your terminal and try again if QR code above is not readable. Resize your terminal and try again if QR code above is not readable.
Press ctrl+c to abort.""" Press ctrl+c to abort."""
message = premessage.format(uri)
qr = segno.make(uri) qr = segno.make(uri)
# This typer.echos. # This typer.echos.
qr.terminal() qr.terminal()
@ -90,11 +103,12 @@ Press ctrl+c to abort."""
@server_cli.command() @server_cli.command()
def launch( def launch(
days: Annotated[
int,
typer.Option(min=1, max=90, help="Number of days the server should run for."),
],
operating_system: Annotated[str, typer.Option(help="Example: debian-11")],
hostname: str = "", hostname: str = "",
days: int = typer.Option(
..., min=1, max=90, help="Number of days the server should run for."
),
operating_system: str = typer.Option(..., help="Example: debian-11"),
ssh_key_file: Path = DEFAULT_SSH_KEY_FILE, ssh_key_file: Path = DEFAULT_SSH_KEY_FILE,
flavor: str = DEFAULT_FLAVOR, flavor: str = DEFAULT_FLAVOR,
token: str = DEFAULT_TOKEN, token: str = DEFAULT_TOKEN,
@ -301,7 +315,7 @@ def print_machine_info(info: "api.ServerInfo.Response") -> None:
typer.echo(f"Region: {info.region}") typer.echo(f"Region: {info.region}")
typer.echo(f"Flavor: {info.flavor.slug}") typer.echo(f"Flavor: {info.flavor.slug}")
typer.echo(f"Expiration: {epoch_to_human(info.expiration)}") typer.echo(f"Expiration: {epoch_to_human(info.expiration)}")
typer.echo(f"Token: {info.token}") typer.echo(f"Token (keep this secret!): {info.token}")
if info.deleted_at != 0 or info.deleted: if info.deleted_at != 0 or info.deleted:
typer.echo("Server was deleted!") typer.echo("Server was deleted!")
if info.deleted_at != 0: if info.deleted_at != 0:
@ -321,20 +335,35 @@ def print_machine_info(info: "api.ServerInfo.Response") -> None:
@server_cli.command(name="list") @server_cli.command(name="list")
def server_list( def server_list(
token: str = DEFAULT_TOKEN, token: str = DEFAULT_TOKEN,
local: bool = typer.Option( local: Annotated[
True, help="List older servers not associated to token." bool, typer.Option(help="List older servers not associated to token.")
), ] = True,
show_forgotten: bool = typer.Option( show_forgotten: Annotated[
False, help="Show deleted and forgotten servers." bool, typer.Option(help="Show deleted and forgotten servers.")
), ] = False,
) -> None: ) -> None:
"""List all locally known servers and all servers under the given token.""" """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 .api_client import APIClient
from .exceptions import SporeStackUserError from .exceptions import SporeStackUserError
api_client = APIClient(api_endpoint=get_api_endpoint()) console = Console(width=None if sys.stdout.isatty() else 10**9)
_token = load_token(token) 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 server_infos = api_client.servers_launched_from_token(token=_token).servers
machine_id_hostnames = {} machine_id_hostnames = {}
@ -348,6 +377,13 @@ def server_list(
printed_machine_ids = [] 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: for info in server_infos:
if not show_forgotten and info.forgotten_at is not None: if not show_forgotten and info.forgotten_at is not None:
continue continue
@ -360,10 +396,23 @@ def server_list(
hostname = machine_id_hostnames[info.machine_id] hostname = machine_id_hostnames[info.machine_id]
info.hostname = hostname info.hostname = hostname
print_machine_info(info) 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),
)
printed_machine_ids.append(info.machine_id) printed_machine_ids.append(info.machine_id)
console.print(table)
if local: if local:
for hostname_json in os.listdir(directory): for hostname_json in os.listdir(directory):
hostname = hostname_json.split(".")[0] hostname = hostname_json.split(".")[0]
@ -383,7 +432,7 @@ def server_list(
except SporeStackUserError as e: except SporeStackUserError as e:
expiration = saved_vm_info["expiration"] expiration = saved_vm_info["expiration"]
human_expiration = time.strftime( human_expiration = time.strftime(
"%Y-%m-%d %H:%M:%S %z", time.localtime(expiration) "%Y-%m-%d %H:%M:%S %z", time.localtime(saved_vm_info["expiration"])
) )
msg = hostname msg = hostname
msg += f" expired ({expiration} {human_expiration}): " msg += f" expired ({expiration} {human_expiration}): "
@ -562,7 +611,7 @@ def flavors() -> None:
from ._cli_utils import cents_to_usd, gb_string, mb_string, tb_string from ._cli_utils import cents_to_usd, gb_string, mb_string, tb_string
from .api_client import APIClient from .api_client import APIClient
console = Console() console = Console(width=None if sys.stdout.isatty() else 10**9)
table = Table(show_header=True, header_style="bold magenta") table = Table(show_header=True, header_style="bold magenta")
table.add_column("Flavor Slug (--flavor)") table.add_column("Flavor Slug (--flavor)")
@ -664,15 +713,17 @@ def save_token(token: str, key: str) -> None:
@token_cli.command(name="create") @token_cli.command(name="create")
def token_create( def token_create(
token: str = typer.Argument(DEFAULT_TOKEN), dollars: Annotated[int, typer.Option()],
dollars: int = typer.Option(...), currency: Annotated[str, typer.Option()],
currency: str = typer.Option(...), token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN,
) -> None: ) -> None:
""" """
Enables a new token. Enables a new token.
Dollars is starting balance. Dollars is starting balance.
""" """
from httpx import HTTPError
from . import utils from . import utils
_token = utils.random_token() _token = utils.random_token()
@ -694,11 +745,7 @@ def token_create(
currency=currency, currency=currency,
) )
uri = response.payment.uri make_payment(response.invoice)
assert uri is not None
usd = response.payment.usd
make_payment(currency=currency, uri=uri, usd=usd)
tries = 360 * 2 tries = 360 * 2
while tries > 0: while tries > 0:
@ -713,10 +760,10 @@ def token_create(
dollars=dollars, dollars=dollars,
currency=currency, currency=currency,
) )
except SporeStackServerError: except (SporeStackServerError, HTTPError):
typer.echo("Received 500 HTTP status, will try again.", err=True) typer.echo("Received 500 HTTP status, will try again.", err=True)
continue continue
if response.payment.paid is True: if response.invoice.paid:
typer.echo(f"{token} has been enabled with ${dollars}.") typer.echo(f"{token} has been enabled with ${dollars}.")
typer.echo(f"{token}'s key is {_token}.") typer.echo(f"{token}'s key is {_token}.")
typer.echo("Save it, don't share it, and don't lose it!") typer.echo("Save it, don't share it, and don't lose it!")
@ -743,6 +790,8 @@ def token_topup(
"""Adds balance to an existing token.""" """Adds balance to an existing token."""
token = load_token(token) token = load_token(token)
from httpx import HTTPError
from .api_client import APIClient from .api_client import APIClient
from .exceptions import SporeStackServerError from .exceptions import SporeStackServerError
@ -754,11 +803,7 @@ def token_topup(
currency=currency, currency=currency,
) )
uri = response.payment.uri make_payment(response.invoice)
assert uri is not None
usd = response.payment.usd
make_payment(currency=currency, uri=uri, usd=usd)
tries = 360 * 2 tries = 360 * 2
while tries > 0: while tries > 0:
@ -771,12 +816,12 @@ def token_topup(
dollars=dollars, dollars=dollars,
currency=currency, currency=currency,
) )
except SporeStackServerError: except (SporeStackServerError, HTTPError):
typer.echo("Received 500 HTTP status, will try again.", err=True) typer.echo("Received 500 HTTP status, will try again.", err=True)
continue continue
# Waiting for payment to set in. # Waiting for payment to set in.
time.sleep(10) time.sleep(10)
if response.payment.paid is True: if response.invoice.paid:
typer.echo(f"Added {dollars} dollars to {token}") typer.echo(f"Added {dollars} dollars to {token}")
return return
raise ValueError(f"{token} did not get enabled in time.") raise ValueError(f"{token} did not get enabled in time.")
@ -795,7 +840,7 @@ def balance(token: str = typer.Argument(DEFAULT_TOKEN)) -> None:
@token_cli.command(name="info") @token_cli.command(name="info")
def token_info(token: str = typer.Argument(DEFAULT_TOKEN)) -> None: def token_info(token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN) -> None:
""" """
Show information about a token, including balance. Show information about a token, including balance.
@ -828,7 +873,7 @@ def token_info(token: str = typer.Argument(DEFAULT_TOKEN)) -> None:
@token_cli.command() @token_cli.command()
def servers(token: str = typer.Argument(DEFAULT_TOKEN)) -> None: def servers(token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN) -> None:
"""Returns server info for servers launched by a given token.""" """Returns server info for servers launched by a given token."""
_token = load_token(token) _token = load_token(token)
@ -842,13 +887,75 @@ def servers(token: str = typer.Argument(DEFAULT_TOKEN)) -> None:
@token_cli.command(name="list") @token_cli.command(name="list")
def token_list() -> None: def token_list() -> None:
"""List tokens.""" """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() token_dir = token_path()
typer.echo(f"SporeStack tokens present in {token_dir}:", err=True) table = Table(
typer.echo("(Name): (Key)", err=True) 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"): for token_file in token_dir.glob("*.json"):
token = token_file.stem token = token_file.stem
key = load_token(token) key = load_token(token)
typer.echo(f"{token}: {key}") 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)
@token_cli.command() @token_cli.command()

View File

@ -3,7 +3,7 @@ from typing import List, Union
from . import api from . import api
from .api_client import APIClient from .api_client import APIClient
from .models import TokenInfo from .models import Invoice, TokenInfo
from .utils import random_machine_id, random_token from .utils import random_machine_id, random_token
@ -74,6 +74,10 @@ class Token:
"""Returns information about a token.""" """Returns information about a token."""
return self.api_client.token_info(token=self.token) return self.api_client.token_info(token=self.token)
def invoices(self) -> List[Invoice]:
"""Returns invoices for adding balance to the token."""
return self.api_client.token_invoices(token=self.token)
def messages(self) -> List[api.TokenMessage]: def messages(self) -> List[api.TokenMessage]:
"""Returns support messages for/from the token.""" """Returns support messages for/from the token."""
return self.api_client.token_get_messages(token=self.token) return self.api_client.token_get_messages(token=self.token)

View File

@ -1,16 +1,29 @@
""" """SporeStack API supplemental models"""
SporeStack API supplemental models import sys
from enum import Enum
from typing import Optional, Union
""" if sys.version_info >= (3, 9): # pragma: nocover
from typing import Annotated
else: # pragma: nocover
from typing_extensions import Annotated
from pydantic import BaseModel, Field
from typing import Optional class Currency(str, Enum):
xmr = "xmr"
from pydantic import BaseModel """Monero"""
btc = "btc"
"""Bitcoin"""
bch = "bch"
"""Bitcoin Cash"""
class Payment(BaseModel): class Payment(BaseModel):
"""This is deprecated in favor of Invoice."""
txid: Optional[str] txid: Optional[str]
uri: Optional[str] uri: Optional[str]
usd: str usd: str
@ -62,3 +75,54 @@ class Region(BaseModel):
slug: str slug: str
# Actually human readable string describing the region. # Actually human readable string describing the region.
name: str name: str
class Invoice(BaseModel):
id: int
payment_uri: Annotated[
str, Field(description="Cryptocurrency URI for the payment.")
]
cryptocurrency: Annotated[
Currency,
Field(description="Cryptocurrency that will be used to pay this invoice."),
]
amount: Annotated[
int,
Field(
description="Amount of cents to add to the token if this invoice is paid."
),
]
fiat_per_coin: Annotated[
str,
Field(
description="Stringified float of the price when this was made.",
example="100.00",
),
]
created: Annotated[
int, Field(description="Timestamp of when this invoice was created.")
]
expires: Annotated[
int, Field(description="Timestamp of when this invoice will expire.")
]
paid: Annotated[
int, Field(description="Timestamp of when this invoice was paid. 0 if unpaid.")
]
txid: Annotated[
Union[str, None],
Field(
description="TXID of the transaction for this payment, if it was paid.",
min_length=64,
max_length=64,
regex="^[a-f0-9]+$",
),
]
expired: Annotated[
bool,
Field(
description=(
"Whether or not the invoice has expired (only applicable if "
"unpaid, or payment not yet confirmed."
),
),
]