This commit is contained in:
Administrator 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]
## [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

View File

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

View File

@ -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]

View File

@ -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,

View File

@ -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:

View File

@ -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")