remotivelabs-cli 0.0.34__tar.gz → 0.0.36__tar.gz

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.
Files changed (54) hide show
  1. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/PKG-INFO +2 -2
  2. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/broker/lib/broker.py +2 -2
  3. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/auth/cmd.py +27 -5
  4. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/auth/login.py +10 -2
  5. remotivelabs_cli-0.0.36/cli/cloud/auth_tokens.py +32 -0
  6. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/configs.py +11 -0
  7. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/recordings.py +14 -23
  8. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/rest_helper.py +8 -9
  9. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/service_account_tokens.py +8 -31
  10. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/storage/__init__.py +1 -1
  11. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/storage/cmd.py +1 -1
  12. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/storage/copy.py +2 -2
  13. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/storage/uri_or_path.py +1 -1
  14. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/connect/protopie/protopie.py +2 -2
  15. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/remotive.py +2 -0
  16. remotivelabs_cli-0.0.36/cli/settings/__init__.py +5 -0
  17. remotivelabs_cli-0.0.36/cli/settings/cmd.py +71 -0
  18. remotivelabs_cli-0.0.36/cli/settings/core.py +261 -0
  19. remotivelabs_cli-0.0.36/cli/settings/token_file.py +22 -0
  20. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/pyproject.toml +2 -2
  21. remotivelabs_cli-0.0.34/cli/cloud/auth_tokens.py +0 -143
  22. remotivelabs_cli-0.0.34/cli/settings.py +0 -64
  23. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/LICENSE +0 -0
  24. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/README.md +0 -0
  25. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/__init__.py +0 -0
  26. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/broker/brokers.py +0 -0
  27. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/broker/export.py +0 -0
  28. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/broker/files.py +0 -0
  29. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/broker/lib/__about__.py +0 -0
  30. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/broker/license_flows.py +0 -0
  31. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/broker/licenses.py +0 -0
  32. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/broker/playback.py +0 -0
  33. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/broker/record.py +0 -0
  34. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/broker/scripting.py +0 -0
  35. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/broker/signals.py +0 -0
  36. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/__init__.py +0 -0
  37. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/auth/__init__.py +0 -0
  38. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/brokers.py +0 -0
  39. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/cloud_cli.py +0 -0
  40. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/organisations.py +0 -0
  41. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/projects.py +0 -0
  42. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/recordings_playback.py +0 -0
  43. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/resumable_upload.py +0 -0
  44. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/sample_recordings.py +0 -0
  45. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/cloud/service_accounts.py +0 -0
  46. {remotivelabs_cli-0.0.34/cli/cloud/storage → remotivelabs_cli-0.0.36/cli/cloud}/uri.py +0 -0
  47. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/connect/__init__.py +0 -0
  48. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/connect/connect.py +0 -0
  49. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/errors.py +0 -0
  50. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/tools/__init__.py +0 -0
  51. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/tools/can/RemotiveLabs.can1.log +0 -0
  52. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/tools/can/__init__.py +0 -0
  53. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/tools/can/can.py +0 -0
  54. {remotivelabs_cli-0.0.34 → remotivelabs_cli-0.0.36}/cli/tools/tools.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: remotivelabs-cli
3
- Version: 0.0.34
3
+ Version: 0.0.36
4
4
  Summary: CLI for operating RemotiveCloud and RemotiveBroker
5
5
  Author: Johan Rask
6
6
  Author-email: johan.rask@remotivelabs.com
7
- Requires-Python: >=3.8
7
+ Requires-Python: >=3.8,<4
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: Programming Language :: Python :: 3.8
10
10
  Classifier: Programming Language :: Python :: 3.9
@@ -21,8 +21,8 @@ import typer
21
21
  from google.protobuf.json_format import MessageToDict
22
22
  from rich.console import Console
23
23
 
24
- from cli import settings
25
24
  from cli.errors import ErrorPrinter
25
+ from cli.settings import settings
26
26
 
27
27
  err_console = Console(stderr=True)
28
28
 
@@ -51,7 +51,7 @@ class Broker:
51
51
 
52
52
  if api_key is None or api_key == "":
53
53
  if url.startswith("https"):
54
- self.intercept_channel = br.create_channel(url, None, settings.read_secret_token())
54
+ self.intercept_channel = br.create_channel(url, None, settings.get_active_token())
55
55
  # TODO - Temporary solution to print proper error message, remove ENV once api-key is gone
56
56
  os.environ["ACCESS_TOKEN"] = "true"
57
57
  else:
@@ -1,8 +1,11 @@
1
+ import sys
2
+
1
3
  import typer
2
4
 
3
- from cli import settings
4
5
  from cli.cloud.auth.login import login as do_login
5
6
  from cli.cloud.rest_helper import RestHelper as Rest
7
+ from cli.errors import ErrorPrinter
8
+ from cli.settings import TokenNotFoundError, settings
6
9
 
7
10
  from .. import auth_tokens
8
11
 
@@ -29,18 +32,37 @@ def whoami() -> None:
29
32
  """
30
33
  Validates authentication and fetches your user information
31
34
  """
32
- Rest.handle_get("/api/whoami")
35
+ try:
36
+ Rest.handle_get("/api/whoami")
37
+ except TokenNotFoundError as e:
38
+ ErrorPrinter.print_hint(str(e))
39
+ sys.exit(1)
33
40
 
34
41
 
35
42
  @app.command()
36
43
  def print_access_token() -> None:
37
44
  """
38
- Print current active access token
45
+ Print current active token
46
+ """
47
+ try:
48
+ print(settings.get_active_token())
49
+ except TokenNotFoundError as e:
50
+ ErrorPrinter.print_hint(str(e))
51
+ sys.exit(1)
52
+
53
+
54
+ def print_access_token_file() -> None:
55
+ """
56
+ Print current active token and its metadata
39
57
  """
40
- print(settings.read_secret_token())
58
+ try:
59
+ print(settings.get_active_token_file())
60
+ except TokenNotFoundError as e:
61
+ ErrorPrinter.print_hint(str(e))
62
+ sys.exit(1)
41
63
 
42
64
 
43
65
  @app.command(help="Clear access token")
44
66
  def logout() -> None:
45
- settings.clear_secret_token()
67
+ settings.clear_active_token()
46
68
  print("Access token removed")
@@ -1,3 +1,4 @@
1
+ import datetime
1
2
  import time
2
3
  import webbrowser
3
4
  from http.server import BaseHTTPRequestHandler, HTTPServer
@@ -6,8 +7,9 @@ from typing import Any
6
7
 
7
8
  from typing_extensions import override
8
9
 
9
- from cli import settings
10
10
  from cli.cloud.rest_helper import RestHelper as Rest
11
+ from cli.settings import settings
12
+ from cli.settings import token_file as tf
11
13
 
12
14
  httpd: HTTPServer
13
15
 
@@ -35,7 +37,13 @@ class S(BaseHTTPRequestHandler):
35
37
  killerthread = Thread(target=httpd.shutdown)
36
38
  killerthread.start()
37
39
 
38
- settings.write_secret_token(path[1:])
40
+ token = tf.TokenFile(
41
+ name="CLI_login_token",
42
+ token=path[1:],
43
+ created=str(datetime.datetime.now().isoformat()),
44
+ expires="unknown",
45
+ )
46
+ settings.add_and_activate_short_lived_cli_token(tf.dumps(token))
39
47
  print("Successfully logged on, you are ready to go with cli")
40
48
 
41
49
 
@@ -0,0 +1,32 @@
1
+ import typer
2
+
3
+ from cli.settings import settings
4
+
5
+ from .rest_helper import RestHelper as Rest
6
+
7
+ app = typer.Typer()
8
+
9
+
10
+ # TODO: add add interactive flag to set target directory # pylint: disable=fixme
11
+ @app.command(name="create", help="Create and download a new personal access token")
12
+ def create(activate: bool = typer.Option(False, help="Activate the token for use after download")) -> None: # pylint: disable=W0621
13
+ response = Rest.handle_post(url="/api/me/keys", return_response=True)
14
+ pat = settings.add_personal_token(response.text)
15
+ print(f"Personal access token added: {pat.name}")
16
+
17
+ if not activate:
18
+ print(f"Use 'remotive cloud auth tokens activate {pat.name}' to use this access token from cli")
19
+ else:
20
+ settings.activate_token(pat.name)
21
+ print("Token file activated and ready for use")
22
+ print("\033[93m This file contains secrets and must be kept safe")
23
+
24
+
25
+ @app.command(name="list", help="List personal access tokens")
26
+ def list_tokens() -> None:
27
+ Rest.handle_get("/api/me/keys")
28
+
29
+
30
+ @app.command(name="revoke", help="Revoke personal access token")
31
+ def revoke(name: str = typer.Argument(help="Access token name")) -> None:
32
+ Rest.handle_delete(f"/api/me/keys/{name}")
@@ -59,6 +59,17 @@ def upload(
59
59
  )
60
60
 
61
61
 
62
+ @app.command()
63
+ def describe(
64
+ signal_db_file: str = typer.Argument("", help="Signal database file"),
65
+ project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
66
+ ) -> None:
67
+ """
68
+ Shows all metadata related to this signal database
69
+ """
70
+ Rest.handle_get(f"/api/project/{project}/files/config/{signal_db_file}")
71
+
72
+
62
73
  @app.command()
63
74
  def download(
64
75
  signal_db_file: str = typer.Argument("", help="Signal database file"),
@@ -1,5 +1,3 @@
1
- from __future__ import annotations
2
-
3
1
  import glob
4
2
  import json
5
3
  import os
@@ -16,7 +14,9 @@ import grpc
16
14
  import requests
17
15
  import typer
18
16
  from rich.progress import Progress, SpinnerColumn, TaskID, TextColumn, track
17
+ from typing_extensions import Annotated
19
18
 
19
+ from cli.cloud.uri import URI
20
20
  from cli.errors import ErrorPrinter
21
21
 
22
22
  from ..broker.lib.broker import Broker
@@ -33,6 +33,10 @@ def uid(p: Any) -> Any:
33
33
  return p["uid"]
34
34
 
35
35
 
36
+ # ruff: noqa: FA100
37
+ # ruff: noqa: C901
38
+
39
+
36
40
  # to be used in options
37
41
  # autocompletion=project_names)
38
42
  def project_names() -> Any:
@@ -83,25 +87,19 @@ def describe(
83
87
 
84
88
  @app.command(name="import")
85
89
  def import_as_recording(
90
+ uri: Annotated[URI, typer.Argument(help="Remote storage path", parser=URI, show_default=False)],
86
91
  project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
87
- path: str = typer.Argument(default=..., help="Remote storage path to file to delete"),
88
92
  ) -> None:
89
93
  """
90
94
  Imports a file from Storage as a recording.
95
+
91
96
  NOTE that Storage is not yet available to all customers
92
97
  """
93
-
94
- if path.startswith("rcs://"):
95
- final_path = __check_rcs_path(path)
96
- else:
97
- ErrorPrinter.print_hint("Path must start with rcs://")
98
- sys.exit(1)
99
-
100
98
  Rest.handle_post(
101
99
  url=f"/api/project/{project}/files/recording",
102
100
  return_response=True,
103
- progress_label=f"Importing {final_path}...",
104
- body=json.dumps({"path": final_path}),
101
+ progress_label=f"Importing {uri.path}...",
102
+ body=json.dumps({"path": uri.path}),
105
103
  )
106
104
 
107
105
  ErrorPrinter.print_hint(
@@ -109,15 +107,6 @@ def import_as_recording(
109
107
  )
110
108
 
111
109
 
112
- # Copied from filestorage
113
- def __check_rcs_path(path: str) -> str:
114
- rcs_path = path.replace("rcs://", "/")
115
- if rcs_path.startswith("/."):
116
- ErrorPrinter.print_hint("Invalid path")
117
- sys.exit(1)
118
- return rcs_path
119
-
120
-
121
110
  def do_start(name: str, project: str, api_key: str, return_response: bool = False) -> requests.Response:
122
111
  body = {"size": "S"}
123
112
  if not api_key:
@@ -252,7 +241,7 @@ def upload( # noqa: C901
252
241
  help="Path to recording file to upload",
253
242
  ),
254
243
  project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
255
- recording_session: str = typer.Option(default=None, help="Optional existing recording to upload file to"),
244
+ recording_session: Optional[str] = typer.Option(default=None, help="Optional existing recording to upload file to"),
256
245
  ) -> None:
257
246
  # pylint: disable=R0912,R0914,R0915
258
247
  """
@@ -540,7 +529,9 @@ def stop(
540
529
 
541
530
 
542
531
  # pylint: disable-next=C0301
543
- def _do_change_playback_mode(mode: str, recording_session: str, broker_name: str, project: str, seconds: int | None = None) -> None: # noqa: C901
532
+ def _do_change_playback_mode(
533
+ mode: str, recording_session: str, broker_name: Optional[str], project: str, seconds: Optional[int] = None
534
+ ) -> None: # noqa: C901
544
535
  # pylint: disable=R0912,R0914,R0915
545
536
  response = Rest.handle_get(f"/api/project/{project}/files/recording/{recording_session}", return_response=True)
546
537
  if response is None:
@@ -15,7 +15,8 @@ from requests.exceptions import JSONDecodeError
15
15
  from rich.console import Console
16
16
  from rich.progress import Progress, SpinnerColumn, TextColumn, wrap_file
17
17
 
18
- from cli import settings
18
+ from cli.errors import ErrorPrinter
19
+ from cli.settings import TokenNotFoundError, settings
19
20
 
20
21
  err_console = Console(stderr=True)
21
22
 
@@ -81,14 +82,12 @@ class RestHelper:
81
82
  # print('You must first set the organisation id to use: export REMOTIVE_CLOUD_ORGANISATION=organisationUid')
82
83
  # raise typer.Exit()
83
84
  # org = os.environ["REMOTIVE_CLOUD_ORGANISATION"]
84
-
85
- # if not exists(str(Path.home()) + "/.config/.remotive/cloud.secret.token"):
86
- # print("Access token not found, please login first")
87
- # raise typer.Exit()
88
-
89
- # f = open(str(Path.home()) + "/.config/.remotive/cloud.secret.token", "r")
90
- token = settings.read_secret_token()
91
- # os.environ['REMOTIVE_CLOUD_AUTH_TOKEN'] = token
85
+ # os.environ['REMOTIVE_CLOUD_AUTH_TOKEN'] = token
86
+ try:
87
+ token = settings.get_active_token()
88
+ except TokenNotFoundError:
89
+ ErrorPrinter.print_hint("you are not logged in, please login using [green]remotive cloud auth login[/green]")
90
+ sys.exit(1)
92
91
 
93
92
  RestHelper.__headers["Authorization"] = "Bearer " + token.strip()
94
93
  RestHelper.__headers["User-Agent"] = f"remotivelabs-cli {RestHelper.get_cli_version()}"
@@ -1,16 +1,16 @@
1
1
  import json
2
- from pathlib import Path
3
2
 
4
3
  import typer
5
4
 
6
- from cli import settings
5
+ from cli.settings import settings
7
6
 
8
7
  from .rest_helper import RestHelper as Rest
9
8
 
10
9
  app = typer.Typer()
11
10
 
12
11
 
13
- @app.command(name="create", help="Create new access token")
12
+ # TODO: add add interactive flag to set target directory # pylint: disable=fixme
13
+ @app.command(name="create", help="Create and download a new service account access token")
14
14
  def create(
15
15
  expire_in_days: int = typer.Option(default=365, help="Number of this token is valid"),
16
16
  service_account: str = typer.Option(..., help="Service account name"),
@@ -22,35 +22,19 @@ def create(
22
22
  body=json.dumps({"daysUntilExpiry": expire_in_days}),
23
23
  )
24
24
 
25
- if response is None:
26
- return
25
+ sat = settings.add_service_account_token(service_account, response.text)
26
+ print(f"Service account access token added: {sat.name}")
27
+ print("\033[93m This file contains secrets and must be kept safe")
27
28
 
28
- if response.status_code == 200:
29
- name = response.json()["name"]
30
- write_sa_token(service_account, name, response.text)
31
- else:
32
- print(f"Got status code: {response.status_code}")
33
- print(response.text)
34
29
 
35
-
36
- @app.command(name="list", help="List service-account access tokens")
37
- def list_keys(
30
+ @app.command(name="list", help="List service account access tokens")
31
+ def list_tokens(
38
32
  service_account: str = typer.Option(..., help="Service account name"),
39
33
  project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
40
34
  ) -> None:
41
35
  Rest.handle_get(f"/api/project/{project}/admin/accounts/{service_account}/keys")
42
36
 
43
37
 
44
- @app.command(name="list-files")
45
- def list_files() -> None:
46
- """
47
- List personal access token files in remotivelabs config directory
48
- """
49
- sa_files = settings.list_service_account_token_files()
50
- for file in sa_files:
51
- print(file)
52
-
53
-
54
38
  @app.command(name="revoke", help="Revoke service account access token")
55
39
  def revoke(
56
40
  name: str = typer.Argument(..., help="Access token name"),
@@ -58,10 +42,3 @@ def revoke(
58
42
  project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
59
43
  ) -> None:
60
44
  Rest.handle_delete(f"/api/project/{project}/admin/accounts/{service_account}/keys/{name}")
61
-
62
-
63
- # TODO: Move to settings # pylint: disable=W0511
64
- def write_sa_token(service_account: str, name: str, token: str) -> Path:
65
- file = f"service-account-{service_account}-{name}-token.json"
66
- path = settings.CONFIG_DIR_PATH / file
67
- return settings._write_settings_file(path, token) # pylint: disable=W0212
@@ -1,5 +1,5 @@
1
1
  from cli.cloud.storage.cmd import app
2
- from cli.cloud.storage.uri import URI
3
2
  from cli.cloud.storage.uri_or_path import UriOrPath
3
+ from cli.cloud.uri import URI
4
4
 
5
5
  __all__ = ["URI", "UriOrPath", "app"]
@@ -5,9 +5,9 @@ from typing_extensions import Annotated
5
5
 
6
6
  from cli.cloud.rest_helper import RestHelper as Rest
7
7
  from cli.cloud.storage.copy import copy
8
- from cli.cloud.storage.uri import URI, InvalidURIError, JoinURIError
9
8
  from cli.cloud.storage.uri_or_path import UriOrPath
10
9
  from cli.cloud.storage.uri_or_path import uri as uri_parser
10
+ from cli.cloud.uri import URI, InvalidURIError, JoinURIError
11
11
  from cli.errors import ErrorPrinter
12
12
 
13
13
  HELP = """
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  from cli.cloud.rest_helper import RestHelper as Rest
7
7
  from cli.cloud.resumable_upload import upload_signed_url
8
- from cli.cloud.storage.uri import URI
8
+ from cli.cloud.uri import URI
9
9
 
10
10
  _RCS_STORAGE_PATH = "/api/project/{project}/files/storage{path}"
11
11
 
@@ -29,7 +29,7 @@ def copy(project: str, source: URI | Path, dest: URI | Path, overwrite: bool = F
29
29
 
30
30
  def _upload(source: Path, dest: URI, project: str, overwrite: bool = False) -> None:
31
31
  if not source.exists():
32
- raise FileNotFoundError(source)
32
+ raise FileNotFoundError(f"Source file does not exist: {source}")
33
33
 
34
34
  files_to_upload = _list_files_for_upload(source, dest)
35
35
 
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
4
 
5
- from cli.cloud.storage.uri import URI, InvalidURIError
5
+ from cli.cloud.uri import URI, InvalidURIError
6
6
 
7
7
 
8
8
  def uri(path: str) -> UriOrPath:
@@ -16,9 +16,9 @@ from rich import print as pretty_print
16
16
  from rich.console import Console
17
17
  from socketio.exceptions import ConnectionError as SocketIoConnectionError # type: ignore
18
18
 
19
- from cli import settings
20
19
  from cli.broker.lib.broker import SubscribableSignal
21
20
  from cli.errors import ErrorPrinter
21
+ from cli.settings import settings
22
22
 
23
23
  global PP_CONNECT_APP_NAME
24
24
  PP_CONNECT_APP_NAME = "RemotiveBridge"
@@ -167,7 +167,7 @@ def do_connect(
167
167
  if broker_url.startswith("https"):
168
168
  if api_key is None:
169
169
  print("No --api-key, reading token from file")
170
- x_api_key = settings.read_secret_token()
170
+ x_api_key = settings.get_active_token_secret()
171
171
  else:
172
172
  x_api_key = api_key
173
173
  elif api_key is not None:
@@ -9,6 +9,7 @@ from typer.main import get_group
9
9
  from .broker.brokers import app as broker_app
10
10
  from .cloud.cloud_cli import app as cloud_app
11
11
  from .connect.connect import app as connect_app
12
+ from .settings.cmd import app as settings_app
12
13
  from .tools.tools import app as tools_app
13
14
 
14
15
  if os.getenv("GRPC_VERBOSITY") is None:
@@ -63,5 +64,6 @@ app.add_typer(
63
64
  name="cloud",
64
65
  help="Manage resources in RemotiveCloud",
65
66
  )
67
+ app.add_typer(settings_app, name="config", help="Manage access tokens")
66
68
  app.add_typer(connect_app, name="connect", help="Integrations with other systems")
67
69
  app.add_typer(tools_app, name="tools")
@@ -0,0 +1,5 @@
1
+ from cli.settings.cmd import app
2
+ from cli.settings.core import InvalidSettingsFilePathError, Settings, TokenNotFoundError, settings
3
+ from cli.settings.token_file import TokenFile
4
+
5
+ __all__ = ["app", "settings", "TokenFile", "TokenNotFoundError", "InvalidSettingsFilePathError", "Settings"]
@@ -0,0 +1,71 @@
1
+ import typer
2
+
3
+ from cli.errors import ErrorPrinter
4
+ from cli.settings.core import TokenNotFoundError, settings
5
+
6
+ app = typer.Typer()
7
+
8
+
9
+ @app.command()
10
+ def describe(file: str = typer.Argument(help="Token name or file path")) -> None:
11
+ """
12
+ Show contents of specified access token file
13
+ """
14
+ try:
15
+ print(settings.get_token_file(file))
16
+ except TokenNotFoundError:
17
+ ErrorPrinter.print_generic_error(f"Token file {file} not found")
18
+
19
+
20
+ @app.command()
21
+ def activate(file: str = typer.Argument(..., help="Token name or file path")) -> None:
22
+ """
23
+ Activate a access token file to be used for authentication.
24
+
25
+ This will be used as the current access token in all subsequent requests. This would
26
+ be the same as login with a browser.
27
+ """
28
+ try:
29
+ settings.activate_token(file)
30
+ except FileNotFoundError as e:
31
+ print(f"File could not be found: {e}")
32
+
33
+
34
+ @app.command(name="list-personal-tokens")
35
+ def list_pats() -> None:
36
+ """
37
+ List personal access token files in remotivelabs config directory
38
+ """
39
+ pats = settings.list_personal_tokens()
40
+ for pat in pats:
41
+ print(pat.name)
42
+
43
+
44
+ @app.command(name="list-personal-tokens-files")
45
+ def list_pats_files() -> None:
46
+ """
47
+ List personal access token files in remotivelabs config directory
48
+ """
49
+ personal_files = settings.list_personal_token_files()
50
+ for file in personal_files:
51
+ print(file)
52
+
53
+
54
+ @app.command(name="list-service-account-tokens")
55
+ def list_sats() -> None:
56
+ """
57
+ List service account access token files in remotivelabs config directory
58
+ """
59
+ sats = settings.list_service_account_tokens()
60
+ for sat in sats:
61
+ print(sat.name)
62
+
63
+
64
+ @app.command(name="list-service-account-tokens-files")
65
+ def list_sats_files() -> None:
66
+ """
67
+ List service account access token files in remotivelabs config directory
68
+ """
69
+ service_account_files = settings.list_service_account_token_files()
70
+ for file in service_account_files:
71
+ print(file)
@@ -0,0 +1,261 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import shutil
5
+ import sys
6
+ from json import JSONDecodeError
7
+ from pathlib import Path
8
+ from typing import Tuple
9
+
10
+ from rich.console import Console
11
+
12
+ from cli.settings import token_file as tf
13
+ from cli.settings.token_file import TokenFile
14
+
15
+ err_console = Console(stderr=True)
16
+
17
+
18
+ CONFIG_DIR_PATH = Path.home() / ".config" / "remotive"
19
+ INCORRECT_CONFIG_DIR_PATH = Path.home() / ".config" / ".remotive"
20
+ DEPRECATED_CONFIG_DIR_PATH = Path.home() / ".remotive"
21
+
22
+ ACTIVE_TOKEN_FILE_NAME = "cloud.secret.token"
23
+ PERSONAL_TOKEN_FILE_PREFIX = "personal-token-"
24
+ SERVICE_ACCOUNT_TOKEN_FILE_PREFIX = "service-account-token-"
25
+
26
+
27
+ TokenFileMetadata = Tuple[TokenFile, Path]
28
+
29
+
30
+ class InvalidSettingsFilePathError(Exception):
31
+ """Raised when trying to access an invalid settings file or file path"""
32
+
33
+
34
+ class TokenNotFoundError(Exception):
35
+ """Raised when a token cannot be found in settings"""
36
+
37
+
38
+ class Settings:
39
+ """
40
+ Settings for the remotive CLI
41
+ """
42
+
43
+ config_dir: Path
44
+
45
+ def __init__(self, config_dir: Path, deprecated_config_dirs: list[Path] | None = None) -> None:
46
+ self.config_dir = config_dir
47
+ self._active_secret_token_path = self.config_dir / ACTIVE_TOKEN_FILE_NAME
48
+
49
+ # no migration of deprecated config dirs if the new config dir already exists
50
+ if self.config_dir.exists():
51
+ return
52
+
53
+ # create the config dir and try to migrate legacy config dirs if they exist
54
+ self.config_dir.mkdir(parents=True, exist_ok=True)
55
+ if deprecated_config_dirs:
56
+ for deprecated_config_dir in deprecated_config_dirs:
57
+ self._migrate_legacy_config_dir(deprecated_config_dir)
58
+
59
+ def get_active_token(self) -> str:
60
+ """
61
+ Get the current active token secret
62
+ """
63
+ token_file = self.get_active_token_file()
64
+ return token_file.token
65
+
66
+ def get_active_token_file(self) -> TokenFile:
67
+ """
68
+ Get the current active token file
69
+ """
70
+ if not self._active_secret_token_path.exists():
71
+ raise TokenNotFoundError("no active token file found")
72
+
73
+ return self._read_token_file(self._active_secret_token_path)
74
+
75
+ def activate_token(self, name: str) -> None:
76
+ """
77
+ Activate a token by name or path
78
+
79
+ The token secret will be set as the current active secret.
80
+ """
81
+ token_file = self.get_token_file(name)
82
+ self._write_token_file(self._active_secret_token_path, token_file)
83
+
84
+ def clear_active_token(self) -> None:
85
+ """
86
+ Clear the current active token
87
+ """
88
+ self._active_secret_token_path.unlink(missing_ok=True)
89
+
90
+ def get_token_file(self, name: str) -> TokenFile:
91
+ """
92
+ Get a token file by name or path
93
+ """
94
+ if Path(name).exists():
95
+ return self._read_token_file(Path(name))
96
+
97
+ return self._get_token_by_name(name)[0]
98
+
99
+ def remove_token_file(self, name: str) -> None:
100
+ """
101
+ Remove a token file by name or path
102
+
103
+ TODO: what about manually downloaded tokens?
104
+ """
105
+ if Path(name).exists():
106
+ if self.config_dir not in Path(name).parents:
107
+ raise InvalidSettingsFilePathError(f"cannot remove a token file not located in settings dir {self.config_dir}")
108
+ return Path(name).unlink()
109
+
110
+ # TODO: what about the active token? # pylint: disable=fixme
111
+
112
+ path = self._get_token_by_name(name)[1]
113
+ return path.unlink()
114
+
115
+ def add_and_activate_short_lived_cli_token(self, token: str) -> TokenFile:
116
+ """
117
+ Activates a short lived token
118
+ """
119
+ token_file = tf.loads(token)
120
+ self._write_token_file(self._active_secret_token_path, token_file)
121
+ return token_file
122
+
123
+ def add_personal_token(
124
+ self,
125
+ token: str,
126
+ activate: bool = False,
127
+ overwrite_if_exists: bool = False,
128
+ ) -> TokenFile:
129
+ """
130
+ Add a personal token
131
+ """
132
+ token_file = tf.loads(token)
133
+
134
+ file = f"{PERSONAL_TOKEN_FILE_PREFIX}{token_file.name}.json"
135
+ path = self.config_dir / file
136
+ if path.exists() and not overwrite_if_exists:
137
+ raise FileExistsError(f"Token file already exists: {path}")
138
+
139
+ self._write_token_file(path, token_file)
140
+
141
+ if activate:
142
+ self.activate_token(token_file.name)
143
+
144
+ return token_file
145
+
146
+ def list_personal_tokens(self) -> list[TokenFile]:
147
+ """
148
+ List all personal tokens
149
+ """
150
+ return [f[0] for f in self._list_personal_tokens()]
151
+
152
+ def list_personal_token_files(self) -> list[Path]:
153
+ """
154
+ List paths to all personal token files
155
+ """
156
+ return [f[1] for f in self._list_personal_tokens()]
157
+
158
+ def add_service_account_token(self, service_account: str, token: str) -> TokenFile:
159
+ """
160
+ Add a service account token to the config directory
161
+ """
162
+ token_file = tf.loads(token)
163
+
164
+ file = f"{SERVICE_ACCOUNT_TOKEN_FILE_PREFIX}{service_account}-{token_file.name}.json"
165
+ path = self.config_dir / file
166
+ if path.exists():
167
+ raise FileExistsError(f"Token file already exists: {path}")
168
+
169
+ self._write_token_file(path, token_file)
170
+ return token_file
171
+
172
+ def list_service_account_tokens(self) -> list[TokenFile]:
173
+ """
174
+ List all service account tokens
175
+ """
176
+ return [f[0] for f in self._list_service_account_tokens()]
177
+
178
+ def list_service_account_token_files(self) -> list[Path]:
179
+ """
180
+ List paths to all service account token files
181
+ """
182
+ return [f[1] for f in self._list_service_account_tokens()]
183
+
184
+ def _list_personal_tokens(self) -> list[TokenFileMetadata]:
185
+ return self._list_token_files(prefix=PERSONAL_TOKEN_FILE_PREFIX)
186
+
187
+ def _list_service_account_tokens(self) -> list[TokenFileMetadata]:
188
+ return self._list_token_files(prefix=SERVICE_ACCOUNT_TOKEN_FILE_PREFIX)
189
+
190
+ def _get_token_by_name(self, name: str) -> TokenFileMetadata:
191
+ token_files = self._list_token_files()
192
+ matches = [token_file for token_file in token_files if token_file[0].name == name]
193
+ if len(matches) != 1:
194
+ raise TokenNotFoundError(f"Ambiguous token file name {name}, found {len(matches)} files")
195
+ return matches[0]
196
+
197
+ def _list_token_files(self, prefix: str = "") -> list[TokenFileMetadata]:
198
+ # list all tokens with the correct prefix in the config dir, but omit the special active token file
199
+ def is_path_prefixed_and_not_active_secret(path: Path) -> bool:
200
+ has_correct_prefix = path.is_file() and path.name.startswith(prefix)
201
+ is_active_secret = path == self._active_secret_token_path
202
+ return has_correct_prefix and not is_active_secret
203
+
204
+ paths = [path for path in self.config_dir.iterdir() if is_path_prefixed_and_not_active_secret(path)]
205
+
206
+ return [(self._read_token_file(token_file), token_file) for token_file in paths]
207
+
208
+ def _read_token_file(self, path: Path) -> TokenFile:
209
+ data = self._read_file(path)
210
+ return tf.loads(data)
211
+
212
+ def _read_file(self, path: Path) -> str:
213
+ if not path.exists():
214
+ raise TokenNotFoundError(f"File could not be found: {path}")
215
+ return path.read_text(encoding="utf-8")
216
+
217
+ def _write_token_file(self, path: Path, token: TokenFile) -> Path:
218
+ data = tf.dumps(token)
219
+ return self._write_file(path, data)
220
+
221
+ def _write_file(self, path: Path, data: str) -> Path:
222
+ if self.config_dir not in path.parents:
223
+ raise InvalidSettingsFilePathError(f"file {path} not in settings dir {self.config_dir}")
224
+ path.parent.mkdir(parents=True, exist_ok=True)
225
+ path.write_text(data, encoding="utf8")
226
+ return path
227
+
228
+ def _migrate_legacy_config_dir(self, path: Path) -> None:
229
+ if not path.exists():
230
+ return
231
+
232
+ sys.stderr.write(f"migrating deprecated config directory {path} to {self.config_dir}\n")
233
+ shutil.copytree(str(path), str(self.config_dir), dirs_exist_ok=True)
234
+ secret = path / ACTIVE_TOKEN_FILE_NAME
235
+ if secret.exists():
236
+ value = secret.read_text(encoding="utf-8").strip()
237
+ # The existing token file might either be a token file, or simply a string. We handle both cases...
238
+ try:
239
+ token = tf.loads(value)
240
+ except JSONDecodeError:
241
+ token = tf.TokenFile(
242
+ name="MigratedActiveToken",
243
+ token=value,
244
+ created=str(datetime.datetime.now().isoformat()),
245
+ expires="unknown",
246
+ )
247
+ self.add_and_activate_short_lived_cli_token(tf.dumps(token))
248
+ shutil.rmtree(str(path))
249
+
250
+
251
+ def create_settings() -> Settings:
252
+ """Create remotive CLI config directory and return its settings instance"""
253
+ return Settings(CONFIG_DIR_PATH, deprecated_config_dirs=[DEPRECATED_CONFIG_DIR_PATH, INCORRECT_CONFIG_DIR_PATH])
254
+
255
+
256
+ settings = create_settings()
257
+ """
258
+ Global/module-level settings instance. Module-level variables are only loaded once, at import time.
259
+
260
+ TODO: Migrate away from singleton instance
261
+ """
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import json
5
+ from dataclasses import dataclass
6
+
7
+
8
+ def loads(data: str) -> TokenFile:
9
+ d = json.loads(data)
10
+ return TokenFile(name=d["name"], token=d["token"], created=d["created"], expires=d["expires"])
11
+
12
+
13
+ def dumps(token: TokenFile) -> str:
14
+ return json.dumps(dataclasses.asdict(token), default=str)
15
+
16
+
17
+ @dataclass
18
+ class TokenFile:
19
+ name: str
20
+ token: str
21
+ created: str
22
+ expires: str
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "remotivelabs-cli"
3
- version = "0.0.34"
3
+ version = "0.0.36"
4
4
  description = "CLI for operating RemotiveCloud and RemotiveBroker"
5
5
  authors = ["Johan Rask <johan.rask@remotivelabs.com>"]
6
6
  readme = "README.md"
@@ -11,7 +11,7 @@ packages = [{ include = "cli" }]
11
11
  remotive = "cli.remotive:app"
12
12
 
13
13
  [tool.poetry.dependencies]
14
- python = ">=3.8"
14
+ python = ">=3.8,<4"
15
15
  trogon = ">=0.5.0"
16
16
  typer = "0.12.5"
17
17
  remotivelabs-broker = "~=0.1.17"
@@ -1,143 +0,0 @@
1
- import json
2
- import sys
3
- from json.decoder import JSONDecodeError
4
- from pathlib import Path
5
-
6
- import typer
7
-
8
- from cli import settings
9
-
10
- from .rest_helper import RestHelper as Rest
11
-
12
- app = typer.Typer()
13
-
14
-
15
- @app.command(name="create", help="Create and download a new personal access token")
16
- def get_personal_access_token(activate: bool = typer.Option(False, help="Activate the token for use after download")) -> None: # pylint: disable=W0621
17
- Rest.ensure_auth_token()
18
- response = Rest.handle_post(url="/api/me/keys", return_response=True)
19
-
20
- if response is None:
21
- return
22
-
23
- if response.status_code == 200:
24
- name = response.json()["name"]
25
- pat_path = write_personal_token(name, response.text)
26
- print(f"Personal access token written to {pat_path}")
27
- if not activate:
28
- print(f"Use 'remotive cloud auth tokens activate {pat_path.name}' to use this access token from cli")
29
- else:
30
- do_activate(str(pat_path))
31
- print("Token file activated and ready for use")
32
- print("\033[93m This file contains secrets and must be kept safe")
33
- else:
34
- print(f"Got status code: {response.status_code}")
35
- print(response.text)
36
-
37
-
38
- @app.command(name="list", help="List personal access tokens")
39
- def list_personal_access_tokens() -> None:
40
- Rest.ensure_auth_token()
41
- Rest.handle_get("/api/me/keys")
42
-
43
-
44
- @app.command(name="revoke")
45
- def revoke(name_or_file: str = typer.Argument(help="Name or file path of the access token to revoke")) -> None:
46
- """
47
- Revoke an access token by token name or path to a file containing that token
48
-
49
- Name is found in the json file
50
- ```
51
- {
52
- "expires": "2034-07-31",
53
- "token": "xxx",
54
- "created": "2024-07-31T09:18:50.406+02:00",
55
- "name": "token_name"
56
- }
57
- ```
58
- """
59
- name = name_or_file
60
- if "." in name_or_file:
61
- json_str = read_file(name_or_file)
62
- try:
63
- name = json.loads(json_str)["name"]
64
- except JSONDecodeError:
65
- sys.stderr.write("Failed to parse json, make sure its a correct access token file\n")
66
- sys.exit(1)
67
- except KeyError:
68
- sys.stderr.write("Json does not contain a name property, make sure its a correct access token file\n")
69
- sys.exit(1)
70
- Rest.ensure_auth_token()
71
- Rest.handle_delete(f"/api/me/keys/{name}")
72
-
73
-
74
- @app.command()
75
- def describe(file: str = typer.Argument(help="File name")) -> None:
76
- """
77
- Show contents of specified access token file
78
- """
79
- print(read_file(file))
80
-
81
-
82
- @app.command()
83
- def activate(file: str = typer.Argument(..., help="File name")) -> None:
84
- """
85
- Activate a access token file to be used for authentication.
86
-
87
- --file
88
-
89
- This will be used as the current access token in all subsequent requests. This would
90
- be the same as login with a browser.
91
- """
92
- do_activate(file)
93
-
94
-
95
- # TODO: Move parts of this to settings # pylint: disable=W0511
96
- def do_activate(file: str) -> None:
97
- # Best effort to read file
98
- if Path(file).exists():
99
- token_file = json.loads(read_file_with_path(Path(file)))
100
- settings.write_secret_token(token_file["token"])
101
- elif (settings.CONFIG_DIR_PATH / file).exists():
102
- token_file = json.loads(read_file(file))
103
- settings.write_secret_token(token_file["token"])
104
- else:
105
- sys.stderr.write("File could not be found \n")
106
-
107
-
108
- @app.command(name="list-files")
109
- def list_files() -> None:
110
- """
111
- List personal access token files in remotivelabs config directory
112
- """
113
- personal_files = settings.list_personal_token_files()
114
- for file in personal_files:
115
- print(file)
116
-
117
-
118
- # TODO: Move to settings # pylint: disable=W0511
119
- def read_file(file: str) -> str:
120
- """
121
- Reads a file using file path or if that does not exist check in config directory
122
- """
123
- path = Path(file)
124
- if not path.exists():
125
- path = settings.CONFIG_DIR_PATH / file
126
- if not path.exists():
127
- sys.stderr.write(f"Failed to find file using {file} or {path}\n")
128
- sys.exit(1)
129
-
130
- return read_file_with_path(path)
131
-
132
-
133
- # TODO: Move to settings # pylint: disable=W0511
134
- def read_file_with_path(path: Path) -> str:
135
- with open(path, "r", encoding="utf8") as f:
136
- return f.read()
137
-
138
-
139
- # TODO: Move to settings # pylint: disable=W0511
140
- def write_personal_token(name: str, token: str) -> Path:
141
- file = f"personal-token-{name}.json"
142
- path = settings.CONFIG_DIR_PATH / file
143
- return settings._write_settings_file(path, token) # pylint: disable=W0212
@@ -1,64 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import sys
4
- from pathlib import Path
5
-
6
- from rich.console import Console
7
-
8
- err_console = Console(stderr=True)
9
-
10
- # pylint: disable-next=W0511
11
- # TODO: We probably want this to be both configurable, and testable. The best solution would probably be to refactor this module into a
12
- # proper class, and configure it similar to logging.
13
- CONFIG_DIR_PATH = Path.home() / ".config" / ".remotive/"
14
- TOKEN_SECRET_FILE_PATH = CONFIG_DIR_PATH / "cloud.secret.token"
15
-
16
-
17
- class InvalidSettingsFileError(Exception):
18
- """Raised when trying to access an invalid settings file or file path"""
19
-
20
-
21
- def read_secret_token() -> str:
22
- if not TOKEN_SECRET_FILE_PATH.exists():
23
- err_console.print(":boom: [bold red]Access failed[/bold red] - No access token found")
24
- err_console.print("Login with [italic]remotive cloud auth login[/italic]")
25
- err_console.print(
26
- "If you have downloaded a personal access token, you can activate "
27
- "it with [italic]remotive cloud auth tokens activate [FILE_NAME][/italic]"
28
- )
29
- sys.exit(1)
30
-
31
- return _read_file(TOKEN_SECRET_FILE_PATH)
32
-
33
-
34
- def list_personal_token_files() -> list[Path]:
35
- return [f for f in CONFIG_DIR_PATH.iterdir() if f.is_file() and f.name.startswith("personal-")]
36
-
37
-
38
- def list_service_account_token_files() -> list[Path]:
39
- return [f for f in CONFIG_DIR_PATH.iterdir() if f.is_file() and f.name.startswith("service-account-")]
40
-
41
-
42
- def write_secret_token(secret: str) -> Path:
43
- return _write_settings_file(TOKEN_SECRET_FILE_PATH, secret)
44
-
45
-
46
- def clear_secret_token() -> None:
47
- TOKEN_SECRET_FILE_PATH.unlink()
48
-
49
-
50
- def _read_file(path: Path) -> str:
51
- with open(path, "r", encoding="utf-8") as f:
52
- return f.read()
53
-
54
-
55
- def _write_settings_file(path: Path, data: str) -> Path:
56
- if CONFIG_DIR_PATH not in path.parents:
57
- raise InvalidSettingsFileError(f"file {path} not in settings dir {CONFIG_DIR_PATH}")
58
-
59
- path.parent.mkdir(parents=True, exist_ok=True)
60
-
61
- with open(path, "w", encoding="utf8") as f:
62
- f.write(data)
63
-
64
- return path