From 65829178986cd922eec658d1af4237599918fa40 Mon Sep 17 00:00:00 2001 From: SporeStack Date: Wed, 7 Sep 2022 23:07:02 +0000 Subject: [PATCH] 7.0.0 --- CHANGELOG.md | 21 ++++ src/sporestack/__init__.py | 2 +- src/sporestack/api.py | 8 ++ src/sporestack/api_client.py | 12 ++ src/sporestack/cli.py | 212 ++++++++++++++++++++--------------- tests/test_cli.py | 14 +++ 6 files changed, 175 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 551ddfc..7b0d5cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.0.0 - 2022-09-07] + +### Added + +- `sporestack server list` now accepts `--local` or `--no-local`. +- `sporestack server operating-systems` + +### Changed + +- `sporestack server` subcommands take `--hostname` or `--machine-id`. +- `sporestack server flavors` output is slightly more readable. + +### Removed + +- `sporestack server delete` (in favor of: `sporestack server destroy`) +- `sporestack server get-attribute` + ## [6.2.0 - 2022-09-07] ### Added - Allow for new *beta* `--autorenew` feature with `sporestack server launch`. +### Changed + +- No longer save server JSON to disk for new servers. + ## [6.1.0 - 2022-06-14] ### Changed diff --git a/src/sporestack/__init__.py b/src/sporestack/__init__.py index a15f22f..a17445b 100644 --- a/src/sporestack/__init__.py +++ b/src/sporestack/__init__.py @@ -2,4 +2,4 @@ __all__ = ["api", "api_client", "exceptions"] -__version__ = "6.2.0" +__version__ = "7.0.0" diff --git a/src/sporestack/api.py b/src/sporestack/api.py index a14e430..838544a 100644 --- a/src/sporestack/api.py +++ b/src/sporestack/api.py @@ -168,3 +168,11 @@ class Flavors: class Response(BaseModel): flavors: dict[str, Flavor] + + +class OperatingSystems: + url = "/operatingsystems" + method = "GET" + + class Response(BaseModel): + operating_systems: list[str] diff --git a/src/sporestack/api_client.py b/src/sporestack/api_client.py index 2d214db..7634743 100644 --- a/src/sporestack/api_client.py +++ b/src/sporestack/api_client.py @@ -271,6 +271,18 @@ def flavors(api_endpoint: str = API_ENDPOINT) -> api.Flavors.Response: return response_object +def operating_systems( + api_endpoint: str = API_ENDPOINT, +) -> api.OperatingSystems.Response: + """ + Returns available operating systems. + """ + url = api_endpoint + api.OperatingSystems.url + response = _api_request(url) + response_object = api.OperatingSystems.Response.parse_obj(response) + return response_object + + def token_add( token: str, dollars: int, diff --git a/src/sporestack/cli.py b/src/sporestack/cli.py index 507e72e..c85c017 100644 --- a/src/sporestack/cli.py +++ b/src/sporestack/cli.py @@ -60,10 +60,11 @@ os.umask(0o0077) cli = typer.Typer(help=HELP) -token_cli = typer.Typer() HOME = Path(_home) + +token_cli = typer.Typer(help="Commands to interact with SporeStack tokens") cli.add_typer(token_cli, name="token") -server_cli = typer.Typer() +server_cli = typer.Typer(help="Commands to interact with SporeStack servers") cli.add_typer(server_cli, name="server") logging.basicConfig(level=logging.INFO) @@ -105,7 +106,7 @@ Press ctrl+c to abort.""" @server_cli.command() def launch( - hostname: str, + hostname: str = "", days: int = typer.Option(...), operating_system: str = typer.Option(...), ssh_key_file: Path = DEFAULT_SSH_KEY_FILE, @@ -124,10 +125,6 @@ def launch( 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. " @@ -203,7 +200,8 @@ def launch( @server_cli.command() def topup( - hostname: str, + hostname: str = "", + machine_id: str = "", days: int = typer.Option(...), token: str = DEFAULT_TOKEN, quote: bool = typer.Option(True, help="Require manual price confirmation."), @@ -212,15 +210,10 @@ def topup( Extend an existing SporeStack server's lifetime. """ - if not machine_exists(hostname): - typer.echo(f"{hostname} does not exist.") - raise typer.Exit(code=1) + machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token) _token = load_token(token) - machine_info = get_machine_info(hostname) - machine_id = machine_info["machine_id"] - if quote: response = api_client.topup( machine_id=machine_id, @@ -310,14 +303,17 @@ def pretty_machine_info(info: Dict[str, Any]) -> str: @server_cli.command(name="list") -def server_list(token: str = DEFAULT_TOKEN) -> None: +def server_list( + token: str = DEFAULT_TOKEN, + local: bool = typer.Option( + True, help="List older servers not associated to token." + ), +) -> None: """ List all locally known servers and all servers under the given token. """ from .exceptions import SporeStackUserError - directory = server_info_path() - _token = load_token(token) server_infos = api_client.servers_launched_from_token( @@ -325,10 +321,12 @@ def server_list(token: str = DEFAULT_TOKEN) -> None: ).servers machine_id_hostnames = {} - for hostname_json in os.listdir(directory): - hostname = hostname_json.split(".")[0] - saved_vm_info = get_machine_info(hostname) - machine_id_hostnames[saved_vm_info["machine_id"]] = hostname + if local: + directory = server_info_path() + for hostname_json in os.listdir(directory): + hostname = hostname_json.split(".")[0] + saved_vm_info = get_machine_info(hostname) + machine_id_hostnames[saved_vm_info["machine_id"]] = hostname printed_machine_ids = [] @@ -360,124 +358,138 @@ def server_list(token: str = DEFAULT_TOKEN) -> None: printed_machine_ids.append(info.machine_id) - for hostname_json in os.listdir(directory): - hostname = hostname_json.split(".")[0] - saved_vm_info = get_machine_info(hostname) - machine_id = saved_vm_info["machine_id"] - if machine_id in printed_machine_ids: - continue + if local: + for hostname_json in os.listdir(directory): + hostname = hostname_json.split(".")[0] + saved_vm_info = get_machine_info(hostname) + machine_id = saved_vm_info["machine_id"] + if machine_id in printed_machine_ids: + continue - try: - upstream_vm_info = api_client.info( - machine_id=saved_vm_info["machine_id"], - api_endpoint=get_api_endpoint(), - ) - saved_vm_info["expiration"] = upstream_vm_info.expiration - saved_vm_info["running"] = upstream_vm_info.running - typer.echo() - typer.echo(pretty_machine_info(saved_vm_info)) - except SporeStackUserError as e: - expiration = saved_vm_info["expiration"] - human_expiration = time.strftime( - "%Y-%m-%d %H:%M:%S %z", time.localtime(expiration) - ) - msg = hostname - msg += f" expired ({expiration} {human_expiration}): " - msg += str(e) - typer.echo(msg) + try: + upstream_vm_info = api_client.info( + machine_id=saved_vm_info["machine_id"], + api_endpoint=get_api_endpoint(), + ) + saved_vm_info["expiration"] = upstream_vm_info.expiration + saved_vm_info["running"] = upstream_vm_info.running + typer.echo() + typer.echo(pretty_machine_info(saved_vm_info)) + except SporeStackUserError as e: + expiration = saved_vm_info["expiration"] + human_expiration = time.strftime( + "%Y-%m-%d %H:%M:%S %z", time.localtime(expiration) + ) + msg = hostname + msg += f" expired ({expiration} {human_expiration}): " + msg += str(e) + typer.echo(msg) typer.echo() -def machine_exists(hostname: str) -> bool: - """ - Check if the VM's JSON exists locally. - """ - return server_info_path().joinpath(f"{hostname}.json").exists() +def _get_machine_id(machine_id: str, hostname: str, token: str) -> str: + usage = "--hostname *OR* --machine-id must be set." + + if machine_id != "" and hostname != "": + typer.echo(usage, err=True) + raise typer.Exit(code=2) + + if machine_id != "": + return machine_id + + if hostname == "": + typer.echo(usage, err=True) + raise typer.Exit(code=2) + + try: + machine_id = get_machine_info(hostname)["machine_id"] + assert isinstance(machine_id, str) + return machine_id + except Exception: + pass + + _token = load_token(token) + + for server in api_client.servers_launched_from_token( + token=_token, api_endpoint=get_api_endpoint() + ).servers: + if server.hostname == hostname: + return server.machine_id + + typer.echo( + f"Could not find any servers matching the hostname: {hostname}", err=True + ) + raise typer.Exit(code=1) @server_cli.command() -def get_attribute(hostname: str, attribute: str) -> None: - """ - Returns an attribute about the VM. - """ - machine_info = get_machine_info(hostname) - typer.echo(machine_info[attribute]) - - -@server_cli.command() -def info(hostname: str) -> None: +def info(hostname: str = "", machine_id: str = "", token: str = DEFAULT_TOKEN) -> None: """ Info on the VM """ - machine_info = get_machine_info(hostname) - machine_id = machine_info["machine_id"] + machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token) typer.echo( api_client.info(machine_id=machine_id, api_endpoint=get_api_endpoint()).json() ) @server_cli.command() -def start(hostname: str) -> None: +def start(hostname: str = "", machine_id: str = "", token: str = DEFAULT_TOKEN) -> None: """ Boots the VM. """ - machine_info = get_machine_info(hostname) - machine_id = machine_info["machine_id"] + machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token) api_client.start(machine_id=machine_id, api_endpoint=get_api_endpoint()) typer.echo(f"{hostname} started.") @server_cli.command() -def stop(hostname: str) -> None: +def stop(hostname: str = "", machine_id: str = "", token: str = DEFAULT_TOKEN) -> None: """ Immediately shuts down the VM. """ - machine_info = get_machine_info(hostname) - machine_id = machine_info["machine_id"] + machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token) api_client.stop(machine_id=machine_id, api_endpoint=get_api_endpoint()) typer.echo(f"{hostname} stopped.") @server_cli.command() -def destroy(hostname: str) -> None: +def destroy( + hostname: str = "", machine_id: str = "", token: str = DEFAULT_TOKEN +) -> None: """ Deletes/destroys the VM before expiration (no refunds/credits) """ - _destroy(hostname) - - -@server_cli.command() -def delete(hostname: str) -> None: - """ - Deletes the VM before expiration (no refunds/credits) - - Deprecated: Use destroy instead. - """ - _destroy(hostname) - - -def _destroy(hostname: str) -> None: - """ - Deletes the VM before expiration (no refunds/credits) - """ - machine_info = get_machine_info(hostname) - machine_id = machine_info["machine_id"] + machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token) api_client.destroy(machine_id=machine_id, api_endpoint=get_api_endpoint()) # Also remove the .json file server_info_path().joinpath(f"{hostname}.json").unlink(missing_ok=True) - typer.echo(f"{hostname} was destroyed.") + typer.echo(f"{machine_id} was destroyed.") @server_cli.command() -def rebuild(hostname: str) -> None: +def forget( + hostname: str = "", machine_id: str = "", token: str = DEFAULT_TOKEN +) -> None: + """ + Forget about a deleted server so that it doesn't show up in server list. + """ + machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token) + api_client.forget(machine_id=machine_id, api_endpoint=get_api_endpoint()) + typer.echo(f"{machine_id} was forgotten.") + + +@server_cli.command() +def rebuild( + hostname: str = "", machine_id: str = "", token: str = DEFAULT_TOKEN +) -> None: """ Rebuilds the VM with the operating system and SSH key given at launch time. Will take a couple minutes to complete after the request is made. """ - machine_info = get_machine_info(hostname) - machine_id = machine_info["machine_id"] + machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token) api_client.rebuild(machine_id=machine_id, api_endpoint=get_api_endpoint()) typer.echo(f"{hostname} rebuilding.") @@ -487,7 +499,21 @@ def flavors() -> None: """ Returns available flavors. """ - typer.echo(api_client.flavors(api_endpoint=get_api_endpoint())) + flavors = api_client.flavors(api_endpoint=get_api_endpoint()).flavors + for flavor in flavors: + typer.echo(f"{flavor}: {flavors[flavor]}") + + +@server_cli.command() +def operating_systems() -> None: + """ + Returns available operating systems. + """ + os_list = api_client.operating_systems( + api_endpoint=get_api_endpoint() + ).operating_systems + for operating_system in os_list: + typer.echo(operating_system) def load_token(token: str) -> str: diff --git a/tests/test_cli.py b/tests/test_cli.py index 09f9f40..1a569a9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,5 @@ +import pytest +import typer from _pytest.monkeypatch import MonkeyPatch from typer.testing import CliRunner @@ -42,3 +44,15 @@ def test_cli_api_endpoint(monkeypatch: MonkeyPatch) -> None: result = runner.invoke(cli.cli, ["api-endpoint"]) assert result.output == TOR_ENDPOINT + " using socks5h://127.0.0.1:1337\n" assert result.exit_code == 0 + + +def test_get_machine_id() -> None: + assert cli._get_machine_id("machine_id", "", "token") == "machine_id" + + # machine_id and hostname set + with pytest.raises(typer.Exit): + cli._get_machine_id("machine_id", "hostname", "token") + + # Neither is set + with pytest.raises(typer.Exit): + cli._get_machine_id("", "", "token")