From 2af37624f79b19408121e9c413a0c710d6da7b9f Mon Sep 17 00:00:00 2001 From: SporeStack Date: Fri, 1 Apr 2022 01:08:12 +0000 Subject: [PATCH] 6.0.0a1: Token-centric release See the changelog for more information! --- .woodpecker.yml | 2 + CHANGELOG.md | 16 ++ Makefile | 1 - README.md | 38 ++--- setup.cfg | 2 +- src/sporestack/api_client.py | 8 +- src/sporestack/cli.py | 276 ++++++++++++++++++----------------- 7 files changed, 179 insertions(+), 164 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index c85d572..7539a33 100644 --- a/.woodpecker.yml +++ b/.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 diff --git a/CHANGELOG.md b/CHANGELOG.md index a44cb19..e967468 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/Makefile b/Makefile index b34c40c..935008c 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,4 @@ test: - pre-commit run --all-files python -m pflake8 . python -m mypy --strict . $(MAKE) test-pytest diff --git a/README.md b/README.md index 82b7aaf..9891acd 100644 --- a/README.md +++ b/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 diff --git a/setup.cfg b/setup.cfg index 021de5d..624b54c 100644 --- a/setup.cfg +++ b/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 diff --git a/src/sporestack/api_client.py b/src/sporestack/api_client.py index 80e5df1..96d79aa 100644 --- a/src/sporestack/api_client.py +++ b/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, ) diff --git a/src/sporestack/cli.py b/src/sporestack/cli.py index fc4870b..ee2060f 100644 --- a/src/sporestack/cli.py +++ b/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()