remotivelabs-cli 0.1.1__py3-none-any.whl → 0.2.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/cloud/auth/cmd.py +2 -5
- cli/cloud/auth/login.py +2 -1
- cli/cloud/auth_tokens.py +29 -14
- cli/cloud/organisations.py +3 -3
- cli/cloud/recordings.py +7 -5
- cli/errors.py +1 -1
- cli/remotive.py +55 -19
- cli/settings/__init__.py +18 -1
- cli/settings/config_file.py +32 -31
- cli/settings/core.py +68 -171
- cli/settings/migration/__init__.py +0 -0
- cli/settings/{migrate_all_token_files.py → migration/migrate_all_token_files.py} +18 -12
- cli/settings/migration/migrate_config_file.py +59 -0
- cli/settings/migration/migrate_legacy_dirs.py +50 -0
- cli/settings/migration/migration_tools.py +36 -0
- cli/settings/token_file.py +14 -0
- cli/topology/cmd.py +101 -0
- {remotivelabs_cli-0.1.1.dist-info → remotivelabs_cli-0.2.0.dist-info}/METADATA +1 -1
- {remotivelabs_cli-0.1.1.dist-info → remotivelabs_cli-0.2.0.dist-info}/RECORD +23 -18
- /cli/settings/{migrate_token_file.py → migration/migrate_token_file.py} +0 -0
- {remotivelabs_cli-0.1.1.dist-info → remotivelabs_cli-0.2.0.dist-info}/LICENSE +0 -0
- {remotivelabs_cli-0.1.1.dist-info → remotivelabs_cli-0.2.0.dist-info}/WHEEL +0 -0
- {remotivelabs_cli-0.1.1.dist-info → remotivelabs_cli-0.2.0.dist-info}/entry_points.txt +0 -0
cli/cloud/auth/cmd.py
CHANGED
@@ -27,9 +27,6 @@ def login(browser: bool = typer.Option(default=True, help="Does not automaticall
|
|
27
27
|
If not able to open a browser it will show fallback to headless login and show a link that
|
28
28
|
users can copy into any browser when this is unsupported where running the cli - such as in docker,
|
29
29
|
virtual machine or ssh sessions.
|
30
|
-
|
31
|
-
This will be used as the current access token in all subsequent requests. This would
|
32
|
-
be the same as activating a personal access key or service-account access key.
|
33
30
|
"""
|
34
31
|
do_login(headless=not browser)
|
35
32
|
|
@@ -61,9 +58,9 @@ def print_access_token(
|
|
61
58
|
else:
|
62
59
|
config = settings.get_cli_config()
|
63
60
|
if account in config.accounts:
|
64
|
-
|
61
|
+
token_file_name = config.accounts[account].credentials_file
|
65
62
|
try:
|
66
|
-
print(settings.get_token_file(
|
63
|
+
print(settings.get_token_file(token_file_name).token)
|
67
64
|
except TokenNotFoundError:
|
68
65
|
ErrorPrinter.print_generic_error(f"Token file for {account} could not be found", exit_code=1)
|
69
66
|
else:
|
cli/cloud/auth/login.py
CHANGED
@@ -18,7 +18,8 @@ from typing_extensions import override
|
|
18
18
|
|
19
19
|
from cli.cloud.auth_tokens import do_activate, prompt_to_set_org
|
20
20
|
from cli.errors import ErrorPrinter
|
21
|
-
from cli.settings import
|
21
|
+
from cli.settings import TokenNotFoundError, settings
|
22
|
+
from cli.settings.token_file import TokenFile
|
22
23
|
from cli.utils.rest_helper import RestHelper as Rest
|
23
24
|
|
24
25
|
httpd: HTTPServer
|
cli/cloud/auth_tokens.py
CHANGED
@@ -9,7 +9,9 @@ from rich.table import Table
|
|
9
9
|
from cli.api.cloud import tokens
|
10
10
|
from cli.cloud.organisations import do_select_default_org
|
11
11
|
from cli.errors import ErrorPrinter
|
12
|
-
from cli.settings import
|
12
|
+
from cli.settings import TokenNotFoundError, settings
|
13
|
+
from cli.settings.config_file import Account
|
14
|
+
from cli.settings.token_file import TokenFile
|
13
15
|
from cli.typer import typer_utils
|
14
16
|
from cli.utils.rest_helper import RestHelper as Rest
|
15
17
|
|
@@ -27,10 +29,6 @@ def _prompt_choice( # noqa: C901, PLR0912
|
|
27
29
|
info_message: Optional[str] = None,
|
28
30
|
) -> Optional[TokenFile]:
|
29
31
|
accounts = settings.get_cli_config().accounts
|
30
|
-
try:
|
31
|
-
active_account = settings.get_cli_config().get_active()
|
32
|
-
except TokenNotFoundError:
|
33
|
-
active_account = None
|
34
32
|
|
35
33
|
table = Table("#", "Active", "Type", "Token", "Account", "Created", "Expires")
|
36
34
|
|
@@ -39,8 +37,13 @@ def _prompt_choice( # noqa: C901, PLR0912
|
|
39
37
|
|
40
38
|
for token in choices:
|
41
39
|
account = accounts.get(token.account.email)
|
42
|
-
if account and account.
|
43
|
-
|
40
|
+
if account and account.credentials_file:
|
41
|
+
try:
|
42
|
+
token_file = settings.get_token_file(account.credentials_file)
|
43
|
+
if token_file.name in (token.name or ""):
|
44
|
+
included_tokens.append(token)
|
45
|
+
except TokenNotFoundError:
|
46
|
+
excluded_tokens.append(token)
|
44
47
|
else:
|
45
48
|
excluded_tokens.append(token)
|
46
49
|
|
@@ -49,10 +52,27 @@ def _prompt_choice( # noqa: C901, PLR0912
|
|
49
52
|
|
50
53
|
included_tokens.sort(key=lambda token: token.created, reverse=True)
|
51
54
|
|
55
|
+
def get_active_account_or_none() -> Optional[Account]:
|
56
|
+
try:
|
57
|
+
return settings.get_cli_config().get_active()
|
58
|
+
except TokenNotFoundError:
|
59
|
+
return None
|
60
|
+
|
61
|
+
def get_active_token_or_none() -> Optional[TokenFile]:
|
62
|
+
try:
|
63
|
+
active_account = get_active_account_or_none()
|
64
|
+
if active_account is not None:
|
65
|
+
return settings.get_token_file(active_account.credentials_file)
|
66
|
+
except TokenNotFoundError:
|
67
|
+
pass
|
68
|
+
return None
|
69
|
+
|
70
|
+
active_token = get_active_token_or_none()
|
52
71
|
active_token_index = None
|
53
72
|
for idx, choice in enumerate(included_tokens, start=1):
|
54
|
-
is_active =
|
73
|
+
is_active = active_token is not None and active_token.name == choice.name
|
55
74
|
active_token_index = idx if is_active else active_token_index
|
75
|
+
|
56
76
|
table.add_row(
|
57
77
|
f"[yellow]{idx}",
|
58
78
|
":white_check_mark:" if is_active else "",
|
@@ -62,7 +82,6 @@ def _prompt_choice( # noqa: C901, PLR0912
|
|
62
82
|
str(choice.created),
|
63
83
|
str(choice.expires),
|
64
84
|
)
|
65
|
-
# console.print("It seems like you have access tokens from previous login, you can select one of these instead of logging in")
|
66
85
|
console.print(table)
|
67
86
|
|
68
87
|
if skip_prompt:
|
@@ -165,9 +184,7 @@ def select_personal_token(
|
|
165
184
|
do_activate(token_name)
|
166
185
|
|
167
186
|
|
168
|
-
def do_activate(
|
169
|
-
token_name: Optional[str],
|
170
|
-
) -> Optional[TokenFile]:
|
187
|
+
def do_activate(token_name: Optional[str]) -> Optional[TokenFile]:
|
171
188
|
if token_name is not None:
|
172
189
|
try:
|
173
190
|
token_file = settings.get_token_file(token_name)
|
@@ -208,8 +225,6 @@ def list_and_select_personal_token(
|
|
208
225
|
sa_tokens = settings.list_service_account_tokens()
|
209
226
|
personal_tokens.extend(sa_tokens)
|
210
227
|
|
211
|
-
# merged = _merge_local_tokens_with_cloud(personal_tokens)
|
212
|
-
|
213
228
|
selected_token = _prompt_choice(personal_tokens, skip_prompt=skip_prompt, info_message=info_message)
|
214
229
|
if selected_token is not None:
|
215
230
|
settings.activate_token(selected_token)
|
cli/cloud/organisations.py
CHANGED
@@ -76,7 +76,7 @@ def do_select_default_org(organisation_uid: Optional[str] = None, get: bool = Fa
|
|
76
76
|
"""
|
77
77
|
if get:
|
78
78
|
default_organisation = settings.get_cli_config().get_active_default_organisation()
|
79
|
-
if default_organisation
|
79
|
+
if default_organisation:
|
80
80
|
console.print(default_organisation)
|
81
81
|
else:
|
82
82
|
console.print("No default organization set")
|
@@ -84,8 +84,8 @@ def do_select_default_org(organisation_uid: Optional[str] = None, get: bool = Fa
|
|
84
84
|
settings.set_default_organisation(organisation_uid)
|
85
85
|
else:
|
86
86
|
account = settings.get_cli_config().get_active()
|
87
|
-
if account
|
88
|
-
token = settings.get_token_file(account.
|
87
|
+
if account:
|
88
|
+
token = settings.get_token_file(account.credentials_file)
|
89
89
|
if token.type != "authorized_user":
|
90
90
|
ErrorPrinter.print_hint(
|
91
91
|
"You must supply the organization name as argument when using a service-account since the "
|
cli/cloud/recordings.py
CHANGED
@@ -137,14 +137,13 @@ def mount( # noqa: C901
|
|
137
137
|
r = Rest.handle_get(url=f"/api/project/{project}/brokers/personal", return_response=True, allow_status_codes=[404])
|
138
138
|
|
139
139
|
if r.status_code == 200:
|
140
|
-
|
141
|
-
|
140
|
+
broker_info = r.json()
|
141
|
+
broker = broker_info["shortName"]
|
142
142
|
elif r.status_code == 404:
|
143
143
|
r = do_start("personal", project, "", return_response=True)
|
144
144
|
if r.status_code != 200:
|
145
145
|
print(r.text)
|
146
146
|
sys.exit(0)
|
147
|
-
broker = r.json()["shortName"]
|
148
147
|
else:
|
149
148
|
sys.stderr.write(f"Got http status code {r.status_code}")
|
150
149
|
raise typer.Exit(0)
|
@@ -154,7 +153,6 @@ def mount( # noqa: C901
|
|
154
153
|
if r.status_code == 404:
|
155
154
|
if ensure_broker_started:
|
156
155
|
r = do_start(broker, project, "", return_response=True)
|
157
|
-
|
158
156
|
if r.status_code != 200:
|
159
157
|
print(r.text)
|
160
158
|
sys.exit(1)
|
@@ -164,6 +162,9 @@ def mount( # noqa: C901
|
|
164
162
|
elif r.status_code != 200:
|
165
163
|
sys.stderr.write(f"Got http status code {r.status_code}")
|
166
164
|
raise typer.Exit(1)
|
165
|
+
|
166
|
+
broker_info = r.json()
|
167
|
+
broker = broker_info["shortName"]
|
167
168
|
broker_config_query = ""
|
168
169
|
if transformation_name != "default":
|
169
170
|
broker_config_query = f"?brokerConfigName={transformation_name}"
|
@@ -174,7 +175,8 @@ def mount( # noqa: C901
|
|
174
175
|
return_response=True,
|
175
176
|
progress_label="Preparing recording on broker...",
|
176
177
|
)
|
177
|
-
print("Successfully mounted recording on broker")
|
178
|
+
err_console.print("Successfully mounted recording on broker")
|
179
|
+
print(json.dumps(broker_info))
|
178
180
|
|
179
181
|
|
180
182
|
@app.command(help="Downloads the specified recording file to disk")
|
cli/errors.py
CHANGED
cli/remotive.py
CHANGED
@@ -6,20 +6,29 @@ from importlib.metadata import version
|
|
6
6
|
import typer
|
7
7
|
from rich import print as rich_print
|
8
8
|
from rich.console import Console
|
9
|
-
from trogon import Trogon
|
9
|
+
from trogon import Trogon
|
10
10
|
from typer.main import get_group
|
11
11
|
|
12
|
-
from cli.
|
13
|
-
|
14
|
-
from .
|
15
|
-
from .
|
16
|
-
from .
|
17
|
-
from .settings import
|
18
|
-
from .
|
19
|
-
from .
|
12
|
+
from cli.broker.brokers import app as broker_app
|
13
|
+
from cli.cloud.cloud_cli import app as cloud_app
|
14
|
+
from cli.connect.connect import app as connect_app
|
15
|
+
from cli.settings import settings
|
16
|
+
from cli.settings.core import Settings
|
17
|
+
from cli.settings.migration.migrate_all_token_files import migrate_any_legacy_tokens
|
18
|
+
from cli.settings.migration.migrate_config_file import migrate_config_file
|
19
|
+
from cli.settings.migration.migrate_legacy_dirs import migrate_legacy_settings_dirs
|
20
|
+
from cli.tools.tools import app as tools_app
|
21
|
+
from cli.topology.cmd import app as topology_app
|
22
|
+
from cli.typer import typer_utils
|
20
23
|
|
21
24
|
err_console = Console(stderr=True)
|
22
25
|
|
26
|
+
|
27
|
+
def is_featue_flag_enabled(env_var: str) -> bool:
|
28
|
+
"""Check if an environment variable indicates a feature is enabled."""
|
29
|
+
return os.getenv(env_var, "").lower() in ("true", "1", "yes", "on")
|
30
|
+
|
31
|
+
|
23
32
|
if os.getenv("GRPC_VERBOSITY") is None:
|
24
33
|
os.environ["GRPC_VERBOSITY"] = "NONE"
|
25
34
|
|
@@ -32,8 +41,6 @@ For documentation - https://docs.remotivelabs.com
|
|
32
41
|
""",
|
33
42
|
)
|
34
43
|
|
35
|
-
# settings.set_default_config_as_env()
|
36
|
-
|
37
44
|
|
38
45
|
def version_callback(value: bool) -> None:
|
39
46
|
if value:
|
@@ -48,14 +55,26 @@ def test_callback(value: int) -> None:
|
|
48
55
|
raise typer.Exit()
|
49
56
|
|
50
57
|
|
51
|
-
def
|
52
|
-
|
53
|
-
|
54
|
-
|
58
|
+
def run_migrations(settings: Settings) -> None:
|
59
|
+
"""
|
60
|
+
Run all migration scripts.
|
61
|
+
|
62
|
+
Each migration script is responsible for a particular migration, and order matters.
|
63
|
+
"""
|
64
|
+
# 1. Migrate legacy settings dirs
|
65
|
+
migrate_legacy_settings_dirs(settings.config_dir)
|
66
|
+
|
67
|
+
# 2. Migrate any legacy tokens
|
68
|
+
has_migrated_tokens = migrate_any_legacy_tokens(settings)
|
69
|
+
|
70
|
+
# 3. Migrate legacy config file format
|
71
|
+
migrate_config_file(settings.config_file_path, settings)
|
72
|
+
|
73
|
+
if has_migrated_tokens:
|
55
74
|
err_console.print("Migrated old credentials and configuration files, you may need to login again or activate correct credentials")
|
56
75
|
|
57
76
|
|
58
|
-
def
|
77
|
+
def set_default_org_as_env(settings: Settings) -> None:
|
59
78
|
"""
|
60
79
|
If not already set, take the default organisation from file and set as env
|
61
80
|
This has to be done early before it is read
|
@@ -68,10 +87,16 @@ def _set_default_org_as_env() -> None:
|
|
68
87
|
|
69
88
|
@app.callback()
|
70
89
|
def main(
|
71
|
-
_the_version: bool = typer.Option(
|
90
|
+
_the_version: bool = typer.Option(
|
91
|
+
None,
|
92
|
+
"--version",
|
93
|
+
callback=version_callback,
|
94
|
+
is_eager=False,
|
95
|
+
help="Print current version",
|
96
|
+
),
|
72
97
|
) -> None:
|
73
|
-
|
74
|
-
|
98
|
+
run_migrations(settings)
|
99
|
+
set_default_org_as_env(settings)
|
75
100
|
# Do other global stuff, handle other global options here
|
76
101
|
|
77
102
|
|
@@ -92,3 +117,14 @@ app.add_typer(
|
|
92
117
|
)
|
93
118
|
app.add_typer(connect_app, name="connect", help="Integrations with other systems")
|
94
119
|
app.add_typer(tools_app, name="tools")
|
120
|
+
|
121
|
+
if is_featue_flag_enabled("REMOTIVE_TOPOLOGY_ENABLED"):
|
122
|
+
app.add_typer(
|
123
|
+
topology_app,
|
124
|
+
name="topology",
|
125
|
+
help="""
|
126
|
+
RemotiveTopology actions
|
127
|
+
|
128
|
+
Read more at https://docs.remotivelabs.com/docs/remotive-topology
|
129
|
+
""",
|
130
|
+
)
|
cli/settings/__init__.py
CHANGED
@@ -1,4 +1,21 @@
|
|
1
|
+
from cli.settings.config_file import Account, ConfigFile
|
2
|
+
from cli.settings.config_file import dumps as dumps_config_file
|
3
|
+
from cli.settings.config_file import loads as loads_config_file
|
1
4
|
from cli.settings.core import InvalidSettingsFilePathError, Settings, TokenNotFoundError, settings
|
2
5
|
from cli.settings.token_file import TokenFile
|
6
|
+
from cli.settings.token_file import dumps as dumps_token_file
|
7
|
+
from cli.settings.token_file import loads as loads_token_file
|
3
8
|
|
4
|
-
__all__ = [
|
9
|
+
__all__ = [
|
10
|
+
"settings",
|
11
|
+
"TokenNotFoundError",
|
12
|
+
"InvalidSettingsFilePathError",
|
13
|
+
"Settings",
|
14
|
+
"TokenFile",
|
15
|
+
"ConfigFile",
|
16
|
+
"Account",
|
17
|
+
"dumps_config_file",
|
18
|
+
"loads_config_file",
|
19
|
+
"dumps_token_file",
|
20
|
+
"loads_token_file",
|
21
|
+
]
|
cli/settings/config_file.py
CHANGED
@@ -3,21 +3,16 @@ from __future__ import annotations
|
|
3
3
|
import dataclasses
|
4
4
|
import json
|
5
5
|
from dataclasses import dataclass
|
6
|
-
from
|
7
|
-
from typing import Dict, Optional
|
6
|
+
from typing import Any, Optional
|
8
7
|
|
9
8
|
from dacite import from_dict
|
10
9
|
|
10
|
+
from cli.settings.token_file import TokenFile
|
11
|
+
|
11
12
|
|
12
13
|
def loads(data: str) -> ConfigFile:
|
13
|
-
|
14
|
-
|
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
|
-
)
|
14
|
+
d = json.loads(data)
|
15
|
+
return from_dict(ConfigFile, d)
|
21
16
|
|
22
17
|
|
23
18
|
def dumps(config: ConfigFile) -> str:
|
@@ -26,7 +21,7 @@ def dumps(config: ConfigFile) -> str:
|
|
26
21
|
|
27
22
|
@dataclass
|
28
23
|
class Account:
|
29
|
-
|
24
|
+
credentials_file: str
|
30
25
|
default_organization: Optional[str] = None
|
31
26
|
# Add project as well
|
32
27
|
|
@@ -35,53 +30,51 @@ class Account:
|
|
35
30
|
class ConfigFile:
|
36
31
|
version: str = "1.0"
|
37
32
|
active: Optional[str] = None
|
38
|
-
accounts:
|
33
|
+
accounts: dict[str, Account] = dataclasses.field(default_factory=dict)
|
39
34
|
|
40
35
|
def get_active_default_organisation(self) -> Optional[str]:
|
41
36
|
active_account = self.get_active()
|
42
|
-
return active_account.default_organization if active_account
|
37
|
+
return active_account.default_organization if active_account else None
|
43
38
|
|
44
39
|
def get_active(self) -> Optional[Account]:
|
45
|
-
if self.active
|
46
|
-
|
47
|
-
|
48
|
-
|
40
|
+
if not self.active:
|
41
|
+
return None
|
42
|
+
account = self.get_account(self.active)
|
43
|
+
if not account:
|
49
44
|
raise KeyError(f"Activated account {self.active} is not a valid account")
|
50
|
-
return
|
45
|
+
return account
|
51
46
|
|
52
47
|
def activate(self, email: str) -> None:
|
53
|
-
account = self.
|
54
|
-
|
55
|
-
if account is not None:
|
56
|
-
self.active = email
|
57
|
-
else:
|
48
|
+
account = self.get_account(email)
|
49
|
+
if not account:
|
58
50
|
raise KeyError(f"Account {email} does not exists")
|
51
|
+
self.active = email
|
59
52
|
|
60
53
|
def get_account(self, email: str) -> Optional[Account]:
|
61
|
-
if self.accounts:
|
62
|
-
return
|
63
|
-
return None
|
54
|
+
if not self.accounts:
|
55
|
+
return None
|
56
|
+
return self.accounts.get(email, None)
|
64
57
|
|
65
58
|
def remove_account(self, email: str) -> None:
|
66
59
|
if self.accounts:
|
67
60
|
self.accounts.pop(email, None)
|
68
61
|
|
69
|
-
def init_account(self, email: str,
|
62
|
+
def init_account(self, email: str, token_file: TokenFile) -> None:
|
70
63
|
if self.accounts is None:
|
71
64
|
self.accounts = {}
|
72
65
|
|
73
|
-
account = self.
|
66
|
+
account = self.get_account(email)
|
74
67
|
if not account:
|
75
|
-
account = Account(
|
68
|
+
account = Account(credentials_file=token_file.get_token_file_name())
|
76
69
|
else:
|
77
|
-
account.
|
70
|
+
account.credentials_file = token_file.get_token_file_name()
|
78
71
|
self.accounts[email] = account
|
79
72
|
|
80
73
|
def set_account_field(self, email: str, default_organization: Optional[str] = None) -> ConfigFile:
|
81
74
|
if self.accounts is None:
|
82
75
|
self.accounts = {}
|
83
76
|
|
84
|
-
account = self.
|
77
|
+
account = self.get_account(email)
|
85
78
|
if not account:
|
86
79
|
raise KeyError(f"Account with email {email} has not been initialized with token")
|
87
80
|
|
@@ -90,3 +83,11 @@ class ConfigFile:
|
|
90
83
|
account.default_organization = default_organization
|
91
84
|
|
92
85
|
return self
|
86
|
+
|
87
|
+
@staticmethod
|
88
|
+
def from_json_str(data: str) -> ConfigFile:
|
89
|
+
return loads(data)
|
90
|
+
|
91
|
+
@staticmethod
|
92
|
+
def from_dict(data: dict[str, Any]) -> ConfigFile:
|
93
|
+
return from_dict(ConfigFile, data)
|
cli/settings/core.py
CHANGED
@@ -1,19 +1,16 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import os
|
4
|
-
import re
|
5
|
-
import shutil
|
6
4
|
import stat
|
7
5
|
import sys
|
8
|
-
from dataclasses import dataclass
|
9
6
|
from json import JSONDecodeError
|
10
7
|
from pathlib import Path
|
11
|
-
from typing import Optional, Tuple
|
8
|
+
from typing import Optional, Tuple
|
12
9
|
|
13
10
|
from rich.console import Console
|
14
11
|
|
15
12
|
from cli.errors import ErrorPrinter
|
16
|
-
from cli.settings import config_file
|
13
|
+
from cli.settings import config_file, token_file
|
17
14
|
from cli.settings import token_file as tf
|
18
15
|
from cli.settings.config_file import ConfigFile
|
19
16
|
from cli.settings.token_file import TokenFile
|
@@ -22,13 +19,7 @@ err_console = Console(stderr=True)
|
|
22
19
|
|
23
20
|
|
24
21
|
CONFIG_DIR_PATH = Path.home() / ".config" / "remotive"
|
25
|
-
INCORRECT_CONFIG_DIR_PATH = Path.home() / ".config" / ".remotive"
|
26
|
-
DEPRECATED_CONFIG_DIR_PATH = Path.home() / ".remotive"
|
27
|
-
|
28
22
|
CLI_CONFIG_FILE_NAME = "config.json"
|
29
|
-
ACTIVE_TOKEN_FILE_NAME = "cloud.secret.token"
|
30
|
-
PERSONAL_TOKEN_FILE_PREFIX = "personal-token-"
|
31
|
-
SERVICE_ACCOUNT_TOKEN_FILE_PREFIX = "service-account-token-"
|
32
23
|
|
33
24
|
|
34
25
|
TokenFileMetadata = Tuple[TokenFile, Path]
|
@@ -38,67 +29,26 @@ class InvalidSettingsFilePathError(Exception):
|
|
38
29
|
"""Raised when trying to access an invalid settings file or file path"""
|
39
30
|
|
40
31
|
|
41
|
-
class NotFoundError(Exception):
|
42
|
-
"""Raised when a token cannot be found in settings"""
|
43
|
-
|
44
|
-
|
45
32
|
class TokenNotFoundError(Exception):
|
46
33
|
"""Raised when a token cannot be found in settings"""
|
47
34
|
|
48
35
|
|
49
|
-
@dataclass()
|
50
|
-
class CliConfigFile:
|
51
|
-
default_organisation: Union[str, None]
|
52
|
-
|
53
|
-
|
54
36
|
class Settings:
|
55
37
|
"""
|
56
|
-
Settings for the remotive CLI
|
38
|
+
Settings handles tokens and other config for the remotive CLI
|
57
39
|
"""
|
58
40
|
|
59
41
|
config_dir: Path
|
60
42
|
|
61
|
-
def __init__(self, config_dir: Path
|
43
|
+
def __init__(self, config_dir: Path) -> None:
|
62
44
|
self.config_dir = config_dir
|
63
|
-
self._active_secret_token_path = self.config_dir / ACTIVE_TOKEN_FILE_NAME
|
64
|
-
self._cli_config = self.config_dir / CLI_CONFIG_FILE_NAME
|
65
|
-
|
66
|
-
if self.config_dir.exists():
|
67
|
-
return
|
68
|
-
|
69
|
-
# create the config dir and try to migrate legacy config dirs if they exist
|
70
45
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
71
|
-
|
72
|
-
|
73
|
-
|
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)
|
46
|
+
self.config_file_path = self.config_dir / CLI_CONFIG_FILE_NAME
|
47
|
+
if not self.config_file_path.exists():
|
48
|
+
self._write_config_file(ConfigFile())
|
98
49
|
|
99
50
|
def set_default_organisation(self, organisation: str) -> None:
|
100
51
|
cli_config = self.get_cli_config()
|
101
|
-
|
102
52
|
try:
|
103
53
|
token = settings.get_active_token_file()
|
104
54
|
cli_config.set_account_field(token.account.email, organisation)
|
@@ -112,7 +62,6 @@ class Settings:
|
|
112
62
|
return self._read_config_file()
|
113
63
|
except TokenNotFoundError:
|
114
64
|
return ConfigFile()
|
115
|
-
# self._write_properties(CONFIG_DIR_PATH / CLI_CONFIG_FILE_NAME, [])
|
116
65
|
|
117
66
|
def get_active_token(self) -> str:
|
118
67
|
"""
|
@@ -125,29 +74,22 @@ class Settings:
|
|
125
74
|
"""
|
126
75
|
Get the current active token file
|
127
76
|
"""
|
128
|
-
|
129
77
|
active_account = self.get_cli_config().get_active()
|
130
78
|
if active_account is not None:
|
131
|
-
|
132
|
-
return self.
|
79
|
+
token_file_name = active_account.credentials_file
|
80
|
+
return self._read_token_file(self.config_dir / token_file_name)
|
81
|
+
|
133
82
|
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)
|
137
83
|
|
138
|
-
def activate_token(self,
|
84
|
+
def activate_token(self, token_file: TokenFile) -> None:
|
139
85
|
"""
|
140
86
|
Activate a token by name or path
|
141
87
|
|
142
88
|
The token secret will be set as the current active secret.
|
143
89
|
"""
|
144
|
-
# token_file = self.get_token_file(name)
|
145
90
|
cli_config = self.get_cli_config()
|
146
|
-
cli_config.activate(
|
147
|
-
# if token_file.account.email not in cli_config.accounts:
|
148
|
-
# cli_config.set_account_field(token_file.account.email)
|
91
|
+
cli_config.activate(token_file.account.email)
|
149
92
|
self._write_config_file(cli_config)
|
150
|
-
# self._write_token_file(self._active_secret_token_path, token_file)
|
151
93
|
|
152
94
|
def clear_active_token(self) -> None:
|
153
95
|
"""
|
@@ -157,9 +99,12 @@ class Settings:
|
|
157
99
|
config.active = None
|
158
100
|
self._write_config_file(config)
|
159
101
|
|
160
|
-
# self._active_secret_token_path.unlink(missing_ok=True)
|
161
|
-
|
162
102
|
def get_token_file_by_email(self, email: str) -> Optional[TokenFile]:
|
103
|
+
"""
|
104
|
+
Get a token file by email.
|
105
|
+
|
106
|
+
If multiple tokens are found, the first one is returned.
|
107
|
+
"""
|
163
108
|
tokens = [t for t in self.list_personal_tokens() if t.account is not None and t.account.email == email]
|
164
109
|
if len(tokens) > 0:
|
165
110
|
return tokens[0]
|
@@ -172,11 +117,15 @@ class Settings:
|
|
172
117
|
"""
|
173
118
|
Get a token file by name or path
|
174
119
|
"""
|
120
|
+
# 1. Try relative path
|
121
|
+
if (self.config_dir / name).exists():
|
122
|
+
return self._read_token_file(self.config_dir / name)
|
123
|
+
|
124
|
+
# 2. Try absolute path
|
175
125
|
if Path(name).exists():
|
176
126
|
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))
|
179
127
|
|
128
|
+
# 3. Try name
|
180
129
|
return self._get_token_by_name(name)[0]
|
181
130
|
|
182
131
|
def remove_token_file(self, name: str) -> None:
|
@@ -192,48 +141,30 @@ class Settings:
|
|
192
141
|
|
193
142
|
# TODO: what about the active token?
|
194
143
|
path = self._get_token_by_name(name)[1]
|
195
|
-
# print("Deleting", path)
|
196
144
|
return path.unlink()
|
197
145
|
|
198
|
-
|
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
|
205
|
-
|
206
|
-
def add_personal_token(
|
207
|
-
self,
|
208
|
-
token: str,
|
209
|
-
activate: bool = False,
|
210
|
-
overwrite_if_exists: bool = False,
|
211
|
-
) -> TokenFile:
|
146
|
+
def add_personal_token(self, token: str, activate: bool = False, overwrite_if_exists: bool = False) -> TokenFile:
|
212
147
|
"""
|
213
148
|
Add a personal token
|
214
149
|
"""
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
# Replace any invalid character with an underscore
|
219
|
-
return re.sub(r'[<>:"/\\|?*]', "_", email)
|
150
|
+
file = tf.loads(token)
|
151
|
+
if file.type != "authorized_user":
|
152
|
+
raise ValueError("Token type MUST be authorized_user")
|
220
153
|
|
221
|
-
|
222
|
-
|
223
|
-
file = f"{PERSONAL_TOKEN_FILE_PREFIX}{token_file.name}-{email}.json"
|
224
|
-
path = self.config_dir / file
|
154
|
+
file_name = file.get_token_file_name()
|
155
|
+
path = self.config_dir / file_name
|
225
156
|
if path.exists() and not overwrite_if_exists:
|
226
157
|
raise FileExistsError(f"Token file already exists: {path}")
|
227
158
|
|
228
|
-
self._write_token_file(path,
|
159
|
+
self._write_token_file(path, file)
|
229
160
|
cli_config = self.get_cli_config()
|
230
|
-
cli_config.init_account(email=
|
161
|
+
cli_config.init_account(email=file.account.email, token_file=file)
|
231
162
|
self._write_config_file(cli_config)
|
232
163
|
|
233
164
|
if activate:
|
234
|
-
self.activate_token(
|
165
|
+
self.activate_token(file)
|
235
166
|
|
236
|
-
return
|
167
|
+
return file
|
237
168
|
|
238
169
|
def list_personal_tokens(self) -> list[TokenFile]:
|
239
170
|
"""
|
@@ -247,39 +178,26 @@ class Settings:
|
|
247
178
|
"""
|
248
179
|
return [f[1] for f in self._list_personal_tokens()]
|
249
180
|
|
250
|
-
def add_service_account_token(self, token: str) -> TokenFile:
|
181
|
+
def add_service_account_token(self, token: str, overwrite_if_exists: bool = False) -> TokenFile:
|
182
|
+
"""
|
183
|
+
Add a service account token
|
184
|
+
"""
|
251
185
|
token_file = tf.loads(token)
|
186
|
+
if token_file.type != "service_account":
|
187
|
+
raise ValueError("Token type MUST be service_account")
|
252
188
|
|
253
|
-
|
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"
|
189
|
+
file = token_file.get_token_file_name()
|
261
190
|
path = self.config_dir / file
|
191
|
+
if path.exists() and not overwrite_if_exists:
|
192
|
+
raise FileExistsError(f"Token file already exists: {path}")
|
262
193
|
|
263
194
|
self._write_token_file(path, token_file)
|
264
|
-
print(f"Service account token stored at {path}")
|
265
195
|
cli_config = self.get_cli_config()
|
266
|
-
cli_config.init_account(email=token_file.account.email,
|
196
|
+
cli_config.init_account(email=token_file.account.email, token_file=token_file)
|
267
197
|
self._write_config_file(cli_config)
|
268
198
|
|
269
|
-
# if activate:
|
270
|
-
# self.activate_token(token_file.account.email)
|
271
|
-
|
272
199
|
return token_file
|
273
200
|
|
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
|
-
|
283
201
|
def list_service_account_tokens(self) -> list[TokenFile]:
|
284
202
|
"""
|
285
203
|
List all service account tokens
|
@@ -293,10 +211,10 @@ class Settings:
|
|
293
211
|
return [f[1] for f in self._list_service_account_tokens()]
|
294
212
|
|
295
213
|
def _list_personal_tokens(self) -> list[TokenFileMetadata]:
|
296
|
-
return self._list_token_files(prefix=PERSONAL_TOKEN_FILE_PREFIX)
|
214
|
+
return self._list_token_files(prefix=token_file.PERSONAL_TOKEN_FILE_PREFIX)
|
297
215
|
|
298
216
|
def _list_service_account_tokens(self) -> list[TokenFileMetadata]:
|
299
|
-
return self._list_token_files(prefix=SERVICE_ACCOUNT_TOKEN_FILE_PREFIX)
|
217
|
+
return self._list_token_files(prefix=token_file.SERVICE_ACCOUNT_TOKEN_FILE_PREFIX)
|
300
218
|
|
301
219
|
def _get_token_by_name(self, name: str) -> TokenFileMetadata:
|
302
220
|
token_files = self._list_token_files()
|
@@ -306,7 +224,11 @@ class Settings:
|
|
306
224
|
return matches[0]
|
307
225
|
|
308
226
|
def _list_token_files(self, prefix: str = "") -> list[TokenFileMetadata]:
|
309
|
-
"""
|
227
|
+
"""
|
228
|
+
list all tokens with the correct prefix in the config dir, but omit files that are not token files
|
229
|
+
|
230
|
+
TODO: improve is_valid_json and is_valid_token_file using token_file parsing instead
|
231
|
+
"""
|
310
232
|
|
311
233
|
def is_valid_json(path: Path) -> bool:
|
312
234
|
try:
|
@@ -318,14 +240,17 @@ class Settings:
|
|
318
240
|
return False
|
319
241
|
|
320
242
|
def is_valid_token_file(path: Path) -> bool:
|
321
|
-
is_token_file = path.name.startswith(SERVICE_ACCOUNT_TOKEN_FILE_PREFIX) or path.name.startswith(
|
243
|
+
is_token_file = path.name.startswith(tf.SERVICE_ACCOUNT_TOKEN_FILE_PREFIX) or path.name.startswith(
|
244
|
+
tf.PERSONAL_TOKEN_FILE_PREFIX
|
245
|
+
)
|
322
246
|
has_correct_prefix = path.is_file() and path.name.startswith(prefix)
|
323
|
-
|
324
|
-
|
325
|
-
|
247
|
+
is_cli_config = path == self.config_file_path
|
248
|
+
is_present_in_cli_config_accounts = any(
|
249
|
+
path.name == account.credentials_file for account in self.get_cli_config().accounts.values()
|
250
|
+
)
|
251
|
+
return is_token_file and is_valid_json(path) and has_correct_prefix and not is_cli_config and is_present_in_cli_config_accounts
|
326
252
|
|
327
253
|
paths = [path for path in self.config_dir.iterdir() if is_valid_token_file(path)]
|
328
|
-
|
329
254
|
return [(self._read_token_file(token_file), token_file) for token_file in paths]
|
330
255
|
|
331
256
|
def _read_token_file(self, path: Path) -> TokenFile:
|
@@ -347,9 +272,16 @@ class Settings:
|
|
347
272
|
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
348
273
|
return path
|
349
274
|
|
275
|
+
# Temporary function while considering how to solve this
|
276
|
+
def write_config_file(self, config: ConfigFile) -> Path:
|
277
|
+
return self._write_config_file(config)
|
278
|
+
|
350
279
|
def _write_config_file(self, config: ConfigFile) -> Path:
|
280
|
+
"""
|
281
|
+
TODO: add read cache to avoid parsing the config every time we read it
|
282
|
+
"""
|
351
283
|
data = config_file.dumps(config)
|
352
|
-
path = self._write_file(self.
|
284
|
+
path = self._write_file(self.config_file_path, data)
|
353
285
|
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
354
286
|
return path
|
355
287
|
|
@@ -360,43 +292,8 @@ class Settings:
|
|
360
292
|
path.write_text(data, encoding="utf8")
|
361
293
|
return path
|
362
294
|
|
363
|
-
|
364
|
-
|
365
|
-
return
|
366
|
-
|
367
|
-
sys.stderr.write(f"migrating deprecated config directory {path} to {self.config_dir}\n")
|
368
|
-
shutil.copytree(str(path), str(self.config_dir), dirs_exist_ok=True)
|
369
|
-
secret = path / ACTIVE_TOKEN_FILE_NAME
|
370
|
-
if secret.exists():
|
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()
|
374
|
-
# The existing token file might either be a token file, or simply a string. We handle both cases...
|
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))
|
388
|
-
shutil.rmtree(str(path))
|
389
|
-
|
390
|
-
|
391
|
-
def create_settings() -> Settings:
|
392
|
-
"""Create remotive CLI config directory and return its settings instance"""
|
393
|
-
return Settings(
|
394
|
-
CONFIG_DIR_PATH,
|
395
|
-
deprecated_config_dirs=[DEPRECATED_CONFIG_DIR_PATH, INCORRECT_CONFIG_DIR_PATH],
|
396
|
-
)
|
397
|
-
|
398
|
-
|
399
|
-
settings = create_settings()
|
295
|
+
|
296
|
+
settings = Settings(CONFIG_DIR_PATH)
|
400
297
|
"""
|
401
298
|
Global/module-level settings instance. Module-level variables are only loaded once, at import time.
|
402
299
|
|
File without changes
|
@@ -1,12 +1,14 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
from cli.settings import
|
4
|
-
from cli.settings.
|
5
|
-
from cli.settings.
|
3
|
+
from cli.settings.core import Settings
|
4
|
+
from cli.settings.migration.migrate_token_file import InvalidTokenError, UnsupportedTokenVersionError, migrate_legacy_token
|
5
|
+
from cli.settings.migration.migration_tools import list_token_files
|
6
6
|
from cli.settings.token_file import TokenFile, dumps
|
7
7
|
|
8
|
+
ACTIVE_TOKEN_FILE_NAME = "cloud.secret.token"
|
8
9
|
|
9
|
-
|
10
|
+
|
11
|
+
def migrate_legacy_tokens(tokens: list[TokenFile]) -> tuple[list[TokenFile], set[str]]:
|
10
12
|
"""
|
11
13
|
Determine which tokens can be updated and which should be removed.
|
12
14
|
|
@@ -28,7 +30,7 @@ def _migrate_legacy_tokens(tokens: list[TokenFile]) -> tuple[list[TokenFile], se
|
|
28
30
|
return updated_tokens, invalid_tokens
|
29
31
|
|
30
32
|
|
31
|
-
def _write_updated_tokens(updated_tokens: list[TokenFile]) -> None:
|
33
|
+
def _write_updated_tokens(settings: Settings, updated_tokens: list[TokenFile]) -> None:
|
32
34
|
for updated_token in updated_tokens:
|
33
35
|
settings.remove_token_file(name=updated_token.name)
|
34
36
|
if updated_token.type == "authorized_user":
|
@@ -39,12 +41,12 @@ def _write_updated_tokens(updated_tokens: list[TokenFile]) -> None:
|
|
39
41
|
raise ValueError(f"Unsupported token type: {updated_token.type}")
|
40
42
|
|
41
43
|
|
42
|
-
def _remove_invalid_tokens(invalid_tokens: set[str]) -> None:
|
44
|
+
def _remove_invalid_tokens(settings: Settings, invalid_tokens: set[str]) -> None:
|
43
45
|
for token_name in invalid_tokens:
|
44
46
|
settings.remove_token_file(name=token_name)
|
45
47
|
|
46
48
|
|
47
|
-
def _remove_old_secret_file() -> bool:
|
49
|
+
def _remove_old_secret_file(settings: Settings) -> bool:
|
48
50
|
old_activated_secret_file = settings.config_dir / ACTIVE_TOKEN_FILE_NAME
|
49
51
|
old_secret_exists = old_activated_secret_file.exists()
|
50
52
|
if old_secret_exists:
|
@@ -52,21 +54,25 @@ def _remove_old_secret_file() -> bool:
|
|
52
54
|
return old_secret_exists
|
53
55
|
|
54
56
|
|
55
|
-
def migrate_any_legacy_tokens(
|
57
|
+
def migrate_any_legacy_tokens(settings: Settings) -> bool:
|
56
58
|
"""
|
57
59
|
Migrate any legacy tokens to the latest TokenFile format.
|
58
60
|
|
61
|
+
If the legacy secret file exists (cloud.secret.token), it will be removed.
|
62
|
+
|
59
63
|
Returns True if any tokens were migrated, False otherwise.
|
60
64
|
"""
|
65
|
+
tokens = list_token_files(settings.config_dir)
|
66
|
+
|
61
67
|
# Get tokens to update/remove
|
62
|
-
updated_tokens, invalid_tokens =
|
68
|
+
updated_tokens, invalid_tokens = migrate_legacy_tokens(tokens)
|
63
69
|
|
64
70
|
# Perform file operations
|
65
|
-
_write_updated_tokens(updated_tokens)
|
66
|
-
_remove_invalid_tokens(invalid_tokens)
|
71
|
+
_write_updated_tokens(settings, updated_tokens)
|
72
|
+
_remove_invalid_tokens(settings, invalid_tokens)
|
67
73
|
|
68
74
|
# Remove old secret file if exists
|
69
|
-
old_secret_removed = _remove_old_secret_file()
|
75
|
+
old_secret_removed = _remove_old_secret_file(settings)
|
70
76
|
if old_secret_removed:
|
71
77
|
return True # We migrated at least one token
|
72
78
|
|
@@ -0,0 +1,59 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import json
|
4
|
+
import sys
|
5
|
+
from pathlib import Path
|
6
|
+
from typing import Any, Optional
|
7
|
+
|
8
|
+
from dacite import from_dict
|
9
|
+
|
10
|
+
from cli.settings.config_file import ConfigFile, loads
|
11
|
+
from cli.settings.core import Settings, TokenNotFoundError
|
12
|
+
from cli.settings.migration.migration_tools import get_token_file
|
13
|
+
|
14
|
+
|
15
|
+
def migrate_account_data(config: dict[str, Any], settings: Settings) -> Optional[dict[str, Any]]:
|
16
|
+
"""
|
17
|
+
Migrates Account property credentials_name to credentials_file
|
18
|
+
"""
|
19
|
+
accounts = config.get("accounts", {})
|
20
|
+
to_delete = []
|
21
|
+
found_old = False
|
22
|
+
for account_email, account_info in list(accounts.items()):
|
23
|
+
cred_name = account_info.pop("credentials_name", None)
|
24
|
+
if not cred_name:
|
25
|
+
continue
|
26
|
+
found_old = True
|
27
|
+
try:
|
28
|
+
cred_file = get_token_file(cred_name, settings.config_dir).get_token_file_name()
|
29
|
+
except TokenNotFoundError:
|
30
|
+
# schedule this account for removal
|
31
|
+
to_delete.append(account_email)
|
32
|
+
sys.stderr.write(f"Dropping account {account_email!r}: token file for {cred_name} not found")
|
33
|
+
continue
|
34
|
+
|
35
|
+
account_info["credentials_file"] = cred_file
|
36
|
+
|
37
|
+
# actually remove them (also remove active if it was the one being removed)
|
38
|
+
for account_email in to_delete:
|
39
|
+
del accounts[account_email]
|
40
|
+
if config.get("active", None) == account_email:
|
41
|
+
config["active"] = None
|
42
|
+
|
43
|
+
return config if found_old else None
|
44
|
+
|
45
|
+
|
46
|
+
def migrate_config_file(path: Path, settings: Settings) -> ConfigFile:
|
47
|
+
"""
|
48
|
+
Migrates data in config file to new format
|
49
|
+
"""
|
50
|
+
data = path.read_text()
|
51
|
+
loaded_data: dict[str, Any] = json.loads(data)
|
52
|
+
migrated_data = migrate_account_data(loaded_data, settings)
|
53
|
+
if not migrated_data:
|
54
|
+
return loads(data)
|
55
|
+
|
56
|
+
sys.stderr.write("Migrating old configuration format")
|
57
|
+
migrated_config: ConfigFile = from_dict(data_class=ConfigFile, data=migrated_data)
|
58
|
+
settings.write_config_file(migrated_config)
|
59
|
+
return migrated_config
|
@@ -0,0 +1,50 @@
|
|
1
|
+
"""
|
2
|
+
Migrate all files from legacy config directories to the new config directory. Any migration of the content is handled by specific migration
|
3
|
+
scripts later in the migration process.
|
4
|
+
"""
|
5
|
+
|
6
|
+
import shutil
|
7
|
+
import sys
|
8
|
+
from pathlib import Path
|
9
|
+
|
10
|
+
INCORRECT_CONFIG_DIR_PATH = Path.home() / ".config" / ".remotive"
|
11
|
+
DEPRECATED_CONFIG_DIR_PATH = Path.home() / ".remotive"
|
12
|
+
DEPRECATED_CONFIG_DIRS = [DEPRECATED_CONFIG_DIR_PATH, INCORRECT_CONFIG_DIR_PATH]
|
13
|
+
|
14
|
+
|
15
|
+
def _copy_dir_fail_on_conflict(src: Path, dst: Path) -> None:
|
16
|
+
if not dst.is_dir():
|
17
|
+
dst.mkdir(parents=True, exist_ok=True)
|
18
|
+
|
19
|
+
for item in src.iterdir():
|
20
|
+
src_file = src / item.name
|
21
|
+
dst_file = dst / item.name
|
22
|
+
|
23
|
+
if src_file.is_file():
|
24
|
+
if dst_file.exists():
|
25
|
+
raise FileExistsError(f"File '{dst_file}' already exists.")
|
26
|
+
shutil.copy2(src_file, dst_file) # preserve metadata
|
27
|
+
|
28
|
+
|
29
|
+
def migrate_legacy_settings_dir(path: Path, target_dir: Path) -> None:
|
30
|
+
if not path.exists():
|
31
|
+
return
|
32
|
+
|
33
|
+
sys.stderr.write(f"found legacy config directory {path}, trying to migrate to {target_dir}\n")
|
34
|
+
try:
|
35
|
+
_copy_dir_fail_on_conflict(path, target_dir)
|
36
|
+
shutil.rmtree(str(path))
|
37
|
+
except FileExistsError as e:
|
38
|
+
sys.stderr.write(
|
39
|
+
f"file {e.filename} already exists in {target_dir}, so files in {path} cannot be migrated without risk of data loss. \
|
40
|
+
Please remove or move the files to {target_dir} manually and make sure to remove {path}.\n"
|
41
|
+
)
|
42
|
+
raise e
|
43
|
+
|
44
|
+
|
45
|
+
def migrate_legacy_settings_dirs(target_dir: Path) -> None:
|
46
|
+
"""
|
47
|
+
Migrate any valid configuration from legacy config directories to the new config directory.
|
48
|
+
"""
|
49
|
+
for deprecated_config_dir in DEPRECATED_CONFIG_DIRS:
|
50
|
+
migrate_legacy_settings_dir(deprecated_config_dir, target_dir)
|
@@ -0,0 +1,36 @@
|
|
1
|
+
from itertools import chain
|
2
|
+
from pathlib import Path
|
3
|
+
|
4
|
+
from cli.settings.core import TokenNotFoundError
|
5
|
+
from cli.settings.token_file import TokenFile
|
6
|
+
|
7
|
+
|
8
|
+
def list_token_files(config_dir: Path) -> list[TokenFile]:
|
9
|
+
"""
|
10
|
+
List all token files in the config directory
|
11
|
+
|
12
|
+
Note! Dont use settings, as that will couple settings to the old config and token formats we want to migrate away from.
|
13
|
+
"""
|
14
|
+
token_files = []
|
15
|
+
patterns = ["personal-token-*.json", "service-account-token-*.json"]
|
16
|
+
files = list(chain.from_iterable(config_dir.glob(pattern) for pattern in patterns))
|
17
|
+
for file in files:
|
18
|
+
try:
|
19
|
+
token_file = TokenFile.from_json_str(file.read_text())
|
20
|
+
token_files.append(token_file)
|
21
|
+
except Exception:
|
22
|
+
print(f"warning: invalid token file {file}. Consider removing it.")
|
23
|
+
return token_files
|
24
|
+
|
25
|
+
|
26
|
+
def get_token_file(cred_name: str, config_dir: Path) -> TokenFile:
|
27
|
+
"""
|
28
|
+
Get the token file for a given credentials name.
|
29
|
+
|
30
|
+
Note! Dont use settings, as that will couple settings to the old config and token formats we want to migrate away from.
|
31
|
+
"""
|
32
|
+
token_files = list_token_files(config_dir)
|
33
|
+
matches = [token_file for token_file in token_files if token_file.name == cred_name]
|
34
|
+
if len(matches) != 1:
|
35
|
+
raise TokenNotFoundError(f"Token file for {cred_name} not found")
|
36
|
+
return matches[0]
|
cli/settings/token_file.py
CHANGED
@@ -2,11 +2,14 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import dataclasses
|
4
4
|
import json
|
5
|
+
import re
|
5
6
|
from dataclasses import dataclass
|
6
7
|
from datetime import date, datetime
|
7
8
|
from typing import Any, Literal
|
8
9
|
|
9
10
|
DEFAULT_EMAIL = "unknown@remotivecloud.com"
|
11
|
+
PERSONAL_TOKEN_FILE_PREFIX = "personal-token-"
|
12
|
+
SERVICE_ACCOUNT_TOKEN_FILE_PREFIX = "service-account-token-"
|
10
13
|
|
11
14
|
TokenType = Literal["authorized_user", "service_account"]
|
12
15
|
|
@@ -72,6 +75,17 @@ class TokenFile:
|
|
72
75
|
expires: date
|
73
76
|
account: TokenFileAccount
|
74
77
|
|
78
|
+
def get_token_file_name(self) -> str:
|
79
|
+
def email_to_safe_filename(email: str) -> str:
|
80
|
+
# Replace any invalid character with an underscore
|
81
|
+
return re.sub(r'[<>:"/\\|?*]', "_", email)
|
82
|
+
|
83
|
+
# From now, user will never be None when adding a token so in this case token_file.user is never None
|
84
|
+
email = email_to_safe_filename(self.account.email) if self.account is not None else "unknown"
|
85
|
+
if self.type == "authorized_user":
|
86
|
+
return f"{PERSONAL_TOKEN_FILE_PREFIX}{self.name}-{email}.json"
|
87
|
+
return f"{SERVICE_ACCOUNT_TOKEN_FILE_PREFIX}{self.name}-{email}.json"
|
88
|
+
|
75
89
|
def is_expired(self) -> bool:
|
76
90
|
return datetime.today().date() > self.expires
|
77
91
|
|
cli/topology/cmd.py
ADDED
@@ -0,0 +1,101 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import dataclasses
|
4
|
+
import datetime
|
5
|
+
from typing import Any
|
6
|
+
|
7
|
+
import typer
|
8
|
+
from rich.console import Console
|
9
|
+
|
10
|
+
from cli.errors import ErrorPrinter
|
11
|
+
from cli.settings import TokenNotFoundError, settings
|
12
|
+
from cli.typer import typer_utils
|
13
|
+
from cli.utils.rest_helper import RestHelper
|
14
|
+
|
15
|
+
HELP = """
|
16
|
+
RemotiveTopology commands
|
17
|
+
"""
|
18
|
+
console = Console()
|
19
|
+
app = typer_utils.create_typer(help=HELP)
|
20
|
+
|
21
|
+
|
22
|
+
@dataclasses.dataclass
|
23
|
+
class Subscription:
|
24
|
+
type: str
|
25
|
+
display_name: str
|
26
|
+
feature: str
|
27
|
+
start_date: str # TODO: add datetime
|
28
|
+
end_date: str # TODO: add datetime
|
29
|
+
|
30
|
+
|
31
|
+
def _print_current_subscription(subscription_info: dict[str, Any]) -> None:
|
32
|
+
subscription_type = subscription_info.get("subscriptionType")
|
33
|
+
end_date_str = subscription_info.get("endDate")
|
34
|
+
now = datetime.datetime.now()
|
35
|
+
|
36
|
+
def parse_date(date_str: str | None) -> datetime.datetime | None:
|
37
|
+
return datetime.datetime.fromisoformat(date_str) if date_str else None
|
38
|
+
|
39
|
+
expires = parse_date(end_date_str)
|
40
|
+
|
41
|
+
if subscription_type == "trial":
|
42
|
+
if expires and expires < now:
|
43
|
+
console.print(f"Your Topology trial expired {end_date_str}, please contact support@remotivelabs.com")
|
44
|
+
else:
|
45
|
+
console.print(f"You already have an active topology trial, it expires {end_date_str}")
|
46
|
+
|
47
|
+
elif subscription_type == "paid":
|
48
|
+
if expires and expires < now:
|
49
|
+
console.print(f"Topology subscription has ended, expired {end_date_str}")
|
50
|
+
else:
|
51
|
+
console.print(f"You already have an active topology subscription, it expires {end_date_str or 'Never'}")
|
52
|
+
|
53
|
+
else:
|
54
|
+
ErrorPrinter.print_generic_error("Unexpected exception, please contact support@remotivelabs.com")
|
55
|
+
raise typer.Exit(1)
|
56
|
+
|
57
|
+
|
58
|
+
@app.command("start-trial")
|
59
|
+
def start_trial(
|
60
|
+
organization: str = typer.Option(None, help="Organization to start trial for", envvar="REMOTIVE_CLOUD_ORGANIZATION"),
|
61
|
+
) -> None:
|
62
|
+
"""
|
63
|
+
Allows you ta start a 30 day trial subscription for running RemotiveTopology, you can read more at https://docs.remotivelabs.com/docs/remotive-topology.
|
64
|
+
|
65
|
+
"""
|
66
|
+
RestHelper.use_progress("Checking access tokens...", transient=True)
|
67
|
+
try:
|
68
|
+
_ = settings.get_active_token_file()
|
69
|
+
except TokenNotFoundError:
|
70
|
+
if len(settings.list_personal_token_files()) == 0:
|
71
|
+
console.print(
|
72
|
+
"You must first sign in to RemotiveCloud, please use [bold]remotive cloud auth login[/bold] to sign-in"
|
73
|
+
"This requires a RemotiveCloud account, if you do not have an account you can sign-up at https://cloud.remotivelabs.com"
|
74
|
+
)
|
75
|
+
else:
|
76
|
+
console.print(
|
77
|
+
"You have not active account, please run [bold]remotive cloud auth activate[/bold] to choose an account"
|
78
|
+
"or [bold]remotive cloud auth login[/bold] to sign-in"
|
79
|
+
)
|
80
|
+
return
|
81
|
+
|
82
|
+
has_access = RestHelper.has_access("/api/whoami")
|
83
|
+
if not has_access:
|
84
|
+
ErrorPrinter.print_generic_message("Your current active credentials are not valid")
|
85
|
+
raise typer.Exit(1)
|
86
|
+
|
87
|
+
if organization is None and settings.get_cli_config().get_active_default_organisation() is None:
|
88
|
+
ErrorPrinter.print_hint("You have not specified any organization and no default organization is set")
|
89
|
+
raise typer.Exit(1)
|
90
|
+
|
91
|
+
sub = RestHelper.handle_get(f"/api/bu/{organization}/features/topology", return_response=True, allow_status_codes=[404, 403])
|
92
|
+
if sub.status_code == 404:
|
93
|
+
created = RestHelper.handle_post(f"/api/bu/{organization}/features/topology", return_response=True)
|
94
|
+
console.print(f"Topology trial started, it expires {created.json()['endDate']}")
|
95
|
+
elif sub.status_code == 403:
|
96
|
+
ErrorPrinter.print_generic_error(f"You are not allowed to start-trial topology in organization {organization}")
|
97
|
+
raise typer.Exit(1)
|
98
|
+
else:
|
99
|
+
subscription_info = sub.json()
|
100
|
+
_print_current_subscription(subscription_info)
|
101
|
+
return
|
@@ -14,15 +14,15 @@ cli/broker/scripting.py,sha256=LFLdaBNxe2sfpcxhDmRlAbEorjL3SJZNK-zEdLQ9ySU,3854
|
|
14
14
|
cli/broker/signals.py,sha256=MFj_bOLIxHY1v3XPkKk6n8U3JLaY8nrXHahRQaVse6s,8207
|
15
15
|
cli/cloud/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
16
16
|
cli/cloud/auth/__init__.py,sha256=MtQ01-n8CgZb9Y_SvxwZUgj44Yo0dFAU3_XwhQiUYtw,54
|
17
|
-
cli/cloud/auth/cmd.py,sha256=
|
18
|
-
cli/cloud/auth/login.py,sha256=
|
19
|
-
cli/cloud/auth_tokens.py,sha256=
|
17
|
+
cli/cloud/auth/cmd.py,sha256=gLmfjIN9Vrytk9BwGH1cq4WeeryOfwTTnQ4yuZrbRcs,2757
|
18
|
+
cli/cloud/auth/login.py,sha256=dX6M5ysE0n9Zg3gVT7hJbChxTsmuba-Z-1Or6DCFYis,11511
|
19
|
+
cli/cloud/auth_tokens.py,sha256=K_HSBto2XfbD-Hxhb0SAFhxSDZdSXo961UcvdVWNkZI,12831
|
20
20
|
cli/cloud/brokers.py,sha256=QTA9bmaK06LKEccF6IBgWBonC4VFrKwFQBsACX_IzYw,3896
|
21
21
|
cli/cloud/cloud_cli.py,sha256=q-oiaLcKC-BRamXfIFGn-BskRmJ3utA7-tI39lSs3Cs,1309
|
22
22
|
cli/cloud/configs.py,sha256=uv46nUoGXOr99smQHahv_ageDv6bGYfUnlRlxcS5D9A,5125
|
23
|
-
cli/cloud/organisations.py,sha256=
|
23
|
+
cli/cloud/organisations.py,sha256=iEmGMEzOIvuWomoJZ0WBa3Rmrkrup5UH7wjPMoElSn4,4092
|
24
24
|
cli/cloud/projects.py,sha256=ecn5Y8UKhgYnHSJQACUk1GNZt9EF8ug4B-6MCr8rZqM,1487
|
25
|
-
cli/cloud/recordings.py,sha256=
|
25
|
+
cli/cloud/recordings.py,sha256=In2fKX668CPsEVBAy7zkU92lEnmu3UcnqiVrqsvLNDQ,24961
|
26
26
|
cli/cloud/recordings_playback.py,sha256=XZoVyujufMQFN2v_Nwsf8tOqn61yLEpAf2z_u5uhXik,11532
|
27
27
|
cli/cloud/resumable_upload.py,sha256=8lEIdncJZoTZzNsQVHH3gm_GunxEmN5JbmWX7awy3p4,3713
|
28
28
|
cli/cloud/sample_recordings.py,sha256=RmuT-a2iMwGj3LXVcPkV5l66uFcf7nyWyJciUjnYkk4,721
|
@@ -36,24 +36,29 @@ cli/cloud/uri.py,sha256=QZCus--KJQlVwGCOzZqiglvj8VvSRKxfVvN33Pilgyg,3616
|
|
36
36
|
cli/connect/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
37
37
|
cli/connect/connect.py,sha256=SH2DNTTVLu2dNpk6xIah1-KJZAqrK_7Skt8RKp8Mjh8,4231
|
38
38
|
cli/connect/protopie/protopie.py,sha256=ElmrGaV0ivb85wo0gLzCAXZhmSmIDASaCVlF1iQblLI,6532
|
39
|
-
cli/errors.py,sha256=
|
40
|
-
cli/remotive.py,sha256=
|
41
|
-
cli/settings/__init__.py,sha256=
|
42
|
-
cli/settings/config_file.py,sha256=
|
43
|
-
cli/settings/core.py,sha256=
|
44
|
-
cli/settings/
|
45
|
-
cli/settings/
|
46
|
-
cli/settings/
|
39
|
+
cli/errors.py,sha256=djODw6sdMJXzOsuAUOP3N13nfmm1sIP3Pe6tllGdozM,1657
|
40
|
+
cli/remotive.py,sha256=FkXJCq0mw_bak75u6aukleIld-jjgn4hjuBQ28Lcz1U,3831
|
41
|
+
cli/settings/__init__.py,sha256=t1qkaGrJ4xx8WMHlmBTbQ1VdJL4YOcz8VFfRkGa2_jQ,711
|
42
|
+
cli/settings/config_file.py,sha256=6sdHUtZSUIgubwpfwEEn7GarTK1M_iQhtRJZzFDdP5o,2784
|
43
|
+
cli/settings/core.py,sha256=FqME9ghV6bBNDrZnQCm3jxz-7T8M04dSFumYK1-QOhs,10911
|
44
|
+
cli/settings/migration/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
45
|
+
cli/settings/migration/migrate_all_token_files.py,sha256=xoVvAqn_tGskEW148uf3xZx1mpJKUnERMTcBo0nkCnI,3010
|
46
|
+
cli/settings/migration/migrate_config_file.py,sha256=hw4EpRwJz1zUNxfCOk0PvMuZjAlaGy4m_rDbMsHZO_w,2047
|
47
|
+
cli/settings/migration/migrate_legacy_dirs.py,sha256=N0t2io3bT_ub8BcVPw1CeQ4eeexRUiu3jXq3DL018OE,1819
|
48
|
+
cli/settings/migration/migrate_token_file.py,sha256=Fp7Z_lNqSdoWY05TYwFW2QH8q9QhmB2TYSok6hV1Mic,1530
|
49
|
+
cli/settings/migration/migration_tools.py,sha256=P72tuw6-aS_Kd0qn-0ZecplsYxMTu0LTXM5sMSNTVEM,1378
|
50
|
+
cli/settings/token_file.py,sha256=llfP1GohXtLVIDy3UyNB8FneGRuCesnce9uasv-7XKM,2953
|
47
51
|
cli/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
48
52
|
cli/tools/can/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
49
53
|
cli/tools/can/can.py,sha256=TtP5w8vb0QG4ObNhkWIDpRMdNelirFffoc_lFZy8ePM,2260
|
50
54
|
cli/tools/tools.py,sha256=jhLfrFDqkmWV3eBAzNwBf6WgDGrz7sOhgVCia36Twn8,232
|
55
|
+
cli/topology/cmd.py,sha256=SQ5wi7KDoh4iR2Ed7gyfGLNj6UE0K6UkksmBMSD2XAk,3981
|
51
56
|
cli/typer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
52
57
|
cli/typer/typer_utils.py,sha256=8SkvG9aKkfK9fTRsLD9pOBtWn9XSwtOXWg2RAk9FhOI,708
|
53
58
|
cli/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
54
59
|
cli/utils/rest_helper.py,sha256=b_FJY6MxnFSqo11qaHxkBFHfVlKf7Zj28Uxv9Oj7XY4,14141
|
55
|
-
remotivelabs_cli-0.
|
56
|
-
remotivelabs_cli-0.
|
57
|
-
remotivelabs_cli-0.
|
58
|
-
remotivelabs_cli-0.
|
59
|
-
remotivelabs_cli-0.
|
60
|
+
remotivelabs_cli-0.2.0.dist-info/LICENSE,sha256=qDPP_yfuv1fF-u7EfexN-cN3M8aFgGVndGhGLovLKz0,608
|
61
|
+
remotivelabs_cli-0.2.0.dist-info/METADATA,sha256=nyRzH6JasTH4wYLC7itZSo9yIYqvcmNUf3kajMN9aQ8,1428
|
62
|
+
remotivelabs_cli-0.2.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
63
|
+
remotivelabs_cli-0.2.0.dist-info/entry_points.txt,sha256=lvDhPgagLqW_KTnLPCwKSqfYlEp-1uYVosRiPjsVj10,45
|
64
|
+
remotivelabs_cli-0.2.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|