Compare commits

...

20 Commits

Author SHA1 Message Date
Administrator 6eaa839d51 Upgrade ruff and typer
ci/woodpecker/push/woodpecker Pipeline failed Details
Also update integration test to use Debian 12 instead of Debian 11.
2024-03-25 20:10:24 +00:00
Administrator 6cad92952a v11.1.0: Automatic per-token SSH key support
ci/woodpecker/push/woodpecker Pipeline failed Details
ci/woodpecker/tag/woodpecker Pipeline failed Details
2024-03-16 01:06:05 +00:00
Administrator 6bc5791980 v11.0.1: Minor bug fixes
ci/woodpecker/push/woodpecker Pipeline failed Details
ci/woodpecker/tag/woodpecker Pipeline failed Details
2024-02-29 04:31:06 +00:00
Administrator c28e8b45fc v11.0.0
ci/woodpecker/push/woodpecker Pipeline failed Details
ci/woodpecker/tag/woodpecker Pipeline failed Details
Mostly cleanups/deprecation removals.
2024-02-27 00:45:32 +00:00
Administrator 9582ce66b7 More Woodpecker CI...
ci/woodpecker/push/woodpecker Pipeline failed Details
2024-01-18 20:35:48 +00:00
Administrator 1ef7224b1a Adjustments to .woodpecker.yml
ci/woodpecker/push/woodpecker Pipeline failed Details
2024-01-18 20:28:39 +00:00
Administrator c0ccff7e74 Remove group from Woodpecker CI
ci/woodpecker/push/woodpecker Pipeline failed Details
2024-01-18 20:22:09 +00:00
Administrator 4de17915cb Update to new Woodpecker CI syntax
ci/woodpecker/push/woodpecker Pipeline failed Details
2024-01-18 20:10:44 +00:00
Administrator 7398ebd1a2 v10.8.0: Add `--wait/--no-wait` support to `sporestack token create/topup` and more 2024-01-03 21:13:48 +00:00
Administrator 7a4f228625 v10.7.2: Fix false positive message about suspended servers 2024-01-03 02:17:27 +00:00
Administrator 0881d28222 v10.7.1: Loosen up type for invoice ID to support new API changes 2024-01-03 02:03:32 +00:00
Administrator bd4be5d999 pipenv updates 2024-01-03 02:00:41 +00:00
Administrator 691fe4d5f8 Integration test fixes for recent changes 2023-12-11 23:13:22 +00:00
Administrator ac7eb3a186 10.7.0: Add suspended server support
ci/woodpecker/push/woodpecker Pipeline failed Details
ci/woodpecker/tag/woodpecker Pipeline failed Details
2023-10-31 21:14:57 +00:00
Administrator 190b94746c Update Pipfile, pipenv
ci/woodpecker/push/woodpecker Pipeline failed Details
2023-10-29 04:50:16 +00:00
Administrator 027bc13ad2 v10.6.3: Bump timeouts to 60 seconds
ci/woodpecker/push/woodpecker Pipeline failed Details
ci/woodpecker/tag/woodpecker Pipeline failed Details
2023-09-18 10:19:52 +00:00
Administrator d84c2975ee v10.6.2: Support Pydantic v1 + v2 2023-07-07 22:31:08 +00:00
Administrator 396dbee6f6 v10.6.1: Require Pydantic v1.10 as we are not yet ready for v2 2023-07-07 18:34:49 +00:00
Administrator 5637790af5 v10.6.0: Add `sporestack server update-server` command.
Also improve test coverage slightly.
2023-05-25 16:24:08 +00:00
Administrator 16c790728d v10.5.0: Added `sporestack token invoices` command 2023-05-12 20:05:38 +00:00
17 changed files with 1562 additions and 820 deletions

View File

@ -1,54 +1,47 @@
pipeline:
pre-commit:
group: pre-commit-test
image: python:3.11
commands:
- pip install pre-commit==2.20.0
- pre-commit run --all-files
python-3.7:
group: test
image: python:3.7-alpine
commands:
- pip install pipenv==2022.10.25
- pipenv install --dev --deploy
- pipenv run almake test-pytest # We only test with pytest on 3.7
steps:
python-3.8:
group: test
image: python:3.8
commands:
- pip install pipenv==2022.10.25
- pip install pipenv==2023.12.1 tomli
- pipenv install --dev --deploy
- pipenv run almake test
- pipenv run almake test-typing
- pipenv run almake test-pytest
- pipenv run almake build-dist
- sha256sum dist/*
python-3.9:
group: test
image: python:3.9
commands:
- pip install pipenv==2022.10.25
- pip install pipenv==2023.12.1
- pipenv install --dev --deploy
- pipenv run almake test
- pipenv run almake test-typing
- pipenv run almake test-pytest
- pipenv run almake build-dist
- sha256sum dist/*
python-3.10:
group: test
image: python:3.10
commands:
- pip install pipenv==2022.10.25
- pip install pipenv==2023.12.1
- pipenv install --dev --deploy
- pipenv run almake test-typing
- pipenv run almake test-pytest
- pipenv run almake build-dist
- sha256sum dist/*
python-3.11:
image: python:3.11
commands:
- pip install pipenv==2023.12.1
- pipenv install --dev --deploy
- pipenv run almake test
- pipenv run almake build-dist
- sha256sum dist/*
python-3.11:
group: test
python-3.12:
image: python:3.11
commands:
- pip install pipenv==2022.10.25
- pip install pipenv==2023.12.1
- pipenv install --dev --deploy
- pipenv run almake test
- pipenv run almake build-dist

View File

@ -5,16 +5,107 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Deprecated features that will be removed in the next major version.
## Deprecated features that will be removed in the next major version (12.X.X).
- `burn_rate` from `TokenInfo` is deprecated. Use `burn_rate_cents` or `burn_rate_usd` instead.
- `--no-local` will become the default for `sporestack server list`.
- If you want the CLI features, you will have to `pip install sporestack[cli]` instead of just `pip install sporestack`.
- `--local` will be removed from `sporestack server list`.
## [Unreleased]
- Nothing yet.
## [11.1.0 - 2024-03-16]
## Added
### Library
- `ssh_key` to `client.Client()` and to `client.Token()`. This acts as a default SSH key when launching servers this way.
### CLI
- Support for automatic per-token SSH keys (can be overridden with `--ssh-key-file` still.) To generate, run: `ssh-keygen -C "" -t ed25519 -f ~/.sporestack/sshkey/{token}/id_ed25519`
- This means that you don't have to pass `--ssh-key-file` if you are using a token that has a locally associated SSH key.
- When launching a server with `sporestack server launch`, it will suggest adding a readymade configuration to `~/.ssh/config` to utilize whatever key you selected.
## Summary
These changes should make it easier to stay private with SporeStack, conveniently, by utilizing a SSH key per token. In general, we recommend using one unique SSH key per token that you have.
## [11.0.1 - 2024-02-29]
## Fixed
- If a server is deleted during the launch wait phase, it will give up rather than trying to wait forever for an IP address that will never come.
- `--hostname` matching is smarter in case of duplicate hostnames.
## [11.0.0 - 2024-02-26]
## Changed
- Various command/help cleanups.
- If you want the CLI features, you will have to `pip install sporestack[cli]` instead of just `pip install sporestack`.
- `--no-local` is now the default for `sporestack server list`.
## Removed
- Deprecated fields from responses and requests.
- `legacy_polling=True` support for token add/topup.
## [10.8.0 - 2024-01-03]
## Added
- Support for paying invoices without polling.
- `--qr/--no-qr` to `sporestack token topup` and `sporestack token create`.
- `--wait/--no-wait` to `sporestack token topup` and `sporestack token create`.
- `sporestack token invoice` support to view an individual invoice.
## Removed
- Python 3.7 support.
## [10.7.0 - 2023-10-31]
## Added
- Added `suspended_at` to server info response object.
- Added `autorenew_servers` to token info response object.
- Added `suspended_servers` to token info response object.
## [10.6.3 - 2023-09-18]
## Changed
- Bumped httpx timeouts from 5 seconds to 60 seconds (this may be fine-tuned in the future).
## [10.6.2 - 2023-07-07]
## Changed
- Make package compatible with Pydantic v1.10.x and v2.
## [10.6.1 - 2023-07-07]
## Changed
- Mark package as being compatible with Pydantic v1.10.X. It's not yet ready with v2. Does not seem to be possible to make the release compatible with both.
## [10.6.0 - 2023-05-25]
## Added
- `sporestack server update-hostname` command.
## [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

@ -1,13 +1,16 @@
format:
black .
ruff --fix .
ruff check --fix .
test:
black --check .
ruff .
mypy
ruff check .
$(MAKE) test-typing
$(MAKE) test-pytest
test-typing:
mypy
test-pytest:
python -m pytest --cov=sporestack --cov-fail-under=39 --cov-report=term --durations=3 --cache-clear

16
Pipfile
View File

@ -7,25 +7,25 @@ name = "pypi"
sporestack = {editable = true, path = "."}
[dev-packages]
black = "~=23.1"
black = "~=24.0"
mypy = "~=1.0"
pytest = "~=7.2"
pytest = "~=8.0"
pytest-cov = "~=4.0"
pytest-mock = "~=3.6"
pytest-socket = "~=0.6.0"
ruff = "==0.0.261"
pytest-socket = "~=0.7.0"
ruff = "~=0.3.4"
respx = "~=0.20.1"
# Building
flit = "~=3.8"
wheel = "~=0.40.0"
build = "~=0.10.0"
wheel = "*"
build = "~=1.0"
# Publishing
twine = "~=4.0"
twine = "~=5.0"
# Docs
pdoc = "~=13.0"
pdoc = "~=14.0"
# Python `make` implementation
almost-make = "~=0.5.2"

1056
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,43 +1,50 @@
# Python 3 library and CLI for [SporeStack](https://sporestack.com) [.onion](http://spore64i5sofqlfz5gq2ju4msgzojjwifls7rok2cti624zyq3fcelad.onion)
# Python 3 library and CLI for [SporeStack](https://sporestack.com) ([SporeStack Tor Hidden Service](http://spore64i5sofqlfz5gq2ju4msgzojjwifls7rok2cti624zyq3fcelad.onion))
[Changelog](CHANGELOG.md)
## Requirements
* Python 3.7-3.11 (or maybe newer)
## Installation
* `pip install sporestack`
* 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.
* Python 3.8-3.11 (and likely newer)
## Running without installing
* Make sure `pipx` is installed.
* `pipx run 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/).
* Make sure [pipx](https://pipx.pypya.io) is installed.
* `pipx run 'sporestack[cli]'`
## Usage
## Installation with pipx
* `sporestack token create --dollars 20 --currency xmr # Can use btc as well.`
* Make sure [pipx](https://pipx.pypya.io) is installed.
* `pipx install 'sporestack[cli]'`
## Traditional installation
* Recommended: Create and activate a virtual environment, first.
* `pip install sporestack` (Run `pip install 'sporestack[cli]'` if you wish to use the command line `sporestack` functionality and not just the Python library.)
## Usage Examples
* Recommended: 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/).
* `sporestack token create --dollars 20 --currency xmr`
* `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-12 --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`
## Notes
* If you want to communicate with SporeStack APIs using Tor, set this environment variable: `SPORESTACK_USE_TOR_ENDPOINT=1`
* If you want to communicate with the SporeStack API using Tor, set this environment variable: `SPORESTACK_USE_TOR_ENDPOINT=1`. Verify which endpoint is in use with `sporestack api-endpoint`.
## Developing
* `pipenv install --deploy --dev`
* `pipenv run make test` (If you don't have `make`, use `almake`)
* `pipenv run make test`
* `pipenv run make format` to format files and apply ruff fixes.
## Licence

View File

@ -21,20 +21,18 @@ 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
sporestack token list | grep imaninvalidkey
sporestack server launch --no-quote --token neverbeencreated --operating-system debian-11 --days 1 2>&1 | grep 'does not exist'
sporestack server launch --no-quote --token neverbeencreated --operating-system debian-12 --days 1 2>&1 | grep 'does not exist'
# Online tests start here.
sporestack token create --dollars 50 --currency fakecurrency ihaveafakecurrency 2>&1 | grep 'value is not a valid'
sporestack server launch --no-quote --token importediminvalid --operating-system debian-11 --days 1 2>&1 | grep 'ensure this value has at least 32'
sporestack server launch --no-quote --token importediminvalid --operating-system debian-12 --days 1 2>&1 | grep 'String should have at least'
sporestack server flavors | grep vcpu
sporestack server operating-systems | grep debian-11
sporestack server operating-systems | grep debian-12
sporestack server regions | grep sfo3
sporestack api-changelog
@ -52,21 +50,29 @@ sporestack token balance realtestingtoken | grep -F '$'
sporestack token info realtestingtoken
sporestack token messages realtestingtoken
sporestack token servers realtestingtoken
sporestack token invoices realtestingtoken
sporestack token topup realtestingtoken --currency xmr --dollars 26 --no-wait
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-12 --days 1 --hostname sporestackpythonintegrationtestdelme
sporestack server list --token realtestingtoken | grep sporestackpythonintegrationtestdelme
sporestack server topup --token realtestingtoken --hostname sporestackpythonintegrationtestdelme --days 1
sporestack server info --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server json --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
MACHINE_ID=$(sporestack server json --token realtestingtoken --hostname sporestackpythonintegrationtestdelme | jq -r .machine_id)
sporestack server autorenew-enable --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server autorenew-disable --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server update-hostname "$MACHINE_ID" --hostname "new" | grep sporestackpythonintegrationtestdelme
sporestack server update-hostname "$MACHINE_ID" --hostname ""
sporestack server update-hostname "$MACHINE_ID" --hostname "new again" | grep set
sporestack server update-hostname "$MACHINE_ID" --hostname sporestackpythonintegrationtestdelme
sporestack server start --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server stop --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server rebuild --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server delete --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server forget --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack token create newtoken --currency xmr --dollars 27 --no-wait
rm -r $SPORESTACK_DIR
echo Success

View File

@ -2,7 +2,7 @@
addopts = "--strict-markers --disable-socket"
[tool.ruff]
select = [
lint.select = [
"F", # pyflakes
"E", # pycodestyle errors
"W", # pycodestyle warnings
@ -13,19 +13,18 @@ select = [
"UP", # pyupgrade
]
ignore = [
lint.ignore = [
"ANN101", # Type annotations for self
"ANN401", # Allow ANY
]
unfixable = [
lint.unfixable = [
"F401", # Don't try to automatically remove unused imports
"RUF100", # Unused noqa
"F841", # Unused variable
]
target-version = "py37"
update-check = false
target-version = "py38"
[tool.coverage.report]
show_missing = true
@ -49,23 +48,19 @@ warn_untyped_fields = true
name = "sporestack"
authors = [ {name = "SporeStack", email="support@sporestack.com"} ]
readme = "README.md"
requires-python = "~=3.7"
requires-python = "~=3.8"
dynamic = ["version", "description"]
keywords = ["bitcoin", "monero", "vps", "server"]
license = {file = "LICENSE.txt"}
dependencies = [
"pydantic",
"pydantic>=1.10,<3",
"httpx[socks]",
"segno",
"typer",
"rich",
]
# These will be made mandatory for v11
[project.optional-dependencies]
cli = [
"segno",
"typer",
"typer>=0.9.0",
"rich",
]

View File

@ -1,5 +1,5 @@
"""SporeStack API and CLI for launching servers with Monero or Bitcoin"""
"""SporeStack API library and CLI for launching servers with Monero or Bitcoin"""
__all__ = ["api", "api_client", "exceptions"]
__all__ = ["api", "api_client", "client", "exceptions"]
__version__ = "10.4.0"
__version__ = "11.1.0"

11
src/sporestack/_models.py Normal file
View File

@ -0,0 +1,11 @@
# This file is split out to improve CLI performance.
from enum import Enum
class Currency(str, Enum):
xmr = "xmr"
"""Monero"""
btc = "btc"
"""Bitcoin"""
bch = "bch"
"""Bitcoin Cash"""

View File

@ -1,13 +1,18 @@
"""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 Currency, Flavor, Invoice, OperatingSystem, Region
if sys.version_info >= (3, 9): # pragma: nocover
from typing import Annotated
else: # pragma: nocover
from typing_extensions import Annotated
class TokenAdd:
@ -15,13 +20,12 @@ class TokenAdd:
method = "POST"
class Request(BaseModel):
currency: str
currency: Currency
dollars: int
affiliate_token: Union[str, None] = None
class Response(BaseModel):
token: str
payment: Payment
invoice: Invoice
class TokenBalance:
@ -29,7 +33,6 @@ class TokenBalance:
method = "GET"
class Response(BaseModel):
token: str
cents: int
usd: str
@ -41,16 +44,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:
@ -107,10 +112,10 @@ class ServerInfo:
ipv6: str
region: str
flavor: Flavor
deleted: bool
deleted_at: int
deleted_by: Union[ServerDeletedBy, None]
forgotten_at: Union[datetime, None]
suspended_at: Union[datetime, None]
operating_system: str
hostname: str
autorenew: bool
@ -189,17 +194,21 @@ class TokenMessageSender(str, Enum):
class TokenMessage(BaseModel):
message: str = Field(
...,
title="Message",
min_length=1,
max_length=10_000,
)
sent_at: datetime = Field(
...,
title="Sent At",
description="When the message was sent.",
)
sender: TokenMessageSender = Field(
..., title="Sender", description="Who sent the message."
)
message: Annotated[
str,
Field(
title="Message",
min_length=1,
max_length=10_000,
),
]
sent_at: Annotated[
datetime,
Field(
title="Sent At",
description="When the message was sent.",
),
]
sender: Annotated[
TokenMessageSender, Field(title="Sender", description="Who sent the message.")
]

View File

@ -7,11 +7,12 @@ import httpx
from pydantic import parse_obj_as
from . import __version__, api, exceptions
from .models import TokenInfo
from .models import Currency, Invoice, ServerUpdateRequest, TokenInfo
log = logging.getLogger(__name__)
LATEST_API_VERSION = 2
"""This is probably not used anymore."""
CLEARNET_ENDPOINT = "https://api.sporestack.com"
TOR_ENDPOINT = (
@ -20,9 +21,7 @@ TOR_ENDPOINT = (
API_ENDPOINT = CLEARNET_ENDPOINT
GET_TIMEOUT = 60
POST_TIMEOUT = 90
USE_TOR_PROXY = "auto"
TIMEOUT = httpx.Timeout(60.0)
HEADERS = {"User-Agent": f"sporestack-python/{__version__}"}
@ -102,7 +101,9 @@ class APIClient:
proxy = None
if _is_onion_url(self.api_endpoint):
proxy = _get_tor_proxy()
self._httpx_client = httpx.Client(headers=headers, proxies=proxy)
self._httpx_client = httpx.Client(
headers=headers, proxies=proxy, timeout=TIMEOUT
)
def server_launch(
self,
@ -212,6 +213,18 @@ class APIClient:
response_object = api.ServerInfo.Response.parse_obj(response.json())
return response_object
def server_update(
self,
machine_id: str,
hostname: Union[str, None] = None,
autorenew: Union[bool, None] = None,
) -> None:
"""Update server settings."""
request = ServerUpdateRequest(hostname=hostname, autorenew=autorenew)
url = self.api_endpoint + f"/server/{machine_id}"
response = self._httpx_client.patch(url=url, json=request.dict())
_handle_response(response)
def servers_launched_from_token(
self, token: str
) -> api.ServersLaunchedFromToken.Response:
@ -261,7 +274,7 @@ class APIClient:
self,
token: str,
dollars: int,
currency: str,
currency: Currency,
) -> api.TokenAdd.Response:
"""Add balance (money) to a token."""
url = self.api_endpoint + api.TokenAdd.url.format(token=token)
@ -300,3 +313,19 @@ 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_invoice(self, token: str, invoice: str) -> Invoice:
"""Get a particular invoice."""
url = self.api_endpoint + f"/token/{token}/invoices/{invoice}"
response = self._httpx_client.get(url=url)
_handle_response(response)
return parse_obj_as(Invoice, response.json())
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,24 @@ 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
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 = """
@ -39,11 +49,12 @@ cli = typer.Typer(help=HELP)
HOME = Path(_home)
token_cli = typer.Typer(help="Commands to interact with SporeStack tokens")
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")
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:
@ -55,6 +66,8 @@ 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
@ -71,36 +84,104 @@ def get_api_endpoint() -> str:
return api_endpoint
def make_payment(currency: str, uri: str, usd: str) -> None:
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
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 = segno.make(invoice.payment_uri)
qr.terminal()
typer.echo(message)
typer.echo(f"Approximate price in USD: {usd}")
input("[Press enter once you have made payment.]")
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(
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,
region: Optional[str] = None,
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."),
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."
),
@ -109,23 +190,16 @@ def launch(
typer.echo(f"Launching server with token {token}...", err=True)
_token = load_token(token)
from . import utils
from .api_client import APIClient
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
api_client = APIClient(api_endpoint=get_api_endpoint())
client = Client(api_client=api_client, client_token=_token)
typer.echo(f"Loading SSH key from {ssh_key_file}...")
if not ssh_key_file.exists():
msg = f"{ssh_key_file} does not exist. "
msg += "You can try generating a key file with `ssh-keygen`"
typer.echo(msg, err=True)
raise typer.Exit(code=1)
ssh_key = ssh_key_file.read_text()
machine_id = utils.random_machine_id()
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)
@ -145,20 +219,23 @@ def launch(
)
server = client.token.launch_server(
machine_id=machine_id,
days=days,
flavor=flavor,
operating_system=operating_system,
ssh_key=ssh_key,
region=region,
hostname=hostname,
autorenew=autorenew,
)
if wait:
tries = 360
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)
@ -171,9 +248,44 @@ def launch(
raise typer.Exit(code=1)
else:
print_machine_info(response)
return
print_machine_info(server.info())
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()
@ -206,20 +318,6 @@ def server_info_path() -> Path:
# Put servers in a subdirectory
servers_dir = SPORESTACK_DIR / "servers"
# Migrate existing server.json files into servers subdirectory
if (
SPORESTACK_DIR.exists()
and not servers_dir.exists()
and len(list(SPORESTACK_DIR.glob("*.json"))) > 0
):
typer.echo(
f"Migrating server profiles found in {SPORESTACK_DIR} to {servers_dir}.",
err=True,
)
servers_dir.mkdir()
for json_file in SPORESTACK_DIR.glob("*.json"):
json_file.rename(servers_dir / json_file.name)
# Make it, if it doesn't exist already.
SPORESTACK_DIR.mkdir(exist_ok=True)
servers_dir.mkdir(exist_ok=True)
@ -236,6 +334,15 @@ def token_path() -> Path:
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.
@ -284,57 +391,94 @@ def epoch_to_human(epoch: int) -> str:
def print_machine_info(info: "api.ServerInfo.Response") -> None:
if info.hostname != "":
typer.echo(f"Hostname: {info.hostname}")
else:
typer.echo("Hostname: (none) (No hostname set)")
from rich.console import Console
from rich.panel import Panel
typer.echo(f"Machine ID (keep this secret!): {info.machine_id}")
console = Console(width=None if sys.stdout.isatty() else 10**9)
output = ""
output = ""
if info.ipv6 != "":
typer.echo(f"IPv6: {info.ipv6}")
output += f"IPv6: {info.ipv6}\n"
else:
typer.echo("IPv6: (Not yet assigned)")
output += "IPv6: (Not yet assigned)\n"
if info.ipv4 != "":
typer.echo(f"IPv4: {info.ipv4}")
output += f"IPv4: {info.ipv4}\n"
else:
typer.echo("IPv4: (Not yet assigned)")
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}")
if info.deleted_at != 0 or info.deleted:
typer.echo("Server was deleted!")
if info.deleted_at != 0:
typer.echo(f"Server deleted at: {epoch_to_human(info.deleted_at)}")
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:
typer.echo(f"Server deleted by: {info.deleted_by.value}")
output += f"Server deleted by: {info.deleted_by.value}\n"
if info.forgotten_at is not None:
typer.echo(f"Server forgotten at: {info.forgotten_at}")
output += f"Server forgotten at: {info.forgotten_at}\n"
else:
typer.echo(f"Running: {info.running}")
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
typer.echo(f"Server will be deleted in {hours} hours.")
typer.echo(f"Autorenew: {info.autorenew}")
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: 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.")
] = False,
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 +492,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 +511,29 @@ 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),
)
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]
@ -383,7 +553,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}): "
@ -420,11 +590,29 @@ def _get_machine_id(machine_id: str, hostname: str, token: str) -> str:
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:
return server.machine_id
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
@ -536,6 +724,27 @@ def forget(
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
@ -562,7 +771,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,65 +873,52 @@ 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(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.
Dollars is starting balance.
"""
"""Enables a new token."""
from . import utils
_token = utils.random_token()
typer.echo(f"Generated key {_token} for use with token {token}", err=True)
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)
from .api_client import APIClient
from .exceptions import SporeStackServerError
_token = utils.random_token()
typer.echo(f"Generated key {_token} for use with token {token}", err=True)
api_client = APIClient(api_endpoint=get_api_endpoint())
response = api_client.token_add(
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!"
)
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)
try:
response = api_client.token_add(
token=_token,
dollars=dollars,
currency=currency,
)
except SporeStackServerError:
typer.echo("Received 500 HTTP status, will try again.", err=True)
continue
if response.payment.paid is True:
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!")
save_token(token, _token)
return
raise ValueError(f"{token} did not get enabled in time.")
@token_cli.command(name="import")
@ -734,52 +930,89 @@ def token_import(
save_token(name, key)
@token_cli.command(name="topup")
def token_topup(
token: str = typer.Argument(DEFAULT_TOKEN),
dollars: int = typer.Option(...),
currency: str = typer.Option(...),
def token_add(
token: str, dollars: int, currency: Currency, wait: bool, token_name: str, qr: bool
) -> None:
"""Adds balance to an existing token."""
token = load_token(token)
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)
response = api_client.token_add(
token,
dollars,
currency=currency,
)
invoice = client.token.add(dollars, currency=currency)
uri = response.payment.uri
assert uri is not None
usd = response.payment.usd
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.")
make_payment(currency=currency, uri=uri, usd=usd)
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
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.
typer.echo("Press ctrl+c to abort.")
while invoice.expired is False or invoice.paid is False:
try:
response = api_client.token_add(
token=token,
dollars=dollars,
currency=currency,
)
except SporeStackServerError:
invoice = client.token.invoice(invoice=invoice.id)
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:
typer.echo(f"Added {dollars} dollars to {token}")
if invoice.paid:
typer.echo(
f"Added ${dollars} to {token_name} ({token}) with TXID {invoice.txid}"
)
return
raise ValueError(f"{token} did not get enabled in time.")
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()
@ -795,7 +1028,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.
@ -816,7 +1049,9 @@ def token_info(token: str = typer.Argument(DEFAULT_TOKEN)) -> None:
info = client.token.info()
print(f"[bold]Token Information for {token} ({_token})[/bold]")
print(f"Balance: [green]{info.balance_usd}")
print(f"Total Servers: {info.servers}")
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)"
@ -828,8 +1063,8 @@ def token_info(token: str = typer.Argument(DEFAULT_TOKEN)) -> None:
@token_cli.command()
def servers(token: str = typer.Argument(DEFAULT_TOKEN)) -> None:
"""Returns server info for servers launched by a given token."""
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
@ -842,13 +1077,127 @@ 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)
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()

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 Currency, Invoice, TokenInfo
from .utils import random_machine_id, random_token
@ -22,7 +22,7 @@ class Server:
self.api_client.server_rebuild(self.machine_id)
def forget(self) -> None:
"""Forget about the server so it doesn't show up in server listings."""
"""Forget about the server so it doesn't show up when listing servers."""
self.api_client.server_forget(self.machine_id)
def delete(self) -> None:
@ -39,11 +39,19 @@ class Server:
def autorenew_enable(self) -> None:
"""Enables autorenew on the server."""
self.api_client.autorenew_enable(self.machine_id)
self.api_client.server_update(self.machine_id, autorenew=True)
def autorenew_disable(self) -> None:
"""Disables autorenew on the server."""
self.api_client.autorenew_disable(self.machine_id)
self.api_client.server_update(self.machine_id, autorenew=False)
def update(
self, hostname: Union[str, None] = None, autorenew: Union[bool, None] = None
) -> None:
"""Update details about a server."""
self.api_client.server_update(
self.machine_id, hostname=hostname, autorenew=autorenew
)
def topup(self, days: int) -> None:
"""
@ -59,12 +67,19 @@ class Server:
@dataclass
class Token:
token: str = random_token()
token: str = field(default_factory=random_token)
api_client: APIClient = field(default_factory=APIClient)
ssh_key: Union[str, None] = None
"""SSH public key for launching new servers with."""
def add(self, dollars: int, currency: str) -> None:
"""Add to token"""
self.api_client.token_add(token=self.token, dollars=dollars, currency=currency)
def add(self, dollars: int, currency: Currency) -> Invoice:
"""Fund the token."""
response = self.api_client.token_add(
token=self.token,
dollars=dollars,
currency=currency,
)
return response.invoice
def balance(self) -> int:
"""Returns the token's balance in cents."""
@ -74,6 +89,14 @@ class Token:
"""Returns information about a token."""
return self.api_client.token_info(token=self.token)
def invoice(self, invoice: str) -> Invoice:
"""Returns the specified token's invoice."""
return self.api_client.token_invoice(token=self.token, invoice=invoice)
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)
@ -98,15 +121,20 @@ class Token:
def launch_server(
self,
ssh_key: str,
flavor: str,
days: int,
operating_system: str,
ssh_key: Union[str, None] = None,
region: Union[str, None] = None,
hostname: str = "",
autorenew: bool = False,
machine_id: str = random_machine_id(),
) -> Server:
if ssh_key is None:
if self.ssh_key is not None:
ssh_key = self.ssh_key
else:
raise ValueError("ssh_key must be set in Client() or launch_server().")
self.api_client.server_launch(
machine_id=machine_id,
days=days,
@ -129,6 +157,8 @@ class Client:
"""Token to manage/pay for servers with."""
api_client: APIClient = field(default_factory=APIClient)
"""Your own API Client, perhaps if you want to connect through Tor."""
ssh_key: Union[str, None] = None
"""SSH public key for launching new servers with."""
def flavors(self) -> api.Flavors.Response:
"""Returns available flavors (server sizes)."""
@ -153,4 +183,6 @@ class Client:
@property
def token(self) -> Token:
"""Returns a Token object with the api_client and token specified."""
return Token(token=self.client_token, api_client=self.api_client)
return Token(
token=self.client_token, api_client=self.api_client, ssh_key=self.ssh_key
)

View File

@ -1,20 +1,17 @@
"""
"""SporeStack API supplemental models"""
SporeStack API supplemental models
import sys
from typing import Union
"""
if sys.version_info >= (3, 9): # pragma: nocover
from typing import Annotated
else: # pragma: nocover
from typing_extensions import Annotated
# Re-export Currency
from pydantic import BaseModel, Field
from typing import Optional
from pydantic import BaseModel
class Payment(BaseModel):
txid: Optional[str]
uri: Optional[str]
usd: str
paid: bool
from ._models import Currency as Currency
class Flavor(BaseModel):
@ -49,12 +46,12 @@ class OperatingSystem(BaseModel):
class TokenInfo(BaseModel):
balance_cents: int
balance_usd: str
burn_rate: int
"""Deprecated."""
burn_rate_cents: int
burn_rate_usd: str
days_remaining: int
servers: int
autorenew_servers: int
suspended_servers: int
class Region(BaseModel):
@ -62,3 +59,79 @@ class Region(BaseModel):
slug: str
# Actually human readable string describing the region.
name: str
class Invoice(BaseModel):
id: str
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,
pattern="^[a-f0-9]+$",
),
]
expired: Annotated[
bool,
Field(
description=(
"Whether or not the invoice has expired (only applicable if "
"unpaid, or payment not yet confirmed."
),
),
]
class ServerUpdateRequest(BaseModel):
hostname: Annotated[
Union[str, None],
Field(
min_length=0,
max_length=128,
title="Hostname",
description="Hostname to refer to your server by.",
example="web-1",
pattern="(^$|^[a-zA-Z0-9-_. ]+$)",
),
] = None
autorenew: Annotated[
Union[bool, None],
Field(
title="Autorenew",
description=(
"Automatically renew the server from the token, "
"keeping it at 1 week expiration."
),
example=True,
),
] = None

View File

@ -81,7 +81,8 @@ def test_token_info(respx_mock: respx.MockRouter) -> None:
"balance_cents": 0,
"balance_usd": "$0.00",
"servers": 0,
"burn_rate": 0,
"autorenew_servers": 0,
"suspended_servers": 0,
"burn_rate_usd": "$0.00",
"burn_rate_cents": 0,
"days_remaining": 0,
@ -95,10 +96,64 @@ def test_token_info(respx_mock: respx.MockRouter) -> None:
info_response = client.token_info(dummy_token)
assert info_response.balance_cents == 0
assert info_response.balance_usd == "$0.00"
assert info_response.burn_rate == 0
assert info_response.burn_rate_cents == 0
assert info_response.burn_rate_usd == "$0.00"
assert info_response.servers == 0
assert info_response.days_remaining == 0
assert route.called
def test_server_info(respx_mock: respx.MockRouter) -> None:
dummy_machine_id = "dummyinvalidmachineid"
flavor = {
"slug": "a flavor slug",
"cores": 1,
"memory": 1024,
"disk": 25,
"price": 38,
"ipv4": "/32",
"ipv6": "/128",
"bandwidth_per_month": 1.0,
}
response_json = {
"machine_id": dummy_machine_id,
"hostname": "a hostname",
"flavor": flavor,
"region": "a region",
"token": "a token",
"running": True,
"created_at": 1,
"expiration": 2,
"autorenew": False,
"ipv4": "0.0.0.0",
"ipv6": "::0",
"deleted": False,
"deleted_at": 0,
"deleted_by": None,
"forgotten_at": None,
"suspended_at": None,
"operating_system": "debian-11",
}
route_response = httpx.Response(200, json=response_json)
route = respx_mock.get(f"/server/{dummy_machine_id}/info").mock(
return_value=route_response
)
client = api_client.APIClient()
info_response = client.server_info(dummy_machine_id)
# These aren't exhaustive, but there's a number here.
assert info_response.machine_id == dummy_machine_id
assert info_response.hostname == response_json["hostname"]
assert info_response.flavor.dict() == response_json["flavor"]
assert info_response.token == response_json["token"]
assert info_response.running == response_json["running"]
assert info_response.created_at == response_json["created_at"]
assert info_response.expiration == response_json["expiration"]
assert info_response.autorenew == response_json["autorenew"]
assert info_response.forgotten_at == response_json["forgotten_at"]
# Not sure why mypy dislikes this. It passes pytest.
assert info_response.flavor.slug == response_json["flavor"]["slug"] # type: ignore
assert route.called

17
tox.ini
View File

@ -1,18 +1,19 @@
[tox]
env_list =
py37
py38
py39
py310
py311
py{38,39,310,311}-pydantic{1,2}
[testenv]
deps =
pytest~=7.2
pytest-socket~=0.6.0
deps =
pydantic1: pydantic~=1.10
pydantic2: pydantic~=2.0
pytest~=8.0
pytest-socket~=0.7.0
pytest-cov~=4.0
pytest-mock~=3.6
respx~=0.20.1
segno
typer~=0.9.0
rich
commands =
pytest --cov=sporestack --cov-fail-under=39 --cov-report=term --durations=3
sporestack api-endpoint