Compare commits
20 Commits
Author | SHA1 | Date |
---|---|---|
Administrator | 6eaa839d51 | |
Administrator | 6cad92952a | |
Administrator | 6bc5791980 | |
Administrator | c28e8b45fc | |
Administrator | 9582ce66b7 | |
Administrator | 1ef7224b1a | |
Administrator | c0ccff7e74 | |
Administrator | 4de17915cb | |
Administrator | 7398ebd1a2 | |
Administrator | 7a4f228625 | |
Administrator | 0881d28222 | |
Administrator | bd4be5d999 | |
Administrator | 691fe4d5f8 | |
Administrator | ac7eb3a186 | |
Administrator | 190b94746c | |
Administrator | 027bc13ad2 | |
Administrator | d84c2975ee | |
Administrator | 396dbee6f6 | |
Administrator | 5637790af5 | |
Administrator | 16c790728d |
|
@ -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
|
||||
|
|
99
CHANGELOG.md
99
CHANGELOG.md
|
@ -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
|
||||
|
|
9
Makefile
9
Makefile
|
@ -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
16
Pipfile
|
@ -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"
|
||||
|
|
File diff suppressed because it is too large
Load Diff
47
README.md
47
README.md
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"""
|
|
@ -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.")
|
||||
]
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
17
tox.ini
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue