Browse Source

6.0.0a1: Token-centric release

See the changelog for more information!
master 6.0.0a1
SporeStack 3 months ago
parent
commit
2af37624f7
  1. 2
      .woodpecker.yml
  2. 16
      CHANGELOG.md
  3. 1
      Makefile
  4. 38
      README.md
  5. 2
      setup.cfg
  6. 8
      src/sporestack/api_client.py
  7. 276
      src/sporestack/cli.py

2
.woodpecker.yml

@ -23,6 +23,7 @@ pipeline:
image: python:3.9
commands:
- pip install pipenv==2022.1.8 pre-commit==2.17.0
- pre-commit run --all-files
- pipenv install --dev --deploy
- pipenv run almake test
- pipenv run almake build-dist
@ -33,6 +34,7 @@ pipeline:
image: python:3.10
commands:
- pip install pipenv==2022.1.8 pre-commit==2.17.0
- pre-commit run --all-files
- pipenv install --dev --deploy
- pipenv run almake test
- pipenv run almake build-dist

16
CHANGELOG.md

@ -15,6 +15,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Nothing yet.
## [6.0.0a1 - 2022-03-31]
Remember to backup your ~/.sporestack folder as any tokens you generate will be stored there!
### Changed
- Now token-centric. You can only use `sporestack` to launch or topup servers from a token.
- `sporestack launch/info/topup`, etc, moved to `sporestack server launch/info/topup`, etc.
- `--token` argument takes the name of the token, and not the key. Defaults to `primary`.
- `--ssh-key-file` now defaults to `~/.ssh/id_rsa.pub`.
- Import generated tokens from the key with: `sporestack token import (token reference name, default is primary) --key (the token key in hex format)`
### Added
- New token commands: `sporestack token create/list`
## [5.2.3 - 2022-03-30]
### Added

1
Makefile

@ -1,5 +1,4 @@
test:
pre-commit run --all-files
python -m pflake8 .
python -m mypy --strict .
$(MAKE) test-pytest

38
README.md

@ -7,45 +7,37 @@
## Installation
* `pip install sporestack`
* Recommended: Create a virtual environment, first. Can use `pipenv`, as well.
* Recommended: Create a virtual environment, first, and use it inside there.
## Running without installing (preferred)
## Running without installing
* Make sure `pipx` is installed.
* `pipx run sporestack`
* Make sure you're on the latest version with `sporestack version`.
## Screenshot
![sporestack CLI screenshot](https://sporestack.com/static/sporestackv2-screenshot.png)
* Make sure you're on the latest stable version comparing `sporestack version` with git tags in this repository, or releases on [PyPI](https://pypi.org/project/sporestack/).
## Usage
* `sporestack launch SomeHostname --flavor vps-1vcpu-1gb --days 7 --ssh-key ~/.ssh/id_rsa.pub --operating-system debian-10 --currency btc`
* `sporestack topup SomeHostname --days 3 --currency xmr`
* `sporestack launch SomeOtherHostname --flavor vps-1vcpu-2gb --days 7 --ssh-key ~/.ssh/id_rsa.pub --operating-system debian-11 --currency btc`
* `sporestack stop SomeHostname`
* `sporestack start SomeHostname`
* `sporestack list`
* `sporestack remove SomeHostname # If expired`
* `sporestack settlement-token-generate`
* `sporestack settlement-token-enable (token) --dollars 10 --currency xmr`
* `sporestack settlement-token-add (token) --dollars 25 --currency btc`
* `sporestack settlement-token-balance (token)`
More examples on the [website](https://sporestack.com).
* `sporestack token create --dollars 20 --currency xmr # Can use btc as well.`
* `sporestack token list`
* `sporestack token balance`
* `sporestack server launch SomeHostname --operating-system debian-11 --days 1 # Will use ~/.ssh/id_rsa.pub as your SSH key, by default`
(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 list`
* `sporestack server remove SomeHostname # If expired`
## Notes
* You can use `--settlement-token` if you don't want to pay with QR codes all the time.
* If using a .onion API endpoint, will try to use a local Tor proxy if connecting to a .onion URL. (127.0.0.1:9050)
* If you want to communicate with SporeStack APIs using Tor, set this environment variable: `SPORESTACK_USE_TOR_ENDPOINT=1`
## Developing
* `pip install pipenv pre-commit`
* `pre-commit install`
* `pipenv install --deploy --dev`
* `pipenv run make test` (If you don't have `make`, use `almake`)
* Hint: `pre-commit run` is a faster way to run some of the tests/autofixers.
* `pre-commit run --all-files` (To format code, or wait for `git commit`)
## Licence

2
setup.cfg

@ -1,6 +1,6 @@
[metadata]
name = sporestack
version = 5.2.3
version = 6.0.0a1
description = SporeStack.com library and client. Launch servers with Monero or Bitcoin.
long_description = file: README.md
long_description_content_type = text/markdown

8
src/sporestack/api_client.py

@ -137,7 +137,7 @@ def launch(
ssh_key: str,
api_endpoint: str = API_ENDPOINT,
region: Optional[str] = None,
settlement_token: Optional[str] = None,
token: Optional[str] = None,
retry: bool = False,
affiliate_amount: Optional[int] = None,
affiliate_token: Optional[str] = None,
@ -146,7 +146,7 @@ def launch(
machine_id=machine_id,
days=days,
currency=currency,
settlement_token=settlement_token,
settlement_token=token,
affiliate_amount=affiliate_amount,
affiliate_token=affiliate_token,
flavor=flavor,
@ -166,7 +166,7 @@ def topup(
days: int,
currency: str,
api_endpoint: str = API_ENDPOINT,
settlement_token: Optional[str] = None,
token: Optional[str] = None,
retry: bool = False,
affiliate_amount: Optional[int] = None,
affiliate_token: Optional[str] = None,
@ -178,7 +178,7 @@ def topup(
machine_id=machine_id,
days=days,
currency=currency,
settlement_token=settlement_token,
settlement_token=token,
affiliate_amount=affiliate_amount,
affiliate_token=affiliate_token,
)

276
src/sporestack/cli.py

@ -53,11 +53,29 @@ SPORESTACK_USE_TOR_ENDPOINT
TOR_PROXY (defaults to socks5h://127.0.0.1:9050 which is fine for most)
"""
_home = os.getenv("HOME", None)
assert _home is not None, "Unable to detect $HOME environment variable?"
HOME = Path(_home)
SPORESTACK_DIR = HOME / ".sporestack"
cli = typer.Typer(help=HELP)
token_cli = typer.Typer()
HOME = Path(_home)
cli.add_typer(token_cli, name="token")
server_cli = typer.Typer()
cli.add_typer(server_cli, name="server")
logging.basicConfig(level=logging.INFO)
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"
# On disk format
TOKEN_VERSION = 1
WAITING_PAYMENT_TO_PROCESS = "Waiting for payment to process..."
@ -86,15 +104,14 @@ Press ctrl+c to abort."""
input("[Press enter once you have made payment.]")
@cli.command()
@server_cli.command()
def launch(
hostname: str,
days: int = typer.Option(...),
ssh_key_file: Path = typer.Option(...),
operating_system: str = typer.Option(...),
ssh_key_file: Path = DEFAULT_SSH_KEY_FILE,
flavor: str = DEFAULT_FLAVOR,
currency: Optional[str] = None,
settlement_token: Optional[str] = None,
token: str = DEFAULT_TOKEN,
region: Optional[str] = None,
) -> None:
"""
@ -103,70 +120,37 @@ def launch(
from . import utils
if settlement_token is not None:
if currency is None or currency == "settlement":
currency = "settlement"
else:
msg = "Cannot use non-settlement --currency with --settlement-token"
typer.echo(msg, err=True)
raise typer.Exit(code=2)
if currency is None:
typer.echo("--currency must be set.", err=True)
raise typer.Exit(code=2)
typer.echo(f"Launching server with token {token}...", err=True)
_token = load_token(token)
if machine_exists(hostname):
typer.echo(f"{hostname} already created.")
raise typer.Exit(code=1)
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()
assert currency is not None
response = api_client.launch(
machine_id=machine_id,
days=days,
flavor=flavor,
operating_system=operating_system,
ssh_key=ssh_key,
currency=currency,
currency="settlement",
region=region,
settlement_token=settlement_token,
token=_token,
api_endpoint=get_api_endpoint(),
retry=True,
)
# This will be false at least the first time if paying with BTC or BCH.
if response.payment.paid is False:
assert response.payment.uri is not None
make_payment(
currency=currency,
uri=response.payment.uri,
usd=response.payment.usd,
)
tries = 360
while tries > 0:
tries = tries - 1
typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True)
# FIXME: Wait one hour in a smarter way.
# Waiting for payment to set in.
time.sleep(10)
response = api_client.launch(
machine_id=machine_id,
days=days,
flavor=flavor,
operating_system=operating_system,
ssh_key=ssh_key,
currency=currency,
region=region,
settlement_token=settlement_token,
api_endpoint=get_api_endpoint(),
retry=True,
)
if response.payment.paid is True:
break
if response.created is False:
tries = 360
while tries > 0:
@ -180,9 +164,9 @@ def launch(
flavor=flavor,
operating_system=operating_system,
ssh_key=ssh_key,
currency=currency,
currency="settlement",
region=region,
settlement_token=settlement_token,
token=_token,
api_endpoint=get_api_endpoint(),
retry=True,
)
@ -200,103 +184,69 @@ def launch(
typer.echo(json.dumps(created_dict, indent=4))
@cli.command()
@server_cli.command()
def topup(
hostname: str,
days: int = typer.Option(...),
currency: Optional[str] = None,
settlement_token: Optional[str] = None,
token: str = DEFAULT_TOKEN,
) -> None:
"""
tops up an existing vm.
"""
if settlement_token is not None:
if currency is None or currency == "settlement":
currency = "settlement"
else:
msg = "Cannot use non-settlement --currency with --settlement-token"
typer.echo(msg, err=True)
raise typer.Exit(code=2)
if currency is None:
typer.echo("--currency must be set.", err=True)
raise typer.Exit(code=2)
if not machine_exists(hostname):
typer.echo(f"{hostname} does not exist.")
raise typer.Exit(code=1)
_token = load_token(token)
machine_info = get_machine_info(hostname)
machine_id = machine_info["machine_id"]
assert currency is not None
response = api_client.topup(
machine_id=machine_id,
days=days,
currency=currency,
currency="settlement",
api_endpoint=get_api_endpoint(),
settlement_token=settlement_token,
token=_token,
retry=True,
)
# This will be false at least the first time if paying with anything
# but settlement.
if response.payment.paid is False:
assert response.payment.uri is not None
make_payment(
currency=currency,
uri=response.payment.uri,
usd=response.payment.usd,
)
tries = 360
while tries > 0:
typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True)
tries = tries - 1
# FIXME: Wait one hour in a smarter way.
# Waiting for payment to set in.
time.sleep(10)
response = api_client.topup(
machine_id=machine_id,
days=days,
currency=currency,
api_endpoint=get_api_endpoint(),
settlement_token=settlement_token,
retry=True,
)
if response.payment.paid is True:
break
machine_info["expiration"] = response.expiration
save_machine_info(machine_info, overwrite=True)
typer.echo(machine_info["expiration"])
def server_info_path() -> Path:
home = os.getenv("HOME")
assert home is not None, "Unable to detect $HOME environment variable?"
sporestack_dir = Path(home, ".sporestack")
# Put servers in a subdirectory
servers_dir = sporestack_dir.joinpath("servers")
servers_dir = SPORESTACK_DIR / "servers"
# Migrate existing server.json files into servers subdirectory
if sporestack_dir.exists() and not servers_dir.exists():
if SPORESTACK_DIR.exists() and not servers_dir.exists():
typer.echo(
f"Migrating server profiles found in {sporestack_dir} to {servers_dir}.",
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.joinpath(json_file.name))
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)
SPORESTACK_DIR.mkdir(exist_ok=True)
servers_dir.mkdir(exist_ok=True)
return servers_dir
def token_path() -> Path:
token_dir = SPORESTACK_DIR / "tokens"
# Make it, if it doesn't exist already.
token_dir.mkdir(exist_ok=True, parents=True)
return token_dir
def save_machine_info(machine_info: Dict[str, Any], overwrite: bool = False) -> None:
"""
Save info to disk.
@ -304,7 +254,7 @@ def save_machine_info(machine_info: Dict[str, Any], overwrite: bool = False) ->
os.umask(0o0077)
directory = server_info_path()
hostname = machine_info["vm_hostname"]
json_file = directory.joinpath(f"{hostname}.json")
json_file = directory / f"{hostname}.json"
if overwrite is False:
assert json_file.exists() is False, f"{json_file} already exists."
json_file.write_text(json.dumps(machine_info))
@ -315,7 +265,7 @@ def get_machine_info(hostname: str) -> Dict[str, Any]:
Get info from disk.
"""
directory = server_info_path()
json_file = directory.joinpath(f"{hostname}.json")
json_file = directory / f"{hostname}.json"
if not json_file.exists():
raise ValueError(f"{hostname} does not exist in {directory} as {json_file}")
machine_info = json.loads(json_file.read_bytes())
@ -343,8 +293,8 @@ def pretty_machine_info(info: Dict[str, Any]) -> str:
return msg
@cli.command()
def list() -> None:
@server_cli.command(name="list")
def server_list() -> None:
"""
List all locally known servers.
"""
@ -386,7 +336,7 @@ def machine_exists(hostname: str) -> bool:
return server_info_path().joinpath(f"{hostname}.json").exists()
@cli.command()
@server_cli.command()
def get_attribute(hostname: str, attribute: str) -> None:
"""
Returns an attribute about the VM.
@ -395,7 +345,7 @@ def get_attribute(hostname: str, attribute: str) -> None:
typer.echo(machine_info[attribute])
@cli.command()
@server_cli.command()
def info(hostname: str) -> None:
"""
Info on the VM
@ -407,7 +357,7 @@ def info(hostname: str) -> None:
)
@cli.command()
@server_cli.command()
def start(hostname: str) -> None:
"""
Boots the VM.
@ -418,7 +368,7 @@ def start(hostname: str) -> None:
typer.echo(f"{hostname} started.")
@cli.command()
@server_cli.command()
def stop(hostname: str) -> None:
"""
Immediately kills the VM.
@ -429,7 +379,7 @@ def stop(hostname: str) -> None:
typer.echo(f"{hostname} stopped.")
@cli.command()
@server_cli.command()
def delete(hostname: str) -> None:
"""
Deletes the VM before expiration (no refunds/credits)
@ -442,7 +392,7 @@ def delete(hostname: str) -> None:
typer.echo(f"{hostname} was deleted.")
@cli.command()
@server_cli.command()
def rebuild(hostname: str) -> None:
"""
Rebuilds the VM with the operating system and SSH key given at launch time.
@ -455,9 +405,38 @@ def rebuild(hostname: str) -> None:
typer.echo(f"{hostname} rebuilding.")
@cli.command()
def settlement_token_enable(
token: str,
def load_token(token: str) -> str:
token_file = token_path().joinpath(f"{token}.json")
if not token_file.exists():
msg = f"Token '{token}' ({token_file}) does not exist. Create it with:\n"
msg += f"sporestack token create {token} --dollars 20 --currency xmr\n"
msg += "(Can do more than $20, or a different currency, like btc.)\n"
msg += (
"With the token credited, you can launch servers, renew existing ones, etc."
)
typer.echo(msg, err=True)
raise typer.Exit(code=1)
token_data = json.loads(token_file.read_text())
assert token_data["version"] == 1
assert isinstance(token_data["key"], str)
return token_data["key"]
def save_token(token: str, key: str) -> None:
token_file = token_path().joinpath(f"{token}.json")
if token_file.exists():
msg = "Token '{token}' already exists in {token_file}. Aborting!"
typer.echo(msg, err=True)
raise typer.Exit(code=1)
token_data = {"version": TOKEN_VERSION, "name": token, "key": key}
token_file.write_text(json.dumps(token_data))
@token_cli.command(name="create")
def token_create(
token: str = typer.Argument(DEFAULT_TOKEN),
dollars: int = typer.Option(...),
currency: str = typer.Option(...),
) -> None:
@ -466,9 +445,18 @@ def settlement_token_enable(
Dollars is starting balance.
"""
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)
response = api_client.token_enable(
token=token,
token=_token,
dollars=dollars,
currency=currency,
api_endpoint=get_api_endpoint(),
@ -489,29 +477,42 @@ def settlement_token_enable(
# Waiting for payment to set in.
time.sleep(10)
response = api_client.token_enable(
token=token,
token=_token,
dollars=dollars,
currency=currency,
api_endpoint=get_api_endpoint(),
retry=True,
)
if response.payment.paid is True:
typer.echo(
f"{token} has been enabled with ${dollars}. Save it and don't lose it!"
)
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.")
@cli.command()
def settlement_token_add(
token: str,
@token_cli.command(name="import")
def token_import(
name: str = typer.Argument(DEFAULT_TOKEN),
key: str = typer.Option(...),
) -> None:
"""
Imports a token from a key.
"""
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(...),
) -> None:
"""
Adds balance to an existing settlement token.
"""
token = load_token(token)
response = api_client.token_add(
token,
@ -547,25 +548,30 @@ def settlement_token_add(
raise ValueError(f"{token} did not get enabled in time.")
@cli.command()
def settlement_token_balance(token: str) -> None:
@token_cli.command()
def balance(token: str = typer.Argument(DEFAULT_TOKEN)) -> None:
"""
Gets balance for a settlement token.
"""
_token = load_token(token)
typer.echo(
api_client.token_balance(token=token, api_endpoint=get_api_endpoint()).usd
api_client.token_balance(token=_token, api_endpoint=get_api_endpoint()).usd
)
@cli.command()
def settlement_token_generate() -> None:
@token_cli.command(name="list")
def token_list() -> None:
"""
Generates a settlement token that can be enabled.
Gets balance for a settlement token.
"""
from . import utils
typer.echo(utils.random_token())
token_dir = token_path()
typer.echo(f"SporeStack tokens present in {token_dir}:", err=True)
typer.echo("(Name): (Key)", err=True)
for token_file in token_dir.glob("*.json"):
token = token_file.stem
key = load_token(token)
typer.echo(f"{token}: {key}")
@cli.command()

Loading…
Cancel
Save