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.
## [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]
## Changed

View File

@ -8,27 +8,31 @@
## 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.
* 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
* 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/).
## Usage
* `sporestack token create --dollars 20 --currency xmr # Can use btc as well.`
* `sporestack token list`
* `sporestack token balance`
* `sporestack server launch SomeHostname --operating-system debian-11 --days 1 # Will use ~/.ssh/id_rsa.pub as your SSH key, by default`
* `sporestack token info`
* `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`.)
* `sporestack server stop SomeHostname`
* `sporestack server start SomeHostname`
* `sporestack server stop --hostname 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 remove SomeHostname # If expired`
* `sporestack server delete --hostname SomeHostname`
* `sporestack server remove --hostname SomeHostname # If expired`
## Notes

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import httpx
from pydantic import parse_obj_as
from . import __version__, api, exceptions
from .models import TokenInfo
from .models import Invoice, TokenInfo
log = logging.getLogger(__name__)
@ -300,3 +300,11 @@ class APIClient:
url = self.api_endpoint + f"/token/{token}/messages"
response = self._httpx_client.post(url=url, json={"message": message})
_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 logging
import os
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Optional
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:
from . import api
from .models import Invoice
HELP = """
@ -71,15 +78,21 @@ def get_api_endpoint() -> str:
return api_endpoint
def make_payment(currency: str, uri: str, usd: str) -> None:
def make_payment(invoice: "Invoice") -> None:
import segno
premessage = """Payment URI: {}
Pay *exactly* the specified amount. No more, no less. Pay within
one hour at the very most.
from ._cli_utils import cents_to_usd
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.
Press ctrl+c to abort."""
message = premessage.format(uri)
qr = segno.make(uri)
# This typer.echos.
qr.terminal()
@ -90,11 +103,12 @@ Press ctrl+c to abort."""
@server_cli.command()
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 = "",
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,
flavor: str = DEFAULT_FLAVOR,
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"Flavor: {info.flavor.slug}")
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:
typer.echo("Server was deleted!")
if info.deleted_at != 0:
@ -321,20 +335,35 @@ def print_machine_info(info: "api.ServerInfo.Response") -> None:
@server_cli.command(name="list")
def server_list(
token: str = DEFAULT_TOKEN,
local: bool = typer.Option(
True, help="List older servers not associated to token."
),
show_forgotten: bool = typer.Option(
False, help="Show deleted and forgotten servers."
),
local: Annotated[
bool, typer.Option(help="List older servers not associated to token.")
] = True,
show_forgotten: Annotated[
bool, typer.Option(help="Show deleted and forgotten servers.")
] = False,
) -> 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 .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
machine_id_hostnames = {}
@ -348,6 +377,13 @@ def server_list(
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
@ -360,10 +396,23 @@ def server_list(
hostname = machine_id_hostnames[info.machine_id]
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)
console.print(table)
if local:
for hostname_json in os.listdir(directory):
hostname = hostname_json.split(".")[0]
@ -383,7 +432,7 @@ def server_list(
except SporeStackUserError as e:
expiration = saved_vm_info["expiration"]
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 += 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 .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.add_column("Flavor Slug (--flavor)")
@ -664,15 +713,17 @@ def save_token(token: str, key: str) -> None:
@token_cli.command(name="create")
def token_create(
token: str = typer.Argument(DEFAULT_TOKEN),
dollars: int = typer.Option(...),
currency: str = typer.Option(...),
dollars: Annotated[int, typer.Option()],
currency: Annotated[str, typer.Option()],
token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN,
) -> None:
"""
Enables a new token.
Dollars is starting balance.
"""
from httpx import HTTPError
from . import utils
_token = utils.random_token()
@ -694,11 +745,7 @@ def token_create(
currency=currency,
)
uri = response.payment.uri
assert uri is not None
usd = response.payment.usd
make_payment(currency=currency, uri=uri, usd=usd)
make_payment(response.invoice)
tries = 360 * 2
while tries > 0:
@ -713,10 +760,10 @@ def token_create(
dollars=dollars,
currency=currency,
)
except SporeStackServerError:
except (SporeStackServerError, HTTPError):
typer.echo("Received 500 HTTP status, will try again.", err=True)
continue
if response.payment.paid is True:
if response.invoice.paid:
typer.echo(f"{token} has been enabled with ${dollars}.")
typer.echo(f"{token}'s key is {_token}.")
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."""
token = load_token(token)
from httpx import HTTPError
from .api_client import APIClient
from .exceptions import SporeStackServerError
@ -754,11 +803,7 @@ def token_topup(
currency=currency,
)
uri = response.payment.uri
assert uri is not None
usd = response.payment.usd
make_payment(currency=currency, uri=uri, usd=usd)
make_payment(response.invoice)
tries = 360 * 2
while tries > 0:
@ -771,12 +816,12 @@ def token_topup(
dollars=dollars,
currency=currency,
)
except SporeStackServerError:
except (SporeStackServerError, HTTPError):
typer.echo("Received 500 HTTP status, will try again.", err=True)
continue
# Waiting for payment to set in.
time.sleep(10)
if response.payment.paid is True:
if response.invoice.paid:
typer.echo(f"Added {dollars} dollars to {token}")
return
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")
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.
@ -828,7 +873,7 @@ def token_info(token: str = typer.Argument(DEFAULT_TOKEN)) -> None:
@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."""
_token = load_token(token)
@ -842,13 +887,75 @@ def servers(token: str = typer.Argument(DEFAULT_TOKEN)) -> None:
@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()
typer.echo(f"SporeStack tokens present in {token_dir}:", err=True)
typer.echo("(Name): (Key)", err=True)
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)
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()

View File

@ -3,7 +3,7 @@ from typing import List, Union
from . import api
from .api_client import APIClient
from .models import TokenInfo
from .models import Invoice, TokenInfo
from .utils import random_machine_id, random_token
@ -74,6 +74,10 @@ class Token:
"""Returns information about a 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]:
"""Returns support messages for/from the 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
from pydantic import BaseModel
class Currency(str, Enum):
xmr = "xmr"
"""Monero"""
btc = "btc"
"""Bitcoin"""
bch = "bch"
"""Bitcoin Cash"""
class Payment(BaseModel):
"""This is deprecated in favor of Invoice."""
txid: Optional[str]
uri: Optional[str]
usd: str
@ -62,3 +75,54 @@ class Region(BaseModel):
slug: str
# Actually human readable string describing the region.
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."
),
),
]