remotivelabs-cli 0.0.42__py3-none-any.whl → 0.1.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.
- cli/.DS_Store +0 -0
- cli/api/cloud/tokens.py +62 -0
- cli/broker/brokers.py +0 -1
- cli/broker/export.py +4 -4
- cli/broker/lib/broker.py +9 -13
- cli/broker/license_flows.py +1 -1
- cli/broker/scripting.py +2 -1
- cli/broker/signals.py +9 -10
- cli/cloud/auth/cmd.py +25 -6
- cli/cloud/auth/login.py +279 -24
- cli/cloud/auth_tokens.py +295 -12
- cli/cloud/brokers.py +3 -4
- cli/cloud/cloud_cli.py +2 -2
- cli/cloud/configs.py +1 -2
- cli/cloud/organisations.py +90 -2
- cli/cloud/projects.py +1 -2
- cli/cloud/recordings.py +9 -16
- cli/cloud/recordings_playback.py +6 -8
- cli/cloud/sample_recordings.py +2 -3
- cli/cloud/service_account_tokens.py +21 -5
- cli/cloud/service_accounts.py +32 -4
- cli/cloud/storage/cmd.py +1 -1
- cli/cloud/storage/copy.py +3 -4
- cli/connect/connect.py +1 -1
- cli/connect/protopie/protopie.py +12 -14
- cli/remotive.py +30 -6
- cli/settings/__init__.py +1 -2
- cli/settings/config_file.py +85 -0
- cli/settings/core.py +195 -46
- cli/settings/migrate_all_token_files.py +74 -0
- cli/settings/migrate_token_file.py +52 -0
- cli/settings/token_file.py +69 -4
- cli/tools/can/can.py +2 -2
- cli/typer/typer_utils.py +18 -1
- cli/utils/__init__.py +0 -0
- cli/{cloud → utils}/rest_helper.py +109 -38
- {remotivelabs_cli-0.0.42.dist-info → remotivelabs_cli-0.1.0a1.dist-info}/METADATA +6 -4
- remotivelabs_cli-0.1.0a1.dist-info/RECORD +59 -0
- {remotivelabs_cli-0.0.42.dist-info → remotivelabs_cli-0.1.0a1.dist-info}/WHEEL +1 -1
- cli/settings/cmd.py +0 -72
- remotivelabs_cli-0.0.42.dist-info/RECORD +0 -54
- {remotivelabs_cli-0.0.42.dist-info → remotivelabs_cli-0.1.0a1.dist-info}/LICENSE +0 -0
- {remotivelabs_cli-0.0.42.dist-info → remotivelabs_cli-0.1.0a1.dist-info}/entry_points.txt +0 -0
cli/settings/core.py
CHANGED
@@ -1,15 +1,20 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
import
|
3
|
+
import os
|
4
|
+
import re
|
4
5
|
import shutil
|
6
|
+
import stat
|
5
7
|
import sys
|
6
|
-
from
|
8
|
+
from dataclasses import dataclass
|
7
9
|
from pathlib import Path
|
8
|
-
from typing import Tuple
|
10
|
+
from typing import Optional, Tuple, Union
|
9
11
|
|
10
12
|
from rich.console import Console
|
11
13
|
|
14
|
+
from cli.errors import ErrorPrinter
|
15
|
+
from cli.settings import config_file
|
12
16
|
from cli.settings import token_file as tf
|
17
|
+
from cli.settings.config_file import ConfigFile
|
13
18
|
from cli.settings.token_file import TokenFile
|
14
19
|
|
15
20
|
err_console = Console(stderr=True)
|
@@ -19,6 +24,7 @@ CONFIG_DIR_PATH = Path.home() / ".config" / "remotive"
|
|
19
24
|
INCORRECT_CONFIG_DIR_PATH = Path.home() / ".config" / ".remotive"
|
20
25
|
DEPRECATED_CONFIG_DIR_PATH = Path.home() / ".remotive"
|
21
26
|
|
27
|
+
CLI_CONFIG_FILE_NAME = "config.json"
|
22
28
|
ACTIVE_TOKEN_FILE_NAME = "cloud.secret.token"
|
23
29
|
PERSONAL_TOKEN_FILE_PREFIX = "personal-token-"
|
24
30
|
SERVICE_ACCOUNT_TOKEN_FILE_PREFIX = "service-account-token-"
|
@@ -31,10 +37,19 @@ class InvalidSettingsFilePathError(Exception):
|
|
31
37
|
"""Raised when trying to access an invalid settings file or file path"""
|
32
38
|
|
33
39
|
|
40
|
+
class NotFoundError(Exception):
|
41
|
+
"""Raised when a token cannot be found in settings"""
|
42
|
+
|
43
|
+
|
34
44
|
class TokenNotFoundError(Exception):
|
35
45
|
"""Raised when a token cannot be found in settings"""
|
36
46
|
|
37
47
|
|
48
|
+
@dataclass()
|
49
|
+
class CliConfigFile:
|
50
|
+
default_organisation: Union[str, None]
|
51
|
+
|
52
|
+
|
38
53
|
class Settings:
|
39
54
|
"""
|
40
55
|
Settings for the remotive CLI
|
@@ -45,8 +60,8 @@ class Settings:
|
|
45
60
|
def __init__(self, config_dir: Path, deprecated_config_dirs: list[Path] | None = None) -> None:
|
46
61
|
self.config_dir = config_dir
|
47
62
|
self._active_secret_token_path = self.config_dir / ACTIVE_TOKEN_FILE_NAME
|
63
|
+
self._cli_config = self.config_dir / CLI_CONFIG_FILE_NAME
|
48
64
|
|
49
|
-
# no migration of deprecated config dirs if the new config dir already exists
|
50
65
|
if self.config_dir.exists():
|
51
66
|
return
|
52
67
|
|
@@ -56,6 +71,65 @@ class Settings:
|
|
56
71
|
for deprecated_config_dir in deprecated_config_dirs:
|
57
72
|
self._migrate_legacy_config_dir(deprecated_config_dir)
|
58
73
|
|
74
|
+
# def _write_properties(self, filepath: Path, props: CliConfigFile) -> None:
|
75
|
+
# with open(filepath, "w", encoding="utf-8") as file:
|
76
|
+
# # keys = sorted(props.keys()) if sort_keys else props.keys()
|
77
|
+
# # for key in keys:
|
78
|
+
# file.write(f"default_organisation={props.default_organisation}\n")
|
79
|
+
|
80
|
+
def _read_properties(self, filepath: Path) -> CliConfigFile:
|
81
|
+
props = {}
|
82
|
+
with open(filepath, "r", encoding="utf-8") as file:
|
83
|
+
for line_num, line in enumerate(file, start=1):
|
84
|
+
line_stripped = line.strip()
|
85
|
+
if not line_stripped or line_stripped.startswith("#"):
|
86
|
+
continue
|
87
|
+
if "=" not in line_stripped:
|
88
|
+
raise ValueError(f"Invalid line format at line {line_num}: {line}")
|
89
|
+
key, value = line_stripped.split("=", 1)
|
90
|
+
key, value = key.strip(), value.strip()
|
91
|
+
if key in props:
|
92
|
+
raise ValueError(f"Duplicate key '{key}' found at line {line_num}")
|
93
|
+
props[key] = value
|
94
|
+
if "default_organisation" in props:
|
95
|
+
return CliConfigFile(default_organisation=props["default_organisation"])
|
96
|
+
return CliConfigFile(default_organisation=None)
|
97
|
+
|
98
|
+
def set_default_organisation(self, organisation: str) -> None:
|
99
|
+
cli_config = self.get_cli_config()
|
100
|
+
|
101
|
+
try:
|
102
|
+
token = settings.get_active_token_file()
|
103
|
+
cli_config.set_account_field(token.account.email, organisation)
|
104
|
+
self._write_config_file(cli_config)
|
105
|
+
except TokenNotFoundError:
|
106
|
+
ErrorPrinter.print_hint("You must have an account activated in order to set default organisation")
|
107
|
+
sys.exit(1)
|
108
|
+
|
109
|
+
# def set_default_config_as_env(self) -> None:
|
110
|
+
# config = self.get_cli_config()
|
111
|
+
# if config.default_organisation is not None:
|
112
|
+
# if "REMOTIVE_CLOUD_ORGANISATION" not in os.environ:
|
113
|
+
# os.environ["REMOTIVE_CLOUD_ORGANISATION"] = config.default_organisation
|
114
|
+
|
115
|
+
# def get_active_cli_account(self) -> Optional[Account]:
|
116
|
+
# try:
|
117
|
+
# token = self.get_active_token_file()
|
118
|
+
# config = self.get_cli_config()
|
119
|
+
# if token.account.email in config.accounts:
|
120
|
+
# return config.accounts[token.account.email]
|
121
|
+
# return None
|
122
|
+
# except TokenNotFoundError:
|
123
|
+
# ErrorPrinter.print_hint("Cannot get active config without activating account credentials first")
|
124
|
+
# sys.exit(1)
|
125
|
+
|
126
|
+
def get_cli_config(self) -> ConfigFile:
|
127
|
+
try:
|
128
|
+
return self._read_config_file()
|
129
|
+
except TokenNotFoundError:
|
130
|
+
return ConfigFile()
|
131
|
+
# self._write_properties(CONFIG_DIR_PATH / CLI_CONFIG_FILE_NAME, [])
|
132
|
+
|
59
133
|
def get_active_token(self) -> str:
|
60
134
|
"""
|
61
135
|
Get the current active token secret
|
@@ -67,25 +141,48 @@ class Settings:
|
|
67
141
|
"""
|
68
142
|
Get the current active token file
|
69
143
|
"""
|
70
|
-
if not self._active_secret_token_path.exists():
|
71
|
-
raise TokenNotFoundError("no active token file found")
|
72
144
|
|
73
|
-
|
145
|
+
active_account = self.get_cli_config().get_active()
|
146
|
+
if active_account is not None:
|
147
|
+
token_name = active_account.credentials_name
|
148
|
+
return self.get_token_file(token_name)
|
149
|
+
raise TokenNotFoundError
|
150
|
+
# if not self._active_secret_token_path.exists():
|
151
|
+
# raise TokenNotFoundError("no active token file found")
|
152
|
+
# return self._read_token_file(self._active_secret_token_path)
|
74
153
|
|
75
|
-
def activate_token(self,
|
154
|
+
def activate_token(self, token: TokenFile) -> None:
|
76
155
|
"""
|
77
156
|
Activate a token by name or path
|
78
157
|
|
79
158
|
The token secret will be set as the current active secret.
|
80
159
|
"""
|
81
|
-
token_file = self.get_token_file(name)
|
82
|
-
self.
|
160
|
+
# token_file = self.get_token_file(name)
|
161
|
+
cli_config = self.get_cli_config()
|
162
|
+
cli_config.activate(token.account.email)
|
163
|
+
# if token_file.account.email not in cli_config.accounts:
|
164
|
+
# cli_config.set_account_field(token_file.account.email)
|
165
|
+
self._write_config_file(cli_config)
|
166
|
+
# self._write_token_file(self._active_secret_token_path, token_file)
|
83
167
|
|
84
168
|
def clear_active_token(self) -> None:
|
85
169
|
"""
|
86
170
|
Clear the current active token
|
87
171
|
"""
|
88
|
-
self.
|
172
|
+
config = self.get_cli_config()
|
173
|
+
config.active = None
|
174
|
+
self._write_config_file(config)
|
175
|
+
|
176
|
+
# self._active_secret_token_path.unlink(missing_ok=True)
|
177
|
+
|
178
|
+
def get_token_file_by_email(self, email: str) -> Optional[TokenFile]:
|
179
|
+
tokens = [t for t in self.list_personal_tokens() if t.account is not None and t.account.email == email]
|
180
|
+
if len(tokens) > 0:
|
181
|
+
return tokens[0]
|
182
|
+
tokens = [t for t in self.list_service_account_tokens() if t.account is not None and t.account.email == email]
|
183
|
+
if len(tokens) > 0:
|
184
|
+
return tokens[0]
|
185
|
+
return None
|
89
186
|
|
90
187
|
def get_token_file(self, name: str) -> TokenFile:
|
91
188
|
"""
|
@@ -93,6 +190,8 @@ class Settings:
|
|
93
190
|
"""
|
94
191
|
if Path(name).exists():
|
95
192
|
return self._read_token_file(Path(name))
|
193
|
+
if Path(CONFIG_DIR_PATH / name).exists():
|
194
|
+
return self._read_token_file(Path(CONFIG_DIR_PATH / name))
|
96
195
|
|
97
196
|
return self._get_token_by_name(name)[0]
|
98
197
|
|
@@ -107,18 +206,19 @@ class Settings:
|
|
107
206
|
raise InvalidSettingsFilePathError(f"cannot remove a token file not located in settings dir {self.config_dir}")
|
108
207
|
return Path(name).unlink()
|
109
208
|
|
110
|
-
# TODO: what about the active token?
|
111
|
-
|
209
|
+
# TODO: what about the active token?
|
112
210
|
path = self._get_token_by_name(name)[1]
|
211
|
+
# print("Deleting", path)
|
212
|
+
print(path)
|
113
213
|
return path.unlink()
|
114
214
|
|
115
|
-
def add_and_activate_short_lived_cli_token(self, token: str) -> TokenFile:
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
215
|
+
# def add_and_activate_short_lived_cli_token(self, token: str) -> TokenFile:
|
216
|
+
# """
|
217
|
+
# Activates a short lived token
|
218
|
+
# """
|
219
|
+
# token_file = tf.loads(token)
|
220
|
+
# self._write_token_file(self._active_secret_token_path, token_file)
|
221
|
+
# return token_file
|
122
222
|
|
123
223
|
def add_personal_token(
|
124
224
|
self,
|
@@ -131,15 +231,24 @@ class Settings:
|
|
131
231
|
"""
|
132
232
|
token_file = tf.loads(token)
|
133
233
|
|
134
|
-
|
234
|
+
def email_to_safe_filename(email: str) -> str:
|
235
|
+
# Replace any invalid character with an underscore
|
236
|
+
return re.sub(r'[<>:"/\\|?*]', "_", email)
|
237
|
+
|
238
|
+
# From now, user will never be None when adding a token so in this case token_file.user is never None
|
239
|
+
email = email_to_safe_filename(token_file.account.email) if token_file.account is not None else "unknown"
|
240
|
+
file = f"{PERSONAL_TOKEN_FILE_PREFIX}{token_file.name}-{email}.json"
|
135
241
|
path = self.config_dir / file
|
136
242
|
if path.exists() and not overwrite_if_exists:
|
137
243
|
raise FileExistsError(f"Token file already exists: {path}")
|
138
244
|
|
139
245
|
self._write_token_file(path, token_file)
|
246
|
+
cli_config = self.get_cli_config()
|
247
|
+
cli_config.init_account(email=token_file.account.email, token_name=token_file.name)
|
248
|
+
self._write_config_file(cli_config)
|
140
249
|
|
141
250
|
if activate:
|
142
|
-
self.activate_token(token_file
|
251
|
+
self.activate_token(token_file)
|
143
252
|
|
144
253
|
return token_file
|
145
254
|
|
@@ -155,20 +264,38 @@ class Settings:
|
|
155
264
|
"""
|
156
265
|
return [f[1] for f in self._list_personal_tokens()]
|
157
266
|
|
158
|
-
def add_service_account_token(self,
|
159
|
-
"""
|
160
|
-
Add a service account token to the config directory
|
161
|
-
"""
|
267
|
+
def add_service_account_token(self, token: str) -> TokenFile:
|
162
268
|
token_file = tf.loads(token)
|
163
269
|
|
164
|
-
|
270
|
+
def email_to_safe_filename(email: str) -> str:
|
271
|
+
# Replace any invalid character with an underscore
|
272
|
+
return re.sub(r'[<>:"/\\|?*]', "_", email)
|
273
|
+
|
274
|
+
# From now, user will never be None when adding a token so in this case token_file.user is never None
|
275
|
+
|
276
|
+
email = email_to_safe_filename(token_file.account.email) if token_file.account is not None else "unknown"
|
277
|
+
file = f"{SERVICE_ACCOUNT_TOKEN_FILE_PREFIX}{email}-{token_file.name}.json"
|
165
278
|
path = self.config_dir / file
|
166
|
-
if path.exists():
|
167
|
-
raise FileExistsError(f"Token file already exists: {path}")
|
168
279
|
|
169
280
|
self._write_token_file(path, token_file)
|
281
|
+
cli_config = self.get_cli_config()
|
282
|
+
cli_config.init_account(email=token_file.account.email, token_name=token_file.name)
|
283
|
+
self._write_config_file(cli_config)
|
284
|
+
|
285
|
+
# if activate:
|
286
|
+
# self.activate_token(token_file.account.email)
|
287
|
+
|
170
288
|
return token_file
|
171
289
|
|
290
|
+
# token_file = tf.loads(token)
|
291
|
+
# file = f"{SERVICE_ACCOUNT_TOKEN_FILE_PREFIX}{service_account}-{token_file.name}.json"
|
292
|
+
# path = self.config_dir / file
|
293
|
+
# if path.exists():
|
294
|
+
# raise FileExistsError(f"Token file already exists: {path}")
|
295
|
+
|
296
|
+
# self._write_token_file(path, token_file)
|
297
|
+
# return token_file
|
298
|
+
|
172
299
|
def list_service_account_tokens(self) -> list[TokenFile]:
|
173
300
|
"""
|
174
301
|
List all service account tokens
|
@@ -195,13 +322,15 @@ class Settings:
|
|
195
322
|
return matches[0]
|
196
323
|
|
197
324
|
def _list_token_files(self, prefix: str = "") -> list[TokenFileMetadata]:
|
198
|
-
|
199
|
-
|
325
|
+
"""list all tokens with the correct prefix in the config dir, but omit files that are not token files"""
|
326
|
+
|
327
|
+
def is_valid_token_file(path: Path) -> bool:
|
200
328
|
has_correct_prefix = path.is_file() and path.name.startswith(prefix)
|
201
329
|
is_active_secret = path == self._active_secret_token_path
|
202
|
-
|
330
|
+
is_cli_config = path == self._cli_config
|
331
|
+
return has_correct_prefix and not is_active_secret and not is_cli_config
|
203
332
|
|
204
|
-
paths = [path for path in self.config_dir.iterdir() if
|
333
|
+
paths = [path for path in self.config_dir.iterdir() if is_valid_token_file(path)]
|
205
334
|
|
206
335
|
return [(self._read_token_file(token_file), token_file) for token_file in paths]
|
207
336
|
|
@@ -209,6 +338,10 @@ class Settings:
|
|
209
338
|
data = self._read_file(path)
|
210
339
|
return tf.loads(data)
|
211
340
|
|
341
|
+
def _read_config_file(self) -> ConfigFile:
|
342
|
+
data = self._read_file(self.config_dir / CLI_CONFIG_FILE_NAME)
|
343
|
+
return config_file.loads(data)
|
344
|
+
|
212
345
|
def _read_file(self, path: Path) -> str:
|
213
346
|
if not path.exists():
|
214
347
|
raise TokenNotFoundError(f"File could not be found: {path}")
|
@@ -216,7 +349,15 @@ class Settings:
|
|
216
349
|
|
217
350
|
def _write_token_file(self, path: Path, token: TokenFile) -> Path:
|
218
351
|
data = tf.dumps(token)
|
219
|
-
|
352
|
+
path = self._write_file(path, data)
|
353
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
354
|
+
return path
|
355
|
+
|
356
|
+
def _write_config_file(self, config: ConfigFile) -> Path:
|
357
|
+
data = config_file.dumps(config)
|
358
|
+
path = self._write_file(self._cli_config, data)
|
359
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
360
|
+
return path
|
220
361
|
|
221
362
|
def _write_file(self, path: Path, data: str) -> Path:
|
222
363
|
if self.config_dir not in path.parents:
|
@@ -233,24 +374,32 @@ class Settings:
|
|
233
374
|
shutil.copytree(str(path), str(self.config_dir), dirs_exist_ok=True)
|
234
375
|
secret = path / ACTIVE_TOKEN_FILE_NAME
|
235
376
|
if secret.exists():
|
236
|
-
|
377
|
+
sys.stderr.write(f"Removing old activated token {secret}")
|
378
|
+
secret.unlink(missing_ok=True)
|
379
|
+
# value = secret.read_text(encoding="utf-8").strip()
|
237
380
|
# The existing token file might either be a token file, or simply a string. We handle both cases...
|
238
|
-
try:
|
239
|
-
|
240
|
-
except JSONDecodeError:
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
381
|
+
# try:
|
382
|
+
# token = tf.loads(value)
|
383
|
+
# except JSONDecodeError:
|
384
|
+
# token = tf.TokenFile(
|
385
|
+
# version="1.0",
|
386
|
+
# type="service-account" if value.startswith("sa") else "authorized_user",
|
387
|
+
# name="MigratedActiveToken",
|
388
|
+
# token=value,
|
389
|
+
# created=str(datetime.datetime.now().isoformat()),
|
390
|
+
# expires="unknown",
|
391
|
+
# account=TokenFileAccount(email="unknown@remotivecloud.com"),
|
392
|
+
# )
|
393
|
+
# self.add_and_activate_short_lived_cli_token(tf.dumps(token))
|
248
394
|
shutil.rmtree(str(path))
|
249
395
|
|
250
396
|
|
251
397
|
def create_settings() -> Settings:
|
252
398
|
"""Create remotive CLI config directory and return its settings instance"""
|
253
|
-
return Settings(
|
399
|
+
return Settings(
|
400
|
+
CONFIG_DIR_PATH,
|
401
|
+
deprecated_config_dirs=[DEPRECATED_CONFIG_DIR_PATH, INCORRECT_CONFIG_DIR_PATH],
|
402
|
+
)
|
254
403
|
|
255
404
|
|
256
405
|
settings = create_settings()
|
@@ -0,0 +1,74 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from cli.settings import settings
|
4
|
+
from cli.settings.core import ACTIVE_TOKEN_FILE_NAME
|
5
|
+
from cli.settings.migrate_token_file import InvalidTokenError, UnsupportedTokenVersionError, migrate_legacy_token
|
6
|
+
from cli.settings.token_file import TokenFile, dumps
|
7
|
+
|
8
|
+
|
9
|
+
def _migrate_legacy_tokens(tokens: list[TokenFile]) -> tuple[list[TokenFile], set[str]]:
|
10
|
+
"""
|
11
|
+
Determine which tokens can be updated and which should be removed.
|
12
|
+
|
13
|
+
Returns:
|
14
|
+
tuple of (updated_tokens, invalid_tokens)
|
15
|
+
"""
|
16
|
+
updated_tokens: list[TokenFile] = []
|
17
|
+
invalid_tokens: set[str] = set()
|
18
|
+
|
19
|
+
for token in tokens:
|
20
|
+
try:
|
21
|
+
migrated_token = migrate_legacy_token(token)
|
22
|
+
if migrated_token.version != token.version:
|
23
|
+
updated_tokens.append(migrated_token)
|
24
|
+
except (InvalidTokenError, UnsupportedTokenVersionError):
|
25
|
+
# Token not valid or unsupported version, mark for removal
|
26
|
+
invalid_tokens.add(token.name)
|
27
|
+
|
28
|
+
return updated_tokens, invalid_tokens
|
29
|
+
|
30
|
+
|
31
|
+
def _write_updated_tokens(updated_tokens: list[TokenFile]) -> None:
|
32
|
+
for updated_token in updated_tokens:
|
33
|
+
settings.remove_token_file(name=updated_token.name)
|
34
|
+
if updated_token.type == "authorized_user":
|
35
|
+
settings.add_personal_token(dumps(updated_token), overwrite_if_exists=True)
|
36
|
+
elif updated_token.type == "service_account":
|
37
|
+
settings.add_service_account_token(dumps(updated_token))
|
38
|
+
else:
|
39
|
+
raise ValueError(f"Unsupported token type: {updated_token.type}")
|
40
|
+
|
41
|
+
|
42
|
+
def _remove_invalid_tokens(invalid_tokens: set[str]) -> None:
|
43
|
+
for token_name in invalid_tokens:
|
44
|
+
settings.remove_token_file(name=token_name)
|
45
|
+
|
46
|
+
|
47
|
+
def _remove_old_secret_file() -> bool:
|
48
|
+
old_activated_secret_file = settings.config_dir / ACTIVE_TOKEN_FILE_NAME
|
49
|
+
old_secret_exists = old_activated_secret_file.exists()
|
50
|
+
if old_secret_exists:
|
51
|
+
old_activated_secret_file.unlink(missing_ok=True)
|
52
|
+
return old_secret_exists
|
53
|
+
|
54
|
+
|
55
|
+
def migrate_any_legacy_tokens(tokens: list[TokenFile]) -> bool:
|
56
|
+
"""
|
57
|
+
Migrate any legacy tokens to the latest TokenFile format.
|
58
|
+
|
59
|
+
Returns True if any tokens were migrated, False otherwise.
|
60
|
+
"""
|
61
|
+
# Get tokens to update/remove
|
62
|
+
updated_tokens, invalid_tokens = _migrate_legacy_tokens(tokens)
|
63
|
+
|
64
|
+
# Perform file operations
|
65
|
+
_write_updated_tokens(updated_tokens)
|
66
|
+
_remove_invalid_tokens(invalid_tokens)
|
67
|
+
|
68
|
+
# Remove old secret file if exists
|
69
|
+
old_secret_removed = _remove_old_secret_file()
|
70
|
+
if old_secret_removed:
|
71
|
+
return True # We migrated at least one token
|
72
|
+
|
73
|
+
# only return True if we migrated at least one token
|
74
|
+
return len(updated_tokens) + len(invalid_tokens) > 0
|
@@ -0,0 +1,52 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from cli.settings.token_file import TokenFile, TokenFileAccount
|
4
|
+
from cli.utils.rest_helper import RestHelper
|
5
|
+
|
6
|
+
|
7
|
+
class InvalidTokenError(Exception):
|
8
|
+
"""Raised when a token is invalid."""
|
9
|
+
|
10
|
+
|
11
|
+
class UnsupportedTokenVersionError(Exception):
|
12
|
+
"""Raised when a token version is not supported."""
|
13
|
+
|
14
|
+
|
15
|
+
def migrate_legacy_token(token: TokenFile) -> TokenFile:
|
16
|
+
"""
|
17
|
+
Migrate a token from a legacy format to the latest format.
|
18
|
+
|
19
|
+
Args:
|
20
|
+
token: The token to migrate.
|
21
|
+
|
22
|
+
Returns:
|
23
|
+
TokenFile: The migrated token.
|
24
|
+
|
25
|
+
Raises:
|
26
|
+
InvalidTokenError: If the token is invalid.
|
27
|
+
UnsupportedTokenVersionError: If the token version is not supported.
|
28
|
+
"""
|
29
|
+
# use a naive approach to compare versions for now
|
30
|
+
version = float(token.version)
|
31
|
+
|
32
|
+
# already migrated
|
33
|
+
if version >= 1.1:
|
34
|
+
return token
|
35
|
+
|
36
|
+
if version == 1.0:
|
37
|
+
res = RestHelper.handle_get("/api/whoami", return_response=True, allow_status_codes=[401, 400, 403], access_token=token.token)
|
38
|
+
if res.status_code != 200:
|
39
|
+
raise InvalidTokenError(f"Token {token.name} is invalid")
|
40
|
+
|
41
|
+
email = res.json()["email"]
|
42
|
+
return TokenFile(
|
43
|
+
version="1.1",
|
44
|
+
type=token.type,
|
45
|
+
name=token.name,
|
46
|
+
token=token.token,
|
47
|
+
created=token.created,
|
48
|
+
expires=token.expires,
|
49
|
+
account=TokenFileAccount(email=email),
|
50
|
+
)
|
51
|
+
|
52
|
+
raise UnsupportedTokenVersionError(f"Unsupported token version: {token.version}")
|
cli/settings/token_file.py
CHANGED
@@ -3,20 +3,85 @@ from __future__ import annotations
|
|
3
3
|
import dataclasses
|
4
4
|
import json
|
5
5
|
from dataclasses import dataclass
|
6
|
+
from datetime import date, datetime
|
7
|
+
from typing import Any, Literal
|
8
|
+
|
9
|
+
DEFAULT_EMAIL = "unknown@remotivecloud.com"
|
10
|
+
|
11
|
+
TokenType = Literal["authorized_user", "service_account"]
|
12
|
+
|
13
|
+
|
14
|
+
def _parse_date(date_str: str) -> date:
|
15
|
+
normalized = date_str.replace("Z", "+00:00")
|
16
|
+
return datetime.fromisoformat(normalized).date()
|
17
|
+
|
18
|
+
|
19
|
+
def _parse_token_type(token: str) -> TokenType:
|
20
|
+
if token.startswith("pa"):
|
21
|
+
return "authorized_user"
|
22
|
+
if token.startswith("sa"):
|
23
|
+
return "service_account"
|
24
|
+
raise ValueError(f"Unknown token type for token: {token}")
|
25
|
+
|
26
|
+
|
27
|
+
def _from_dict(d: dict[str, Any]) -> TokenFile:
|
28
|
+
if "version" not in d:
|
29
|
+
token_type = _parse_token_type(d["token"])
|
30
|
+
return TokenFile(
|
31
|
+
version="1.0",
|
32
|
+
type=token_type,
|
33
|
+
name=d["name"],
|
34
|
+
token=d["token"],
|
35
|
+
created=_parse_date(d["created"]),
|
36
|
+
expires=_parse_date(d["expires"]),
|
37
|
+
account=TokenFileAccount(email=DEFAULT_EMAIL),
|
38
|
+
)
|
39
|
+
|
40
|
+
account_email = d.get("account", {}).get("email", DEFAULT_EMAIL)
|
41
|
+
return TokenFile(
|
42
|
+
version=d["version"],
|
43
|
+
type=d["type"],
|
44
|
+
name=d["name"],
|
45
|
+
token=d["token"],
|
46
|
+
created=_parse_date(d["created"]),
|
47
|
+
expires=_parse_date(d["expires"]),
|
48
|
+
account=TokenFileAccount(email=account_email),
|
49
|
+
)
|
6
50
|
|
7
51
|
|
8
52
|
def loads(data: str) -> TokenFile:
|
9
|
-
|
10
|
-
return TokenFile(name=d["name"], token=d["token"], created=d["created"], expires=d["expires"])
|
53
|
+
return _from_dict(json.loads(data))
|
11
54
|
|
12
55
|
|
13
56
|
def dumps(token: TokenFile) -> str:
|
14
57
|
return json.dumps(dataclasses.asdict(token), default=str)
|
15
58
|
|
16
59
|
|
60
|
+
@dataclass
|
61
|
+
class TokenFileAccount:
|
62
|
+
email: str
|
63
|
+
|
64
|
+
|
17
65
|
@dataclass
|
18
66
|
class TokenFile:
|
67
|
+
version: str
|
68
|
+
type: TokenType
|
19
69
|
name: str
|
20
70
|
token: str
|
21
|
-
created:
|
22
|
-
expires:
|
71
|
+
created: date
|
72
|
+
expires: date
|
73
|
+
account: TokenFileAccount
|
74
|
+
|
75
|
+
def is_expired(self) -> bool:
|
76
|
+
return datetime.today().date() > self.expires
|
77
|
+
|
78
|
+
def expires_in_days(self) -> int:
|
79
|
+
return (self.expires - datetime.today().date()).days
|
80
|
+
|
81
|
+
@staticmethod
|
82
|
+
def from_json_str(data: str) -> TokenFile:
|
83
|
+
return loads(data)
|
84
|
+
|
85
|
+
@staticmethod
|
86
|
+
def from_dict(data: dict[str, Any]) -> TokenFile:
|
87
|
+
return _from_dict(data)
|
cli/tools/can/can.py
CHANGED
@@ -48,7 +48,7 @@ def convert(
|
|
48
48
|
with can.Logger(out_file) as writer:
|
49
49
|
for msg in reader:
|
50
50
|
writer.on_message_received(msg)
|
51
|
-
except Exception as e:
|
51
|
+
except Exception as e:
|
52
52
|
err_console.print(f":boom: [bold red]Failed to convert file[/bold red]: {e}")
|
53
53
|
|
54
54
|
|
@@ -77,5 +77,5 @@ def validate(
|
|
77
77
|
if print_to_terminal:
|
78
78
|
writer.on_message_received(msg)
|
79
79
|
console.print(f"Successfully verified {in_file}")
|
80
|
-
except Exception as e:
|
80
|
+
except Exception as e:
|
81
81
|
err_console.print(f":boom: [bold red]Failed to convert file[/bold red]: {e}")
|
cli/typer/typer_utils.py
CHANGED
@@ -1,8 +1,25 @@
|
|
1
1
|
from typing import Any
|
2
2
|
|
3
3
|
import typer
|
4
|
+
from click import Context
|
5
|
+
from rich.console import Console
|
6
|
+
from typer.core import TyperGroup
|
7
|
+
|
8
|
+
|
9
|
+
class OrderCommands(TyperGroup):
|
10
|
+
def list_commands(self, _ctx: Context): # type: ignore
|
11
|
+
return list(self.commands)
|
12
|
+
|
13
|
+
|
14
|
+
console = Console()
|
4
15
|
|
5
16
|
|
6
17
|
def create_typer(**kwargs: Any) -> typer.Typer:
|
7
18
|
"""Create a Typer instance with default settings."""
|
8
|
-
return typer.Typer(no_args_is_help=True, **kwargs)
|
19
|
+
# return typer.Typer(no_args_is_help=True, **kwargs)
|
20
|
+
return typer.Typer(cls=OrderCommands, no_args_is_help=True, **kwargs)
|
21
|
+
|
22
|
+
|
23
|
+
def print_padded(label: str, right_text: str, length: int = 30) -> None:
|
24
|
+
padded_label = label.ljust(length) # pad to 30 characters
|
25
|
+
console.print(f"{padded_label} {right_text}")
|
cli/utils/__init__.py
ADDED
File without changes
|