remotivelabs-cli 0.0.42__py3-none-any.whl → 0.1.0__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 +37 -13
- cli/cloud/auth/login.py +278 -24
- cli/cloud/auth_tokens.py +319 -12
- cli/cloud/brokers.py +3 -4
- cli/cloud/cloud_cli.py +5 -5
- cli/cloud/configs.py +1 -2
- cli/cloud/organisations.py +101 -2
- cli/cloud/projects.py +5 -6
- 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/errors.py +6 -1
- cli/remotive.py +30 -6
- cli/settings/__init__.py +1 -2
- cli/settings/config_file.py +92 -0
- cli/settings/core.py +188 -45
- 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 +114 -39
- {remotivelabs_cli-0.0.42.dist-info → remotivelabs_cli-0.1.0.dist-info}/METADATA +6 -4
- remotivelabs_cli-0.1.0.dist-info/RECORD +59 -0
- {remotivelabs_cli-0.0.42.dist-info → remotivelabs_cli-0.1.0.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.0.dist-info}/LICENSE +0 -0
- {remotivelabs_cli-0.0.42.dist-info → remotivelabs_cli-0.1.0.dist-info}/entry_points.txt +0 -0
cli/remotive.py
CHANGED
@@ -1,18 +1,25 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import os
|
2
4
|
from importlib.metadata import version
|
3
5
|
|
4
6
|
import typer
|
5
7
|
from rich import print as rich_print
|
8
|
+
from rich.console import Console
|
6
9
|
from trogon import Trogon # type: ignore
|
7
10
|
from typer.main import get_group
|
8
11
|
|
12
|
+
from cli.settings.migrate_all_token_files import migrate_any_legacy_tokens
|
13
|
+
|
9
14
|
from .broker.brokers import app as broker_app
|
10
15
|
from .cloud.cloud_cli import app as cloud_app
|
11
16
|
from .connect.connect import app as connect_app
|
12
|
-
from .settings
|
17
|
+
from .settings import settings
|
13
18
|
from .tools.tools import app as tools_app
|
14
19
|
from .typer import typer_utils
|
15
20
|
|
21
|
+
err_console = Console(stderr=True)
|
22
|
+
|
16
23
|
if os.getenv("GRPC_VERBOSITY") is None:
|
17
24
|
os.environ["GRPC_VERBOSITY"] = "NONE"
|
18
25
|
|
@@ -25,6 +32,8 @@ For documentation - https://docs.remotivelabs.com
|
|
25
32
|
""",
|
26
33
|
)
|
27
34
|
|
35
|
+
# settings.set_default_config_as_env()
|
36
|
+
|
28
37
|
|
29
38
|
def version_callback(value: bool) -> None:
|
30
39
|
if value:
|
@@ -37,17 +46,33 @@ def test_callback(value: int) -> None:
|
|
37
46
|
if value:
|
38
47
|
rich_print(value)
|
39
48
|
raise typer.Exit()
|
40
|
-
|
41
|
-
|
42
|
-
|
49
|
+
|
50
|
+
|
51
|
+
def _migrate_old_tokens() -> None:
|
52
|
+
tokens = settings.list_personal_tokens()
|
53
|
+
tokens.extend(settings.list_service_account_tokens())
|
54
|
+
if migrate_any_legacy_tokens(tokens):
|
55
|
+
err_console.print("Migrated old credentials and configuration files, you may need to login again or activate correct credentials")
|
56
|
+
|
57
|
+
|
58
|
+
def _set_default_org_as_env() -> None:
|
59
|
+
"""
|
60
|
+
If not already set, take the default organisation from file and set as env
|
61
|
+
This has to be done early before it is read
|
62
|
+
"""
|
63
|
+
if "REMOTIVE_CLOUD_ORGANIZATION" not in os.environ:
|
64
|
+
org = settings.get_cli_config().get_active_default_organisation()
|
65
|
+
if org is not None:
|
66
|
+
os.environ["REMOTIVE_CLOUD_ORGANIZATION"] = org
|
43
67
|
|
44
68
|
|
45
69
|
@app.callback()
|
46
70
|
def main(
|
47
71
|
_the_version: bool = typer.Option(None, "--version", callback=version_callback, is_eager=False, help="Print current version"),
|
48
72
|
) -> None:
|
73
|
+
_set_default_org_as_env()
|
74
|
+
_migrate_old_tokens()
|
49
75
|
# Do other global stuff, handle other global options here
|
50
|
-
return
|
51
76
|
|
52
77
|
|
53
78
|
@app.command()
|
@@ -65,6 +90,5 @@ app.add_typer(
|
|
65
90
|
name="cloud",
|
66
91
|
help="Manage resources in RemotiveCloud",
|
67
92
|
)
|
68
|
-
app.add_typer(settings_app, name="config", help="Manage access tokens")
|
69
93
|
app.add_typer(connect_app, name="connect", help="Integrations with other systems")
|
70
94
|
app.add_typer(tools_app, name="tools")
|
cli/settings/__init__.py
CHANGED
@@ -1,5 +1,4 @@
|
|
1
|
-
from cli.settings.cmd import app
|
2
1
|
from cli.settings.core import InvalidSettingsFilePathError, Settings, TokenNotFoundError, settings
|
3
2
|
from cli.settings.token_file import TokenFile
|
4
3
|
|
5
|
-
__all__ = ["
|
4
|
+
__all__ = ["settings", "TokenFile", "TokenNotFoundError", "InvalidSettingsFilePathError", "Settings"]
|
@@ -0,0 +1,92 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import dataclasses
|
4
|
+
import json
|
5
|
+
from dataclasses import dataclass
|
6
|
+
from json import JSONDecodeError
|
7
|
+
from typing import Dict, Optional
|
8
|
+
|
9
|
+
from dacite import from_dict
|
10
|
+
|
11
|
+
|
12
|
+
def loads(data: str) -> ConfigFile:
|
13
|
+
try:
|
14
|
+
d = json.loads(data)
|
15
|
+
return from_dict(ConfigFile, d)
|
16
|
+
except JSONDecodeError as e:
|
17
|
+
# ErrorPrinter.print_generic_error("Invalid json format, config.json")
|
18
|
+
raise JSONDecodeError(
|
19
|
+
f"File config.json is not valid json, please edit or remove file to have it re-created ({e.msg})", pos=e.pos, doc=e.doc
|
20
|
+
)
|
21
|
+
|
22
|
+
|
23
|
+
def dumps(config: ConfigFile) -> str:
|
24
|
+
return json.dumps(dataclasses.asdict(config), default=str)
|
25
|
+
|
26
|
+
|
27
|
+
@dataclass
|
28
|
+
class Account:
|
29
|
+
credentials_name: str
|
30
|
+
default_organization: Optional[str] = None
|
31
|
+
# Add project as well
|
32
|
+
|
33
|
+
|
34
|
+
@dataclass
|
35
|
+
class ConfigFile:
|
36
|
+
version: str = "1.0"
|
37
|
+
active: Optional[str] = None
|
38
|
+
accounts: Dict[str, Account] = dataclasses.field(default_factory=dict)
|
39
|
+
|
40
|
+
def get_active_default_organisation(self) -> Optional[str]:
|
41
|
+
active_account = self.get_active()
|
42
|
+
return active_account.default_organization if active_account is not None else None
|
43
|
+
|
44
|
+
def get_active(self) -> Optional[Account]:
|
45
|
+
if self.active is not None:
|
46
|
+
account = self.accounts.get(self.active)
|
47
|
+
if account is not None:
|
48
|
+
return account
|
49
|
+
raise KeyError(f"Activated account {self.active} is not a valid account")
|
50
|
+
return None
|
51
|
+
|
52
|
+
def activate(self, email: str) -> None:
|
53
|
+
account = self.accounts.get(email)
|
54
|
+
|
55
|
+
if account is not None:
|
56
|
+
self.active = email
|
57
|
+
else:
|
58
|
+
raise KeyError(f"Account {email} does not exists")
|
59
|
+
|
60
|
+
def get_account(self, email: str) -> Optional[Account]:
|
61
|
+
if self.accounts:
|
62
|
+
return self.accounts[email]
|
63
|
+
return None
|
64
|
+
|
65
|
+
def remove_account(self, email: str) -> None:
|
66
|
+
if self.accounts:
|
67
|
+
self.accounts.pop(email, None)
|
68
|
+
|
69
|
+
def init_account(self, email: str, token_name: str) -> None:
|
70
|
+
if self.accounts is None:
|
71
|
+
self.accounts = {}
|
72
|
+
|
73
|
+
account = self.accounts.get(email)
|
74
|
+
if not account:
|
75
|
+
account = Account(credentials_name=token_name)
|
76
|
+
else:
|
77
|
+
account.credentials_name = token_name
|
78
|
+
self.accounts[email] = account
|
79
|
+
|
80
|
+
def set_account_field(self, email: str, default_organization: Optional[str] = None) -> ConfigFile:
|
81
|
+
if self.accounts is None:
|
82
|
+
self.accounts = {}
|
83
|
+
|
84
|
+
account = self.accounts.get(email)
|
85
|
+
if not account:
|
86
|
+
raise KeyError(f"Account with email {email} has not been initialized with token")
|
87
|
+
|
88
|
+
# Update only fields explicitly passed
|
89
|
+
if default_organization is not None:
|
90
|
+
account.default_organization = default_organization
|
91
|
+
|
92
|
+
return self
|
cli/settings/core.py
CHANGED
@@ -1,15 +1,21 @@
|
|
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
|
8
|
+
from dataclasses import dataclass
|
6
9
|
from json import JSONDecodeError
|
7
10
|
from pathlib import Path
|
8
|
-
from typing import Tuple
|
11
|
+
from typing import Optional, Tuple, Union
|
9
12
|
|
10
13
|
from rich.console import Console
|
11
14
|
|
15
|
+
from cli.errors import ErrorPrinter
|
16
|
+
from cli.settings import config_file
|
12
17
|
from cli.settings import token_file as tf
|
18
|
+
from cli.settings.config_file import ConfigFile
|
13
19
|
from cli.settings.token_file import TokenFile
|
14
20
|
|
15
21
|
err_console = Console(stderr=True)
|
@@ -19,6 +25,7 @@ CONFIG_DIR_PATH = Path.home() / ".config" / "remotive"
|
|
19
25
|
INCORRECT_CONFIG_DIR_PATH = Path.home() / ".config" / ".remotive"
|
20
26
|
DEPRECATED_CONFIG_DIR_PATH = Path.home() / ".remotive"
|
21
27
|
|
28
|
+
CLI_CONFIG_FILE_NAME = "config.json"
|
22
29
|
ACTIVE_TOKEN_FILE_NAME = "cloud.secret.token"
|
23
30
|
PERSONAL_TOKEN_FILE_PREFIX = "personal-token-"
|
24
31
|
SERVICE_ACCOUNT_TOKEN_FILE_PREFIX = "service-account-token-"
|
@@ -31,10 +38,19 @@ class InvalidSettingsFilePathError(Exception):
|
|
31
38
|
"""Raised when trying to access an invalid settings file or file path"""
|
32
39
|
|
33
40
|
|
41
|
+
class NotFoundError(Exception):
|
42
|
+
"""Raised when a token cannot be found in settings"""
|
43
|
+
|
44
|
+
|
34
45
|
class TokenNotFoundError(Exception):
|
35
46
|
"""Raised when a token cannot be found in settings"""
|
36
47
|
|
37
48
|
|
49
|
+
@dataclass()
|
50
|
+
class CliConfigFile:
|
51
|
+
default_organisation: Union[str, None]
|
52
|
+
|
53
|
+
|
38
54
|
class Settings:
|
39
55
|
"""
|
40
56
|
Settings for the remotive CLI
|
@@ -45,8 +61,8 @@ class Settings:
|
|
45
61
|
def __init__(self, config_dir: Path, deprecated_config_dirs: list[Path] | None = None) -> None:
|
46
62
|
self.config_dir = config_dir
|
47
63
|
self._active_secret_token_path = self.config_dir / ACTIVE_TOKEN_FILE_NAME
|
64
|
+
self._cli_config = self.config_dir / CLI_CONFIG_FILE_NAME
|
48
65
|
|
49
|
-
# no migration of deprecated config dirs if the new config dir already exists
|
50
66
|
if self.config_dir.exists():
|
51
67
|
return
|
52
68
|
|
@@ -56,6 +72,48 @@ class Settings:
|
|
56
72
|
for deprecated_config_dir in deprecated_config_dirs:
|
57
73
|
self._migrate_legacy_config_dir(deprecated_config_dir)
|
58
74
|
|
75
|
+
# def _write_properties(self, filepath: Path, props: CliConfigFile) -> None:
|
76
|
+
# with open(filepath, "w", encoding="utf-8") as file:
|
77
|
+
# # keys = sorted(props.keys()) if sort_keys else props.keys()
|
78
|
+
# # for key in keys:
|
79
|
+
# file.write(f"default_organisation={props.default_organisation}\n")
|
80
|
+
|
81
|
+
def _read_properties(self, filepath: Path) -> CliConfigFile:
|
82
|
+
props = {}
|
83
|
+
with open(filepath, "r", encoding="utf-8") as file:
|
84
|
+
for line_num, line in enumerate(file, start=1):
|
85
|
+
line_stripped = line.strip()
|
86
|
+
if not line_stripped or line_stripped.startswith("#"):
|
87
|
+
continue
|
88
|
+
if "=" not in line_stripped:
|
89
|
+
raise ValueError(f"Invalid line format at line {line_num}: {line}")
|
90
|
+
key, value = line_stripped.split("=", 1)
|
91
|
+
key, value = key.strip(), value.strip()
|
92
|
+
if key in props:
|
93
|
+
raise ValueError(f"Duplicate key '{key}' found at line {line_num}")
|
94
|
+
props[key] = value
|
95
|
+
if "default_organisation" in props:
|
96
|
+
return CliConfigFile(default_organisation=props["default_organisation"])
|
97
|
+
return CliConfigFile(default_organisation=None)
|
98
|
+
|
99
|
+
def set_default_organisation(self, organisation: str) -> None:
|
100
|
+
cli_config = self.get_cli_config()
|
101
|
+
|
102
|
+
try:
|
103
|
+
token = settings.get_active_token_file()
|
104
|
+
cli_config.set_account_field(token.account.email, organisation)
|
105
|
+
self._write_config_file(cli_config)
|
106
|
+
except TokenNotFoundError:
|
107
|
+
ErrorPrinter.print_hint("You must have an account activated in order to set default organization")
|
108
|
+
sys.exit(1)
|
109
|
+
|
110
|
+
def get_cli_config(self) -> ConfigFile:
|
111
|
+
try:
|
112
|
+
return self._read_config_file()
|
113
|
+
except TokenNotFoundError:
|
114
|
+
return ConfigFile()
|
115
|
+
# self._write_properties(CONFIG_DIR_PATH / CLI_CONFIG_FILE_NAME, [])
|
116
|
+
|
59
117
|
def get_active_token(self) -> str:
|
60
118
|
"""
|
61
119
|
Get the current active token secret
|
@@ -67,25 +125,48 @@ class Settings:
|
|
67
125
|
"""
|
68
126
|
Get the current active token file
|
69
127
|
"""
|
70
|
-
if not self._active_secret_token_path.exists():
|
71
|
-
raise TokenNotFoundError("no active token file found")
|
72
128
|
|
73
|
-
|
129
|
+
active_account = self.get_cli_config().get_active()
|
130
|
+
if active_account is not None:
|
131
|
+
token_name = active_account.credentials_name
|
132
|
+
return self.get_token_file(token_name)
|
133
|
+
raise TokenNotFoundError
|
134
|
+
# if not self._active_secret_token_path.exists():
|
135
|
+
# raise TokenNotFoundError("no active token file found")
|
136
|
+
# return self._read_token_file(self._active_secret_token_path)
|
74
137
|
|
75
|
-
def activate_token(self,
|
138
|
+
def activate_token(self, token: TokenFile) -> None:
|
76
139
|
"""
|
77
140
|
Activate a token by name or path
|
78
141
|
|
79
142
|
The token secret will be set as the current active secret.
|
80
143
|
"""
|
81
|
-
token_file = self.get_token_file(name)
|
82
|
-
self.
|
144
|
+
# token_file = self.get_token_file(name)
|
145
|
+
cli_config = self.get_cli_config()
|
146
|
+
cli_config.activate(token.account.email)
|
147
|
+
# if token_file.account.email not in cli_config.accounts:
|
148
|
+
# cli_config.set_account_field(token_file.account.email)
|
149
|
+
self._write_config_file(cli_config)
|
150
|
+
# self._write_token_file(self._active_secret_token_path, token_file)
|
83
151
|
|
84
152
|
def clear_active_token(self) -> None:
|
85
153
|
"""
|
86
154
|
Clear the current active token
|
87
155
|
"""
|
88
|
-
self.
|
156
|
+
config = self.get_cli_config()
|
157
|
+
config.active = None
|
158
|
+
self._write_config_file(config)
|
159
|
+
|
160
|
+
# self._active_secret_token_path.unlink(missing_ok=True)
|
161
|
+
|
162
|
+
def get_token_file_by_email(self, email: str) -> Optional[TokenFile]:
|
163
|
+
tokens = [t for t in self.list_personal_tokens() if t.account is not None and t.account.email == email]
|
164
|
+
if len(tokens) > 0:
|
165
|
+
return tokens[0]
|
166
|
+
tokens = [t for t in self.list_service_account_tokens() if t.account is not None and t.account.email == email]
|
167
|
+
if len(tokens) > 0:
|
168
|
+
return tokens[0]
|
169
|
+
return None
|
89
170
|
|
90
171
|
def get_token_file(self, name: str) -> TokenFile:
|
91
172
|
"""
|
@@ -93,6 +174,8 @@ class Settings:
|
|
93
174
|
"""
|
94
175
|
if Path(name).exists():
|
95
176
|
return self._read_token_file(Path(name))
|
177
|
+
if Path(CONFIG_DIR_PATH / name).exists():
|
178
|
+
return self._read_token_file(Path(CONFIG_DIR_PATH / name))
|
96
179
|
|
97
180
|
return self._get_token_by_name(name)[0]
|
98
181
|
|
@@ -107,18 +190,18 @@ class Settings:
|
|
107
190
|
raise InvalidSettingsFilePathError(f"cannot remove a token file not located in settings dir {self.config_dir}")
|
108
191
|
return Path(name).unlink()
|
109
192
|
|
110
|
-
# TODO: what about the active token?
|
111
|
-
|
193
|
+
# TODO: what about the active token?
|
112
194
|
path = self._get_token_by_name(name)[1]
|
195
|
+
# print("Deleting", path)
|
113
196
|
return path.unlink()
|
114
197
|
|
115
|
-
def add_and_activate_short_lived_cli_token(self, token: str) -> TokenFile:
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
198
|
+
# def add_and_activate_short_lived_cli_token(self, token: str) -> TokenFile:
|
199
|
+
# """
|
200
|
+
# Activates a short lived token
|
201
|
+
# """
|
202
|
+
# token_file = tf.loads(token)
|
203
|
+
# self._write_token_file(self._active_secret_token_path, token_file)
|
204
|
+
# return token_file
|
122
205
|
|
123
206
|
def add_personal_token(
|
124
207
|
self,
|
@@ -131,15 +214,24 @@ class Settings:
|
|
131
214
|
"""
|
132
215
|
token_file = tf.loads(token)
|
133
216
|
|
134
|
-
|
217
|
+
def email_to_safe_filename(email: str) -> str:
|
218
|
+
# Replace any invalid character with an underscore
|
219
|
+
return re.sub(r'[<>:"/\\|?*]', "_", email)
|
220
|
+
|
221
|
+
# From now, user will never be None when adding a token so in this case token_file.user is never None
|
222
|
+
email = email_to_safe_filename(token_file.account.email) if token_file.account is not None else "unknown"
|
223
|
+
file = f"{PERSONAL_TOKEN_FILE_PREFIX}{token_file.name}-{email}.json"
|
135
224
|
path = self.config_dir / file
|
136
225
|
if path.exists() and not overwrite_if_exists:
|
137
226
|
raise FileExistsError(f"Token file already exists: {path}")
|
138
227
|
|
139
228
|
self._write_token_file(path, token_file)
|
229
|
+
cli_config = self.get_cli_config()
|
230
|
+
cli_config.init_account(email=token_file.account.email, token_name=token_file.name)
|
231
|
+
self._write_config_file(cli_config)
|
140
232
|
|
141
233
|
if activate:
|
142
|
-
self.activate_token(token_file
|
234
|
+
self.activate_token(token_file)
|
143
235
|
|
144
236
|
return token_file
|
145
237
|
|
@@ -155,20 +247,39 @@ class Settings:
|
|
155
247
|
"""
|
156
248
|
return [f[1] for f in self._list_personal_tokens()]
|
157
249
|
|
158
|
-
def add_service_account_token(self,
|
159
|
-
"""
|
160
|
-
Add a service account token to the config directory
|
161
|
-
"""
|
250
|
+
def add_service_account_token(self, token: str) -> TokenFile:
|
162
251
|
token_file = tf.loads(token)
|
163
252
|
|
164
|
-
|
253
|
+
def email_to_safe_filename(email: str) -> str:
|
254
|
+
# Replace any invalid character with an underscore
|
255
|
+
return re.sub(r'[<>:"/\\|?*]', "_", email)
|
256
|
+
|
257
|
+
# From now, user will never be None when adding a token so in this case token_file.user is never None
|
258
|
+
|
259
|
+
email = email_to_safe_filename(token_file.account.email) if token_file.account is not None else "unknown"
|
260
|
+
file = f"{SERVICE_ACCOUNT_TOKEN_FILE_PREFIX}{token_file.name}-{email}.json"
|
165
261
|
path = self.config_dir / file
|
166
|
-
if path.exists():
|
167
|
-
raise FileExistsError(f"Token file already exists: {path}")
|
168
262
|
|
169
263
|
self._write_token_file(path, token_file)
|
264
|
+
print(f"Service account token stored at {path}")
|
265
|
+
cli_config = self.get_cli_config()
|
266
|
+
cli_config.init_account(email=token_file.account.email, token_name=token_file.name)
|
267
|
+
self._write_config_file(cli_config)
|
268
|
+
|
269
|
+
# if activate:
|
270
|
+
# self.activate_token(token_file.account.email)
|
271
|
+
|
170
272
|
return token_file
|
171
273
|
|
274
|
+
# token_file = tf.loads(token)
|
275
|
+
# file = f"{SERVICE_ACCOUNT_TOKEN_FILE_PREFIX}{service_account}-{token_file.name}.json"
|
276
|
+
# path = self.config_dir / file
|
277
|
+
# if path.exists():
|
278
|
+
# raise FileExistsError(f"Token file already exists: {path}")
|
279
|
+
|
280
|
+
# self._write_token_file(path, token_file)
|
281
|
+
# return token_file
|
282
|
+
|
172
283
|
def list_service_account_tokens(self) -> list[TokenFile]:
|
173
284
|
"""
|
174
285
|
List all service account tokens
|
@@ -195,13 +306,25 @@ class Settings:
|
|
195
306
|
return matches[0]
|
196
307
|
|
197
308
|
def _list_token_files(self, prefix: str = "") -> list[TokenFileMetadata]:
|
198
|
-
|
199
|
-
|
309
|
+
"""list all tokens with the correct prefix in the config dir, but omit files that are not token files"""
|
310
|
+
|
311
|
+
def is_valid_json(path: Path) -> bool:
|
312
|
+
try:
|
313
|
+
self._read_token_file(path)
|
314
|
+
return True
|
315
|
+
except JSONDecodeError:
|
316
|
+
# TODO - this should be printed but printing it here causes it to be displayed to many times
|
317
|
+
# err_console.print(f"File is not valid json, skipping. {path}")
|
318
|
+
return False
|
319
|
+
|
320
|
+
def is_valid_token_file(path: Path) -> bool:
|
321
|
+
is_token_file = path.name.startswith(SERVICE_ACCOUNT_TOKEN_FILE_PREFIX) or path.name.startswith(PERSONAL_TOKEN_FILE_PREFIX)
|
200
322
|
has_correct_prefix = path.is_file() and path.name.startswith(prefix)
|
201
323
|
is_active_secret = path == self._active_secret_token_path
|
202
|
-
|
324
|
+
is_cli_config = path == self._cli_config
|
325
|
+
return is_token_file and is_valid_json(path) and has_correct_prefix and not is_active_secret and not is_cli_config
|
203
326
|
|
204
|
-
paths = [path for path in self.config_dir.iterdir() if
|
327
|
+
paths = [path for path in self.config_dir.iterdir() if is_valid_token_file(path)]
|
205
328
|
|
206
329
|
return [(self._read_token_file(token_file), token_file) for token_file in paths]
|
207
330
|
|
@@ -209,6 +332,10 @@ class Settings:
|
|
209
332
|
data = self._read_file(path)
|
210
333
|
return tf.loads(data)
|
211
334
|
|
335
|
+
def _read_config_file(self) -> ConfigFile:
|
336
|
+
data = self._read_file(self.config_dir / CLI_CONFIG_FILE_NAME)
|
337
|
+
return config_file.loads(data)
|
338
|
+
|
212
339
|
def _read_file(self, path: Path) -> str:
|
213
340
|
if not path.exists():
|
214
341
|
raise TokenNotFoundError(f"File could not be found: {path}")
|
@@ -216,7 +343,15 @@ class Settings:
|
|
216
343
|
|
217
344
|
def _write_token_file(self, path: Path, token: TokenFile) -> Path:
|
218
345
|
data = tf.dumps(token)
|
219
|
-
|
346
|
+
path = self._write_file(path, data)
|
347
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
348
|
+
return path
|
349
|
+
|
350
|
+
def _write_config_file(self, config: ConfigFile) -> Path:
|
351
|
+
data = config_file.dumps(config)
|
352
|
+
path = self._write_file(self._cli_config, data)
|
353
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
354
|
+
return path
|
220
355
|
|
221
356
|
def _write_file(self, path: Path, data: str) -> Path:
|
222
357
|
if self.config_dir not in path.parents:
|
@@ -233,24 +368,32 @@ class Settings:
|
|
233
368
|
shutil.copytree(str(path), str(self.config_dir), dirs_exist_ok=True)
|
234
369
|
secret = path / ACTIVE_TOKEN_FILE_NAME
|
235
370
|
if secret.exists():
|
236
|
-
|
371
|
+
sys.stderr.write(f"Removing old activated token {secret}")
|
372
|
+
secret.unlink(missing_ok=True)
|
373
|
+
# value = secret.read_text(encoding="utf-8").strip()
|
237
374
|
# 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
|
-
|
375
|
+
# try:
|
376
|
+
# token = tf.loads(value)
|
377
|
+
# except JSONDecodeError:
|
378
|
+
# token = tf.TokenFile(
|
379
|
+
# version="1.0",
|
380
|
+
# type="service-account" if value.startswith("sa") else "authorized_user",
|
381
|
+
# name="MigratedActiveToken",
|
382
|
+
# token=value,
|
383
|
+
# created=str(datetime.datetime.now().isoformat()),
|
384
|
+
# expires="unknown",
|
385
|
+
# account=TokenFileAccount(email="unknown@remotivecloud.com"),
|
386
|
+
# )
|
387
|
+
# self.add_and_activate_short_lived_cli_token(tf.dumps(token))
|
248
388
|
shutil.rmtree(str(path))
|
249
389
|
|
250
390
|
|
251
391
|
def create_settings() -> Settings:
|
252
392
|
"""Create remotive CLI config directory and return its settings instance"""
|
253
|
-
return Settings(
|
393
|
+
return Settings(
|
394
|
+
CONFIG_DIR_PATH,
|
395
|
+
deprecated_config_dirs=[DEPRECATED_CONFIG_DIR_PATH, INCORRECT_CONFIG_DIR_PATH],
|
396
|
+
)
|
254
397
|
|
255
398
|
|
256
399
|
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}")
|