remotivelabs-cli 0.5.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- remotivelabs/cli/__init__.py +0 -0
- remotivelabs/cli/api/cloud/tokens.py +62 -0
- remotivelabs/cli/broker/__init__.py +33 -0
- remotivelabs/cli/broker/defaults.py +1 -0
- remotivelabs/cli/broker/discovery.py +43 -0
- remotivelabs/cli/broker/export.py +92 -0
- remotivelabs/cli/broker/files.py +119 -0
- remotivelabs/cli/broker/lib/__about__.py +4 -0
- remotivelabs/cli/broker/lib/broker.py +625 -0
- remotivelabs/cli/broker/lib/client.py +224 -0
- remotivelabs/cli/broker/lib/helper.py +277 -0
- remotivelabs/cli/broker/lib/signalcreator.py +196 -0
- remotivelabs/cli/broker/license_flows.py +167 -0
- remotivelabs/cli/broker/licenses.py +98 -0
- remotivelabs/cli/broker/playback.py +117 -0
- remotivelabs/cli/broker/record.py +41 -0
- remotivelabs/cli/broker/recording_session/__init__.py +3 -0
- remotivelabs/cli/broker/recording_session/client.py +67 -0
- remotivelabs/cli/broker/recording_session/cmd.py +254 -0
- remotivelabs/cli/broker/recording_session/time.py +49 -0
- remotivelabs/cli/broker/scripting.py +129 -0
- remotivelabs/cli/broker/signals.py +220 -0
- remotivelabs/cli/broker/version.py +31 -0
- remotivelabs/cli/cloud/__init__.py +17 -0
- remotivelabs/cli/cloud/auth/__init__.py +3 -0
- remotivelabs/cli/cloud/auth/cmd.py +128 -0
- remotivelabs/cli/cloud/auth/login.py +283 -0
- remotivelabs/cli/cloud/auth_tokens.py +149 -0
- remotivelabs/cli/cloud/brokers.py +109 -0
- remotivelabs/cli/cloud/configs.py +109 -0
- remotivelabs/cli/cloud/licenses/__init__.py +0 -0
- remotivelabs/cli/cloud/licenses/cmd.py +14 -0
- remotivelabs/cli/cloud/organisations.py +112 -0
- remotivelabs/cli/cloud/projects.py +44 -0
- remotivelabs/cli/cloud/recordings.py +580 -0
- remotivelabs/cli/cloud/recordings_playback.py +274 -0
- remotivelabs/cli/cloud/resumable_upload.py +87 -0
- remotivelabs/cli/cloud/sample_recordings.py +25 -0
- remotivelabs/cli/cloud/service_account_tokens.py +62 -0
- remotivelabs/cli/cloud/service_accounts.py +72 -0
- remotivelabs/cli/cloud/storage/__init__.py +5 -0
- remotivelabs/cli/cloud/storage/cmd.py +76 -0
- remotivelabs/cli/cloud/storage/copy.py +86 -0
- remotivelabs/cli/cloud/storage/uri_or_path.py +45 -0
- remotivelabs/cli/cloud/uri.py +113 -0
- remotivelabs/cli/connect/__init__.py +0 -0
- remotivelabs/cli/connect/connect.py +118 -0
- remotivelabs/cli/connect/protopie/protopie.py +185 -0
- remotivelabs/cli/py.typed +0 -0
- remotivelabs/cli/remotive.py +123 -0
- remotivelabs/cli/settings/__init__.py +20 -0
- remotivelabs/cli/settings/config_file.py +113 -0
- remotivelabs/cli/settings/core.py +333 -0
- remotivelabs/cli/settings/migration/__init__.py +0 -0
- remotivelabs/cli/settings/migration/migrate_all_token_files.py +80 -0
- remotivelabs/cli/settings/migration/migrate_config_file.py +64 -0
- remotivelabs/cli/settings/migration/migrate_legacy_dirs.py +50 -0
- remotivelabs/cli/settings/migration/migrate_token_file.py +52 -0
- remotivelabs/cli/settings/migration/migration_tools.py +38 -0
- remotivelabs/cli/settings/state_file.py +67 -0
- remotivelabs/cli/settings/token_file.py +128 -0
- remotivelabs/cli/tools/__init__.py +0 -0
- remotivelabs/cli/tools/can/__init__.py +0 -0
- remotivelabs/cli/tools/can/can.py +78 -0
- remotivelabs/cli/tools/tools.py +9 -0
- remotivelabs/cli/topology/__init__.py +28 -0
- remotivelabs/cli/topology/all.py +322 -0
- remotivelabs/cli/topology/cli/__init__.py +3 -0
- remotivelabs/cli/topology/cli/run_in_docker.py +58 -0
- remotivelabs/cli/topology/cli/topology_cli.py +16 -0
- remotivelabs/cli/topology/cmd.py +130 -0
- remotivelabs/cli/topology/start_trial.py +134 -0
- remotivelabs/cli/typer/__init__.py +0 -0
- remotivelabs/cli/typer/typer_utils.py +27 -0
- remotivelabs/cli/utils/__init__.py +0 -0
- remotivelabs/cli/utils/console.py +99 -0
- remotivelabs/cli/utils/rest_helper.py +369 -0
- remotivelabs/cli/utils/time.py +11 -0
- remotivelabs/cli/utils/versions.py +120 -0
- remotivelabs_cli-0.5.0a1.dist-info/METADATA +51 -0
- remotivelabs_cli-0.5.0a1.dist-info/RECORD +84 -0
- remotivelabs_cli-0.5.0a1.dist-info/WHEEL +4 -0
- remotivelabs_cli-0.5.0a1.dist-info/entry_points.txt +3 -0
- remotivelabs_cli-0.5.0a1.dist-info/licenses/LICENSE +17 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from remotivelabs.cli.cloud import auth, brokers, configs, organisations, projects, recordings, sample_recordings, service_accounts, storage
|
|
2
|
+
from remotivelabs.cli.cloud.licenses.cmd import licenses
|
|
3
|
+
from remotivelabs.cli.typer import typer_utils
|
|
4
|
+
|
|
5
|
+
app = typer_utils.create_typer()
|
|
6
|
+
|
|
7
|
+
app.command(name="licenses", help="List licenses for an organization")(licenses)
|
|
8
|
+
|
|
9
|
+
app.add_typer(organisations.app, name="organizations", help="Manage organizations")
|
|
10
|
+
app.add_typer(projects.app, name="projects", help="Manage projects")
|
|
11
|
+
app.add_typer(auth.app, name="auth")
|
|
12
|
+
app.add_typer(brokers.app, name="brokers", help="Manage cloud broker lifecycle")
|
|
13
|
+
app.add_typer(recordings.app, name="recordings", help="Manage recordings")
|
|
14
|
+
app.add_typer(configs.app, name="signal-databases", help="Manage signal databases")
|
|
15
|
+
app.add_typer(storage.app, name="storage")
|
|
16
|
+
app.add_typer(service_accounts.app, name="service-accounts", help="Manage project service account keys")
|
|
17
|
+
app.add_typer(sample_recordings.app, name="samples", help="Manage sample recordings")
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from remotivelabs.cli.cloud import auth_tokens
|
|
11
|
+
from remotivelabs.cli.cloud.auth.login import login as do_login
|
|
12
|
+
from remotivelabs.cli.settings import settings
|
|
13
|
+
from remotivelabs.cli.typer import typer_utils
|
|
14
|
+
from remotivelabs.cli.utils.console import print_generic_error, print_generic_message, print_success, print_unformatted
|
|
15
|
+
from remotivelabs.cli.utils.rest_helper import RestHelper as Rest
|
|
16
|
+
|
|
17
|
+
console = Console(stderr=False)
|
|
18
|
+
|
|
19
|
+
HELP = """
|
|
20
|
+
Manage how you authenticate with our cloud platform
|
|
21
|
+
"""
|
|
22
|
+
app = typer_utils.create_typer(help=HELP)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command(name="login")
|
|
26
|
+
def login(browser: bool = typer.Option(default=True, help="Does not automatically open browser, instead shows a link")) -> None:
|
|
27
|
+
"""
|
|
28
|
+
Login to the cli using browser
|
|
29
|
+
|
|
30
|
+
If not able to open a browser it will show fallback to headless login and show a link that
|
|
31
|
+
users can copy into any browser when this is unsupported where running the cli - such as in docker,
|
|
32
|
+
virtual machine or ssh sessions.
|
|
33
|
+
"""
|
|
34
|
+
do_login(headless=not browser)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command()
|
|
38
|
+
def whoami() -> None:
|
|
39
|
+
"""
|
|
40
|
+
Validates authentication and fetches your account information
|
|
41
|
+
"""
|
|
42
|
+
Rest.handle_get("/api/whoami")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command()
|
|
46
|
+
def print_access_token(
|
|
47
|
+
account: str = typer.Option(None, help="Email of the account you want to print access token for, defaults to active"),
|
|
48
|
+
) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Print current active access token or the token for the specified account
|
|
51
|
+
"""
|
|
52
|
+
if not account:
|
|
53
|
+
active_token = settings.get_active_token() or os.getenv("REMOTIVE_CLOUD_ACCESS_TOKEN", None)
|
|
54
|
+
if not active_token:
|
|
55
|
+
print_generic_error("You have no active account")
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
print_generic_message(active_token)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
accounts = settings.list_accounts()
|
|
62
|
+
if account not in accounts:
|
|
63
|
+
print_generic_error(f"No account for {account} was found")
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
|
|
66
|
+
token_file_name = accounts[account].credentials_file
|
|
67
|
+
token_file = settings.get_token_file(token_file_name)
|
|
68
|
+
if not token_file:
|
|
69
|
+
print_generic_error(f"Token file for {account} could not be found")
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
|
|
72
|
+
print_generic_message(token_file.token)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def print_access_token_file() -> None:
|
|
76
|
+
"""
|
|
77
|
+
Print current active token and its metadata
|
|
78
|
+
"""
|
|
79
|
+
active_token_file = settings.get_active_token_file()
|
|
80
|
+
if not active_token_file:
|
|
81
|
+
print_generic_error("You have no active account")
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
|
|
84
|
+
print_generic_message(str(active_token_file))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command(name="deactivate")
|
|
88
|
+
def deactivate() -> None:
|
|
89
|
+
"""
|
|
90
|
+
Clears active account
|
|
91
|
+
"""
|
|
92
|
+
settings.clear_active_account()
|
|
93
|
+
print_success("Account no longer active")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.command("activate")
|
|
97
|
+
def activate(token_name: str = typer.Argument(None, help="Name, filename or path to a credentials file")) -> None:
|
|
98
|
+
"""
|
|
99
|
+
Set the active account
|
|
100
|
+
"""
|
|
101
|
+
auth_tokens.do_activate(token_name)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command(name="list")
|
|
105
|
+
def list() -> None:
|
|
106
|
+
"""
|
|
107
|
+
Lists available credential files on filesystem
|
|
108
|
+
|
|
109
|
+
TODO: Support output format
|
|
110
|
+
"""
|
|
111
|
+
accounts = settings.list_accounts()
|
|
112
|
+
|
|
113
|
+
table = Table("#", "Active", "Type", "Token", "Account", "Organization", "Created", "Expires")
|
|
114
|
+
for idx, (email, account) in enumerate(accounts.items(), start=1):
|
|
115
|
+
token_file = settings.get_token_file_by_email(email)
|
|
116
|
+
is_active = settings.is_active_account(email)
|
|
117
|
+
|
|
118
|
+
table.add_row(
|
|
119
|
+
f"[yellow]{idx}",
|
|
120
|
+
":white_check_mark:" if is_active else "",
|
|
121
|
+
"unknown" if not token_file else "user" if token_file.type == "authorized_user" else "sa",
|
|
122
|
+
token_file.name if token_file else "",
|
|
123
|
+
f"[bold]{email}[/bold]",
|
|
124
|
+
account.default_organization if account.default_organization else "",
|
|
125
|
+
str(token_file.created) if token_file else "",
|
|
126
|
+
str(token_file.expires) if token_file else "",
|
|
127
|
+
)
|
|
128
|
+
print_unformatted(table)
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import webbrowser
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
11
|
+
from threading import Thread
|
|
12
|
+
from typing import Any, Optional, Tuple
|
|
13
|
+
from urllib.parse import parse_qs, urlparse
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from typing_extensions import override
|
|
17
|
+
|
|
18
|
+
from remotivelabs.cli.cloud.auth_tokens import do_activate, prompt_to_set_org
|
|
19
|
+
from remotivelabs.cli.settings import settings
|
|
20
|
+
from remotivelabs.cli.settings.token_file import TokenFile
|
|
21
|
+
from remotivelabs.cli.utils.console import print_generic_error, print_newline, print_success, print_unformatted, print_url
|
|
22
|
+
from remotivelabs.cli.utils.rest_helper import RestHelper as Rest
|
|
23
|
+
|
|
24
|
+
httpd: HTTPServer
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def generate_pkce_pair() -> Tuple[str, str]:
|
|
28
|
+
"""
|
|
29
|
+
PKCE is used for all cli login flows, both headless and browser.
|
|
30
|
+
"""
|
|
31
|
+
code_verifier_ = secrets.token_urlsafe(64) # High-entropy string
|
|
32
|
+
code_challenge_ = base64.urlsafe_b64encode(hashlib.sha256(code_verifier_.encode("ascii")).digest()).rstrip(b"=").decode("ascii")
|
|
33
|
+
return code_verifier_, code_challenge_
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
code_verifier, code_challenge = generate_pkce_pair()
|
|
37
|
+
state = secrets.token_urlsafe(16)
|
|
38
|
+
|
|
39
|
+
short_lived_token = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class S(BaseHTTPRequestHandler):
|
|
43
|
+
def _set_response(self) -> None:
|
|
44
|
+
self.send_response(200)
|
|
45
|
+
self.send_header("Content-type", "text/html")
|
|
46
|
+
self.end_headers()
|
|
47
|
+
|
|
48
|
+
@override
|
|
49
|
+
def log_message(self, format: Any, *args: Any) -> None:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
# Please do not change this into lowercase!
|
|
53
|
+
@override
|
|
54
|
+
def do_GET(self) -> None: # type: ignore # noqa: PLR0912
|
|
55
|
+
self._set_response()
|
|
56
|
+
|
|
57
|
+
parsed_url = urlparse(self.path)
|
|
58
|
+
|
|
59
|
+
# Get query parameters as a dict
|
|
60
|
+
query_params = parse_qs(parsed_url.query)
|
|
61
|
+
|
|
62
|
+
# Example: Get the value of the "error" parameter if it exists
|
|
63
|
+
error_value = query_params.get("error", [None])[0]
|
|
64
|
+
path = self.path
|
|
65
|
+
auth_code = path[1:] # Remotive /
|
|
66
|
+
time.sleep(1)
|
|
67
|
+
httpd.server_close()
|
|
68
|
+
|
|
69
|
+
killerthread = Thread(target=httpd.shutdown)
|
|
70
|
+
killerthread.start()
|
|
71
|
+
if error_value is None:
|
|
72
|
+
res = Rest.handle_get(
|
|
73
|
+
f"/api/open/token?code={auth_code}&code_verifier={code_verifier}",
|
|
74
|
+
return_response=True,
|
|
75
|
+
skip_access_token=True,
|
|
76
|
+
allow_status_codes=[401, 400],
|
|
77
|
+
)
|
|
78
|
+
if res.status_code != 200:
|
|
79
|
+
print_generic_error(
|
|
80
|
+
"Failed to fetch token. Please try again, if the problem persists please reach out to support@remotivelabs.com"
|
|
81
|
+
)
|
|
82
|
+
self.wfile.write(
|
|
83
|
+
"Failed to fetch token. Please try again, if the problem persists please reach out to support@remotivelabs.com".encode(
|
|
84
|
+
"utf-8"
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
|
|
89
|
+
# TODO - This is written before we are done...
|
|
90
|
+
self.wfile.write(
|
|
91
|
+
"""Successfully setup CLI, you may close this window now. Return to your terminal to continue""".encode("utf-8")
|
|
92
|
+
)
|
|
93
|
+
access_token = res.json()["access_token"]
|
|
94
|
+
global short_lived_token # noqa: PLW0603
|
|
95
|
+
short_lived_token = access_token
|
|
96
|
+
|
|
97
|
+
else:
|
|
98
|
+
if error_value == "no_consent":
|
|
99
|
+
self.wfile.write(
|
|
100
|
+
"""
|
|
101
|
+
Authorization was cancelled.<br/>
|
|
102
|
+
To use RemotiveCLI, you need to grant access to your RemotiveCloud account.
|
|
103
|
+
<br/><br/>
|
|
104
|
+
Run `remotive cloud auth login` to try again.
|
|
105
|
+
""".encode("utf-8")
|
|
106
|
+
)
|
|
107
|
+
print_generic_error("You did not grant access to RemotiveCloud, login aborted")
|
|
108
|
+
elif error_value == "user_not_exists":
|
|
109
|
+
self.wfile.write(
|
|
110
|
+
"""
|
|
111
|
+
It seems like you do not have an account at RemotiveCloud with that user<br/>
|
|
112
|
+
To use RemotiveCLI you must first sign up at <a href="https://cloud.remotivelabs.com">cloud.remotivelabs.com</a>
|
|
113
|
+
and approve our agreements.<br/>
|
|
114
|
+
<br/><br/>
|
|
115
|
+
Once you are signed up, Run `remotive cloud auth login` again.
|
|
116
|
+
""".encode("utf-8")
|
|
117
|
+
)
|
|
118
|
+
print_generic_error(
|
|
119
|
+
"To use RemotiveCLI you must first sign up at https://cloud.remotivelabs.com and approve our agreements"
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
self.wfile.write(f"Unknown error {error_value}, please contact support@remotivelabs.com".encode("utf-8"))
|
|
123
|
+
print_generic_error(f"Unexpected error {error_value}, please contact support@remotivelabs.com")
|
|
124
|
+
sys.exit(1)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def prepare_local_webserver(server_class: type = HTTPServer, handler_class: type = S, port: Optional[int] = None) -> None:
|
|
128
|
+
if port is None:
|
|
129
|
+
env_val = os.getenv("REMOTIVE_LOGIN_CALLBACK_PORT" or "")
|
|
130
|
+
port = int(env_val) if env_val and env_val.isdigit() else 0
|
|
131
|
+
|
|
132
|
+
server_address = ("", port)
|
|
133
|
+
global httpd # noqa: PLW0603
|
|
134
|
+
httpd = server_class(server_address, handler_class)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def create_personal_token() -> None:
|
|
138
|
+
response = Rest.handle_post(
|
|
139
|
+
url="/api/me/keys",
|
|
140
|
+
return_response=True,
|
|
141
|
+
access_token=short_lived_token,
|
|
142
|
+
# TODO - add body with alias
|
|
143
|
+
)
|
|
144
|
+
token = response.json()
|
|
145
|
+
email = token["account"]["email"]
|
|
146
|
+
existing_file = settings.get_token_file_by_email(email=email)
|
|
147
|
+
if existing_file is not None:
|
|
148
|
+
res = Rest.handle_patch(
|
|
149
|
+
f"/api/me/keys/{existing_file.name}/revoke",
|
|
150
|
+
quiet=True,
|
|
151
|
+
access_token=short_lived_token,
|
|
152
|
+
allow_status_codes=[400, 404],
|
|
153
|
+
)
|
|
154
|
+
if res is not None and res.status_code == 204:
|
|
155
|
+
Rest.handle_delete(
|
|
156
|
+
f"/api/me/keys/{existing_file.name}",
|
|
157
|
+
quiet=True,
|
|
158
|
+
access_token=short_lived_token,
|
|
159
|
+
)
|
|
160
|
+
settings.remove_token_file(existing_file.name)
|
|
161
|
+
|
|
162
|
+
settings.add_personal_token(response.text, activate=True)
|
|
163
|
+
|
|
164
|
+
print_success("Logged in")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _do_prompt_to_use_existing_credentials() -> Optional[TokenFile]:
|
|
168
|
+
token_files = settings.list_personal_token_files()
|
|
169
|
+
if len(token_files) > 0:
|
|
170
|
+
should_select_token = typer.confirm(
|
|
171
|
+
"You have credentials available already, would you like to choose one of these instead?", default=True
|
|
172
|
+
)
|
|
173
|
+
if should_select_token:
|
|
174
|
+
token = do_activate(token_name=None)
|
|
175
|
+
if token is not None:
|
|
176
|
+
return token
|
|
177
|
+
# TODO - fix so this is not needed
|
|
178
|
+
sys.exit(0)
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def login(headless: bool = False) -> bool: # noqa: C901, PLR0915
|
|
183
|
+
"""
|
|
184
|
+
Initiate login
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
newly_activated_token_file = _do_prompt_to_use_existing_credentials()
|
|
188
|
+
if newly_activated_token_file:
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
prepare_local_webserver()
|
|
192
|
+
|
|
193
|
+
def force_use_webserver_callback() -> bool:
|
|
194
|
+
env_val = os.getenv("REMOTIVE_LOGIN_FORCE_CALLBACK" or "no")
|
|
195
|
+
if env_val and env_val == "yes":
|
|
196
|
+
return True
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
def login_with_callback_but_copy_url() -> None:
|
|
200
|
+
"""
|
|
201
|
+
This will print a url the will trigger a callback later so the webserver must be up and running.
|
|
202
|
+
"""
|
|
203
|
+
print_unformatted("Copy the following link in a browser to login to cloud, and complete the sign-in prompts:")
|
|
204
|
+
print_newline()
|
|
205
|
+
|
|
206
|
+
url = (
|
|
207
|
+
f"{Rest.get_base_frontend_url()}/login"
|
|
208
|
+
f"?state={state}"
|
|
209
|
+
f"&cli_version={Rest.get_cli_version()}"
|
|
210
|
+
f"&response_type=code"
|
|
211
|
+
f"&code_challenge={code_challenge}"
|
|
212
|
+
f"&redirect_uri=http://localhost:{httpd.server_address[1]}"
|
|
213
|
+
)
|
|
214
|
+
print_url(url)
|
|
215
|
+
httpd.serve_forever()
|
|
216
|
+
|
|
217
|
+
def login_headless() -> None:
|
|
218
|
+
"""
|
|
219
|
+
Full headless, opens a browser and expects a auth code to be entered and exchanged for the token
|
|
220
|
+
"""
|
|
221
|
+
print_unformatted("Copy the following link in a browser to login to cloud, and complete the sign-in prompts:")
|
|
222
|
+
print_newline()
|
|
223
|
+
|
|
224
|
+
url = (
|
|
225
|
+
f"{Rest.get_base_frontend_url()}/login"
|
|
226
|
+
f"?state={state}"
|
|
227
|
+
f"&cli_version={Rest.get_cli_version()}"
|
|
228
|
+
f"&response_type=code"
|
|
229
|
+
f"&code_challenge={code_challenge}"
|
|
230
|
+
)
|
|
231
|
+
print_url(url)
|
|
232
|
+
|
|
233
|
+
code = typer.prompt(
|
|
234
|
+
"Once finished, enter the verification code provided in your browser",
|
|
235
|
+
hide_input=False,
|
|
236
|
+
)
|
|
237
|
+
res = Rest.handle_get(
|
|
238
|
+
f"/api/open/token?code={code}&code_verifier={code_verifier}",
|
|
239
|
+
return_response=True,
|
|
240
|
+
skip_access_token=True,
|
|
241
|
+
allow_status_codes=[401],
|
|
242
|
+
)
|
|
243
|
+
if res.status_code == 401:
|
|
244
|
+
print_generic_error(
|
|
245
|
+
"Failed to fetch token. Please try again, if the problem persists please reach out to support@remotivelabs.com"
|
|
246
|
+
)
|
|
247
|
+
sys.exit(1)
|
|
248
|
+
access_token = res.json()["access_token"]
|
|
249
|
+
global short_lived_token # noqa: PLW0603
|
|
250
|
+
short_lived_token = access_token
|
|
251
|
+
create_personal_token()
|
|
252
|
+
prompt_to_set_org()
|
|
253
|
+
|
|
254
|
+
if headless and not force_use_webserver_callback():
|
|
255
|
+
login_headless()
|
|
256
|
+
elif headless and force_use_webserver_callback():
|
|
257
|
+
login_with_callback_but_copy_url()
|
|
258
|
+
else:
|
|
259
|
+
could_open = webbrowser.open_new_tab(
|
|
260
|
+
f"{Rest.get_base_frontend_url()}/login"
|
|
261
|
+
f"?state={state}"
|
|
262
|
+
f"&cli_version={Rest.get_cli_version()}"
|
|
263
|
+
f"&response_type=code"
|
|
264
|
+
f"&code_challenge={code_challenge}"
|
|
265
|
+
f"&redirect_uri=http://localhost:{httpd.server_address[1]}"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if not could_open:
|
|
269
|
+
print_generic_error(
|
|
270
|
+
"Could not open a browser on this machine, this is likely because you are in an environment where no browser is available"
|
|
271
|
+
)
|
|
272
|
+
print_newline()
|
|
273
|
+
if force_use_webserver_callback():
|
|
274
|
+
login_with_callback_but_copy_url()
|
|
275
|
+
create_personal_token()
|
|
276
|
+
else:
|
|
277
|
+
login_headless()
|
|
278
|
+
else:
|
|
279
|
+
httpd.serve_forever()
|
|
280
|
+
create_personal_token()
|
|
281
|
+
prompt_to_set_org()
|
|
282
|
+
|
|
283
|
+
return True
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from remotivelabs.cli.cloud.organisations import do_select_default_org
|
|
9
|
+
from remotivelabs.cli.settings import settings
|
|
10
|
+
from remotivelabs.cli.settings.token_file import TokenFile
|
|
11
|
+
from remotivelabs.cli.utils.console import (
|
|
12
|
+
print_generic_error,
|
|
13
|
+
print_generic_message,
|
|
14
|
+
print_hint,
|
|
15
|
+
print_newline,
|
|
16
|
+
print_success,
|
|
17
|
+
print_unformatted,
|
|
18
|
+
)
|
|
19
|
+
from remotivelabs.cli.utils.rest_helper import RestHelper as Rest
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _prompt_choice( # noqa: C901
|
|
23
|
+
choices: List[TokenFile],
|
|
24
|
+
skip_prompt: bool = False,
|
|
25
|
+
info_message: Optional[str] = None,
|
|
26
|
+
) -> Optional[TokenFile]:
|
|
27
|
+
accounts = settings.list_accounts()
|
|
28
|
+
|
|
29
|
+
table = Table("#", "Active", "Type", "Token ID", "Account", "Created", "Expires")
|
|
30
|
+
|
|
31
|
+
included_tokens: list[TokenFile] = []
|
|
32
|
+
excluded_tokens: list[TokenFile] = []
|
|
33
|
+
|
|
34
|
+
for token in choices:
|
|
35
|
+
account = accounts.get(token.account.email)
|
|
36
|
+
if account and account.credentials_file:
|
|
37
|
+
token_file = settings.get_token_file(account.credentials_file)
|
|
38
|
+
if token_file and token_file.name in (token.name or ""):
|
|
39
|
+
included_tokens.append(token)
|
|
40
|
+
else:
|
|
41
|
+
excluded_tokens.append(token)
|
|
42
|
+
else:
|
|
43
|
+
excluded_tokens.append(token)
|
|
44
|
+
|
|
45
|
+
if len(included_tokens) == 0:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
included_tokens.sort(key=lambda token: token.created, reverse=True)
|
|
49
|
+
|
|
50
|
+
active_token = settings.get_active_token_file()
|
|
51
|
+
active_token_index = None
|
|
52
|
+
for idx, choice in enumerate(included_tokens, start=1):
|
|
53
|
+
is_active = active_token is not None and active_token.name == choice.name
|
|
54
|
+
active_token_index = idx if is_active else active_token_index
|
|
55
|
+
|
|
56
|
+
table.add_row(
|
|
57
|
+
f"[yellow]{idx}",
|
|
58
|
+
":white_check_mark:" if is_active else "",
|
|
59
|
+
"user" if choice.type == "authorized_user" else "sa",
|
|
60
|
+
choice.name,
|
|
61
|
+
f"[bold]{choice.account.email if choice.account else 'unknown'}[/bold]",
|
|
62
|
+
str(choice.created),
|
|
63
|
+
str(choice.expires),
|
|
64
|
+
)
|
|
65
|
+
print_unformatted(table)
|
|
66
|
+
print_unformatted(
|
|
67
|
+
":point_right: To get the access token for your activated account, use [bold]remotive cloud auth print-access-token[/bold]"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if skip_prompt:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
print_newline()
|
|
74
|
+
if info_message:
|
|
75
|
+
print_generic_message(info_message)
|
|
76
|
+
|
|
77
|
+
selection = typer.prompt(
|
|
78
|
+
f"Enter the number(# 1-{len(included_tokens)}) of the account to select (q to quit)",
|
|
79
|
+
default=f"{active_token_index}" if active_token_index is not None else None,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if selection == "q":
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
index = int(selection) - 1
|
|
86
|
+
if 0 <= index < len(included_tokens):
|
|
87
|
+
return included_tokens[index]
|
|
88
|
+
raise ValueError
|
|
89
|
+
except ValueError:
|
|
90
|
+
typer.echo("Invalid choice, please try again")
|
|
91
|
+
return _prompt_choice(included_tokens, skip_prompt, info_message)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def prompt_to_set_org() -> None:
|
|
95
|
+
active_account = settings.get_active_account()
|
|
96
|
+
if active_account and not active_account.default_organization:
|
|
97
|
+
set_default_organisation = typer.confirm(
|
|
98
|
+
"You have not set a default organization\nWould you like to choose one now?",
|
|
99
|
+
abort=False,
|
|
100
|
+
default=True,
|
|
101
|
+
)
|
|
102
|
+
if set_default_organisation:
|
|
103
|
+
do_select_default_org(get=False)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def do_activate(token_name: Optional[str]) -> Optional[TokenFile]:
|
|
107
|
+
if token_name:
|
|
108
|
+
token_file = settings.get_token_file(token_name)
|
|
109
|
+
if not token_file:
|
|
110
|
+
print_generic_error(f"Token with filename or name {token_name} could not be found")
|
|
111
|
+
return None
|
|
112
|
+
return settings.activate_token(token_file)
|
|
113
|
+
|
|
114
|
+
token_files = settings.list_personal_token_files()
|
|
115
|
+
token_files.extend(settings.list_service_account_token_files())
|
|
116
|
+
if len(token_files) > 0:
|
|
117
|
+
token_selected = list_and_select_personal_token(include_service_accounts=True)
|
|
118
|
+
if token_selected is not None:
|
|
119
|
+
is_logged_in = Rest.has_access("/api/whoami")
|
|
120
|
+
if not is_logged_in:
|
|
121
|
+
print_generic_error("Could not access RemotiveCloud with selected token")
|
|
122
|
+
else:
|
|
123
|
+
print_success("Access to RemotiveCloud granted")
|
|
124
|
+
# Only select default if activate was done with selection and successful
|
|
125
|
+
# and not SA since SA cannot list available organizations
|
|
126
|
+
if token_selected.type == "authorized_user":
|
|
127
|
+
prompt_to_set_org()
|
|
128
|
+
return token_selected
|
|
129
|
+
|
|
130
|
+
print_hint("No credentials available, login to activate credentials")
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def list_and_select_personal_token(
|
|
135
|
+
skip_prompt: bool = False,
|
|
136
|
+
include_service_accounts: bool = False,
|
|
137
|
+
info_message: Optional[str] = None,
|
|
138
|
+
) -> Optional[TokenFile]:
|
|
139
|
+
personal_tokens = settings.list_personal_token_files()
|
|
140
|
+
|
|
141
|
+
if include_service_accounts:
|
|
142
|
+
sa_tokens = settings.list_service_account_token_files()
|
|
143
|
+
personal_tokens.extend(sa_tokens)
|
|
144
|
+
|
|
145
|
+
selected_token = _prompt_choice(personal_tokens, skip_prompt=skip_prompt, info_message=info_message)
|
|
146
|
+
if selected_token is not None:
|
|
147
|
+
settings.activate_token(selected_token)
|
|
148
|
+
|
|
149
|
+
return selected_token
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import signal as os_signal
|
|
6
|
+
from typing import Any, Union
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
import typer
|
|
10
|
+
import websocket
|
|
11
|
+
|
|
12
|
+
from remotivelabs.cli.typer import typer_utils
|
|
13
|
+
from remotivelabs.cli.utils.console import print_generic_message
|
|
14
|
+
from remotivelabs.cli.utils.rest_helper import RestHelper as Rest
|
|
15
|
+
|
|
16
|
+
app = typer_utils.create_typer()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command("list", help="Lists brokers in project")
|
|
20
|
+
def list(project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT")) -> None:
|
|
21
|
+
Rest.handle_get(f"/api/project/{project}/brokers")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command("describe", help="Shows details about a specific broker")
|
|
25
|
+
def describe(name: str, project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT")) -> None:
|
|
26
|
+
Rest.handle_get(f"/api/project/{project}/brokers/{name}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command("delete", help="Stops and deletes a broker from project")
|
|
30
|
+
def stop(name: str, project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT")) -> None:
|
|
31
|
+
Rest.handle_delete(f"/api/project/{project}/brokers/{name}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command(help="Deletes your personal broker from project")
|
|
35
|
+
def delete_personal(project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT")) -> None:
|
|
36
|
+
Rest.handle_delete(f"/api/project/{project}/brokers/personal")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def do_start(name: str, project: str, api_key: str, tag: str, return_response: bool = False) -> Union[requests.Response, None]:
|
|
40
|
+
if tag == "":
|
|
41
|
+
tag_to_use = None
|
|
42
|
+
else:
|
|
43
|
+
tag_to_use = tag
|
|
44
|
+
|
|
45
|
+
if api_key == "":
|
|
46
|
+
body = {"size": "S", "tag": tag_to_use}
|
|
47
|
+
else:
|
|
48
|
+
body = {"size": "S", "apiKey": api_key, "tag": tag_to_use}
|
|
49
|
+
return Rest.handle_post(
|
|
50
|
+
f"/api/project/{project}/brokers/{name}",
|
|
51
|
+
body=json.dumps(body),
|
|
52
|
+
return_response=return_response,
|
|
53
|
+
progress_label=f"Starting {name}...",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command(name="create", help="Create a broker in project")
|
|
58
|
+
def start(
|
|
59
|
+
name: str,
|
|
60
|
+
project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
|
|
61
|
+
tag: str = typer.Option("", help="Optional specific tag/version"),
|
|
62
|
+
silent: bool = typer.Option(False, help="Optional specific tag/version"),
|
|
63
|
+
api_key: str = typer.Option("", help="Start with your own api-key"),
|
|
64
|
+
) -> None:
|
|
65
|
+
do_start(name, project, api_key, tag, return_response=silent)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command(name="logs")
|
|
69
|
+
def logs(
|
|
70
|
+
broker_name: str = typer.Argument(..., help="Broker name to see logs for"),
|
|
71
|
+
tail: bool = typer.Option(False, help="Tail the broker log"),
|
|
72
|
+
history_minutes: str = typer.Option(10, help="History in minutes minutes to fetch"),
|
|
73
|
+
project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
|
|
74
|
+
) -> None:
|
|
75
|
+
"""
|
|
76
|
+
Exposes broker logs history or real-time tail of the broker.
|
|
77
|
+
|
|
78
|
+
When using --tail option, --history always skipped even if supplied
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def exit_on_ctrlc(_sig: Any, _frame: Any) -> None:
|
|
82
|
+
wsapp.close()
|
|
83
|
+
os._exit(0)
|
|
84
|
+
|
|
85
|
+
os_signal.signal(os_signal.SIGINT, exit_on_ctrlc)
|
|
86
|
+
|
|
87
|
+
def on_message(_wsapp: Any, message: str) -> None:
|
|
88
|
+
# TODO: use log instead of print for debug information?
|
|
89
|
+
print_generic_message(message)
|
|
90
|
+
|
|
91
|
+
def on_error(_wsapp: Any, err: str) -> None:
|
|
92
|
+
# TODO: use log instead of print for debug information?
|
|
93
|
+
print_generic_message(f"Error encountered: {err}")
|
|
94
|
+
|
|
95
|
+
Rest.ensure_auth_token()
|
|
96
|
+
# This will work with both http -> ws and https -> wss
|
|
97
|
+
ws_url = Rest.get_base_url().replace("http", "ws")
|
|
98
|
+
|
|
99
|
+
if tail:
|
|
100
|
+
q = "?tail=yes"
|
|
101
|
+
elif history_minutes != "10":
|
|
102
|
+
q = f"?history={history_minutes}"
|
|
103
|
+
else:
|
|
104
|
+
q = ""
|
|
105
|
+
|
|
106
|
+
wsapp = websocket.WebSocketApp(
|
|
107
|
+
f"{ws_url}/api/project/{project}/brokers/{broker_name}/logs{q}", header=Rest.get_headers(), on_message=on_message, on_error=on_error
|
|
108
|
+
)
|
|
109
|
+
wsapp.run_forever()
|