This commit is contained in:
SporeStack 2022-09-07 23:07:02 +00:00
parent 3f2cd2c20b
commit 6582917898
6 changed files with 175 additions and 94 deletions

View File

@ -7,12 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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] ## [6.2.0 - 2022-09-07]
### Added ### Added
- Allow for new *beta* `--autorenew` feature with `sporestack server launch`. - 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] ## [6.1.0 - 2022-06-14]
### Changed ### Changed

View File

@ -2,4 +2,4 @@
__all__ = ["api", "api_client", "exceptions"] __all__ = ["api", "api_client", "exceptions"]
__version__ = "6.2.0" __version__ = "7.0.0"

View File

@ -168,3 +168,11 @@ class Flavors:
class Response(BaseModel): class Response(BaseModel):
flavors: dict[str, Flavor] flavors: dict[str, Flavor]
class OperatingSystems:
url = "/operatingsystems"
method = "GET"
class Response(BaseModel):
operating_systems: list[str]

View File

@ -271,6 +271,18 @@ def flavors(api_endpoint: str = API_ENDPOINT) -> api.Flavors.Response:
return response_object 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( def token_add(
token: str, token: str,
dollars: int, dollars: int,

View File

@ -60,10 +60,11 @@ os.umask(0o0077)
cli = typer.Typer(help=HELP) cli = typer.Typer(help=HELP)
token_cli = typer.Typer()
HOME = Path(_home) HOME = Path(_home)
token_cli = typer.Typer(help="Commands to interact with SporeStack tokens")
cli.add_typer(token_cli, name="token") 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") cli.add_typer(server_cli, name="server")
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@ -105,7 +106,7 @@ Press ctrl+c to abort."""
@server_cli.command() @server_cli.command()
def launch( def launch(
hostname: str, hostname: str = "",
days: int = typer.Option(...), days: int = typer.Option(...),
operating_system: str = typer.Option(...), operating_system: str = typer.Option(...),
ssh_key_file: Path = DEFAULT_SSH_KEY_FILE, ssh_key_file: Path = DEFAULT_SSH_KEY_FILE,
@ -124,10 +125,6 @@ def launch(
typer.echo(f"Launching server with token {token}...", err=True) typer.echo(f"Launching server with token {token}...", err=True)
_token = load_token(token) _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}...") typer.echo(f"Loading SSH key from {ssh_key_file}...")
if not ssh_key_file.exists(): if not ssh_key_file.exists():
msg = f"{ssh_key_file} does not exist. " msg = f"{ssh_key_file} does not exist. "
@ -203,7 +200,8 @@ def launch(
@server_cli.command() @server_cli.command()
def topup( def topup(
hostname: str, hostname: str = "",
machine_id: str = "",
days: int = typer.Option(...), days: int = typer.Option(...),
token: str = DEFAULT_TOKEN, token: str = DEFAULT_TOKEN,
quote: bool = typer.Option(True, help="Require manual price confirmation."), quote: bool = typer.Option(True, help="Require manual price confirmation."),
@ -212,15 +210,10 @@ def topup(
Extend an existing SporeStack server's lifetime. Extend an existing SporeStack server's lifetime.
""" """
if not machine_exists(hostname): machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token)
typer.echo(f"{hostname} does not exist.")
raise typer.Exit(code=1)
_token = load_token(token) _token = load_token(token)
machine_info = get_machine_info(hostname)
machine_id = machine_info["machine_id"]
if quote: if quote:
response = api_client.topup( response = api_client.topup(
machine_id=machine_id, machine_id=machine_id,
@ -310,14 +303,17 @@ def pretty_machine_info(info: Dict[str, Any]) -> str:
@server_cli.command(name="list") @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. List all locally known servers and all servers under the given token.
""" """
from .exceptions import SporeStackUserError from .exceptions import SporeStackUserError
directory = server_info_path()
_token = load_token(token) _token = load_token(token)
server_infos = api_client.servers_launched_from_token( server_infos = api_client.servers_launched_from_token(
@ -325,10 +321,12 @@ def server_list(token: str = DEFAULT_TOKEN) -> None:
).servers ).servers
machine_id_hostnames = {} machine_id_hostnames = {}
for hostname_json in os.listdir(directory): if local:
hostname = hostname_json.split(".")[0] directory = server_info_path()
saved_vm_info = get_machine_info(hostname) for hostname_json in os.listdir(directory):
machine_id_hostnames[saved_vm_info["machine_id"]] = hostname hostname = hostname_json.split(".")[0]
saved_vm_info = get_machine_info(hostname)
machine_id_hostnames[saved_vm_info["machine_id"]] = hostname
printed_machine_ids = [] printed_machine_ids = []
@ -360,124 +358,138 @@ def server_list(token: str = DEFAULT_TOKEN) -> None:
printed_machine_ids.append(info.machine_id) printed_machine_ids.append(info.machine_id)
for hostname_json in os.listdir(directory): if local:
hostname = hostname_json.split(".")[0] for hostname_json in os.listdir(directory):
saved_vm_info = get_machine_info(hostname) hostname = hostname_json.split(".")[0]
machine_id = saved_vm_info["machine_id"] saved_vm_info = get_machine_info(hostname)
if machine_id in printed_machine_ids: machine_id = saved_vm_info["machine_id"]
continue if machine_id in printed_machine_ids:
continue
try: try:
upstream_vm_info = api_client.info( upstream_vm_info = api_client.info(
machine_id=saved_vm_info["machine_id"], machine_id=saved_vm_info["machine_id"],
api_endpoint=get_api_endpoint(), api_endpoint=get_api_endpoint(),
) )
saved_vm_info["expiration"] = upstream_vm_info.expiration saved_vm_info["expiration"] = upstream_vm_info.expiration
saved_vm_info["running"] = upstream_vm_info.running saved_vm_info["running"] = upstream_vm_info.running
typer.echo() typer.echo()
typer.echo(pretty_machine_info(saved_vm_info)) typer.echo(pretty_machine_info(saved_vm_info))
except SporeStackUserError as e: except SporeStackUserError as e:
expiration = saved_vm_info["expiration"] expiration = saved_vm_info["expiration"]
human_expiration = time.strftime( human_expiration = time.strftime(
"%Y-%m-%d %H:%M:%S %z", time.localtime(expiration) "%Y-%m-%d %H:%M:%S %z", time.localtime(expiration)
) )
msg = hostname msg = hostname
msg += f" expired ({expiration} {human_expiration}): " msg += f" expired ({expiration} {human_expiration}): "
msg += str(e) msg += str(e)
typer.echo(msg) typer.echo(msg)
typer.echo() typer.echo()
def machine_exists(hostname: str) -> bool: def _get_machine_id(machine_id: str, hostname: str, token: str) -> str:
""" usage = "--hostname *OR* --machine-id must be set."
Check if the VM's JSON exists locally.
""" if machine_id != "" and hostname != "":
return server_info_path().joinpath(f"{hostname}.json").exists() 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() @server_cli.command()
def get_attribute(hostname: str, attribute: str) -> None: def info(hostname: str = "", machine_id: str = "", token: str = DEFAULT_TOKEN) -> 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:
""" """
Info on the VM Info on the VM
""" """
machine_info = get_machine_info(hostname) machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token)
machine_id = machine_info["machine_id"]
typer.echo( typer.echo(
api_client.info(machine_id=machine_id, api_endpoint=get_api_endpoint()).json() api_client.info(machine_id=machine_id, api_endpoint=get_api_endpoint()).json()
) )
@server_cli.command() @server_cli.command()
def start(hostname: str) -> None: def start(hostname: str = "", machine_id: str = "", token: str = DEFAULT_TOKEN) -> None:
""" """
Boots the VM. Boots the VM.
""" """
machine_info = get_machine_info(hostname) machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token)
machine_id = machine_info["machine_id"]
api_client.start(machine_id=machine_id, api_endpoint=get_api_endpoint()) api_client.start(machine_id=machine_id, api_endpoint=get_api_endpoint())
typer.echo(f"{hostname} started.") typer.echo(f"{hostname} started.")
@server_cli.command() @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. Immediately shuts down the VM.
""" """
machine_info = get_machine_info(hostname) machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token)
machine_id = machine_info["machine_id"]
api_client.stop(machine_id=machine_id, api_endpoint=get_api_endpoint()) api_client.stop(machine_id=machine_id, api_endpoint=get_api_endpoint())
typer.echo(f"{hostname} stopped.") typer.echo(f"{hostname} stopped.")
@server_cli.command() @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) Deletes/destroys the VM before expiration (no refunds/credits)
""" """
_destroy(hostname) machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token)
@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"]
api_client.destroy(machine_id=machine_id, api_endpoint=get_api_endpoint()) api_client.destroy(machine_id=machine_id, api_endpoint=get_api_endpoint())
# Also remove the .json file # Also remove the .json file
server_info_path().joinpath(f"{hostname}.json").unlink(missing_ok=True) 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() @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. 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. Will take a couple minutes to complete after the request is made.
""" """
machine_info = get_machine_info(hostname) machine_id = _get_machine_id(machine_id=machine_id, hostname=hostname, token=token)
machine_id = machine_info["machine_id"]
api_client.rebuild(machine_id=machine_id, api_endpoint=get_api_endpoint()) api_client.rebuild(machine_id=machine_id, api_endpoint=get_api_endpoint())
typer.echo(f"{hostname} rebuilding.") typer.echo(f"{hostname} rebuilding.")
@ -487,7 +499,21 @@ def flavors() -> None:
""" """
Returns available flavors. 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: def load_token(token: str) -> str:

View File

@ -1,3 +1,5 @@
import pytest
import typer
from _pytest.monkeypatch import MonkeyPatch from _pytest.monkeypatch import MonkeyPatch
from typer.testing import CliRunner from typer.testing import CliRunner
@ -42,3 +44,15 @@ def test_cli_api_endpoint(monkeypatch: MonkeyPatch) -> None:
result = runner.invoke(cli.cli, ["api-endpoint"]) result = runner.invoke(cli.cli, ["api-endpoint"])
assert result.output == TOR_ENDPOINT + " using socks5h://127.0.0.1:1337\n" assert result.output == TOR_ENDPOINT + " using socks5h://127.0.0.1:1337\n"
assert result.exit_code == 0 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")