remotivelabs-cli 0.2.0a2__py3-none-any.whl → 0.2.1__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.
Potentially problematic release.
This version of remotivelabs-cli might be problematic. Click here for more details.
- cli/cloud/auth/cmd.py +0 -3
- cli/cloud/auth_tokens.py +15 -16
- cli/cloud/organisations.py +2 -2
- cli/cloud/recordings.py +7 -5
- cli/remotive.py +48 -21
- cli/settings/__init__.py +19 -3
- cli/settings/config_file.py +26 -74
- cli/settings/core.py +54 -147
- 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 +2 -6
- cli/topology/cmd.py +19 -16
- cli/utils/rest_helper.py +1 -1
- {remotivelabs_cli-0.2.0a2.dist-info → remotivelabs_cli-0.2.1.dist-info}/METADATA +1 -1
- {remotivelabs_cli-0.2.0a2.dist-info → remotivelabs_cli-0.2.1.dist-info}/RECORD +22 -18
- /cli/settings/{migrate_token_file.py → migration/migrate_token_file.py} +0 -0
- {remotivelabs_cli-0.2.0a2.dist-info → remotivelabs_cli-0.2.1.dist-info}/LICENSE +0 -0
- {remotivelabs_cli-0.2.0a2.dist-info → remotivelabs_cli-0.2.1.dist-info}/WHEEL +0 -0
- {remotivelabs_cli-0.2.0a2.dist-info → remotivelabs_cli-0.2.1.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
|
|
cli/cloud/auth_tokens.py
CHANGED
|
@@ -29,10 +29,6 @@ def _prompt_choice( # noqa: C901, PLR0912
|
|
|
29
29
|
info_message: Optional[str] = None,
|
|
30
30
|
) -> Optional[TokenFile]:
|
|
31
31
|
accounts = settings.get_cli_config().accounts
|
|
32
|
-
try:
|
|
33
|
-
active_account = settings.get_cli_config().get_active()
|
|
34
|
-
except TokenNotFoundError:
|
|
35
|
-
active_account = None
|
|
36
32
|
|
|
37
33
|
table = Table("#", "Active", "Type", "Token", "Account", "Created", "Expires")
|
|
38
34
|
|
|
@@ -56,19 +52,27 @@ def _prompt_choice( # noqa: C901, PLR0912
|
|
|
56
52
|
|
|
57
53
|
included_tokens.sort(key=lambda token: token.created, reverse=True)
|
|
58
54
|
|
|
59
|
-
def
|
|
60
|
-
if account is None:
|
|
61
|
-
return None
|
|
55
|
+
def get_active_account_or_none() -> Optional[Account]:
|
|
62
56
|
try:
|
|
63
|
-
return settings.
|
|
57
|
+
return settings.get_cli_config().get_active()
|
|
64
58
|
except TokenNotFoundError:
|
|
65
59
|
return None
|
|
66
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()
|
|
67
71
|
active_token_index = None
|
|
68
72
|
for idx, choice in enumerate(included_tokens, start=1):
|
|
69
|
-
|
|
70
|
-
is_active = active_account is not None and active_token is not None and active_token.name == choice.name
|
|
73
|
+
is_active = active_token is not None and active_token.name == choice.name
|
|
71
74
|
active_token_index = idx if is_active else active_token_index
|
|
75
|
+
|
|
72
76
|
table.add_row(
|
|
73
77
|
f"[yellow]{idx}",
|
|
74
78
|
":white_check_mark:" if is_active else "",
|
|
@@ -78,7 +82,6 @@ def _prompt_choice( # noqa: C901, PLR0912
|
|
|
78
82
|
str(choice.created),
|
|
79
83
|
str(choice.expires),
|
|
80
84
|
)
|
|
81
|
-
# console.print("It seems like you have access tokens from previous login, you can select one of these instead of logging in")
|
|
82
85
|
console.print(table)
|
|
83
86
|
|
|
84
87
|
if skip_prompt:
|
|
@@ -181,9 +184,7 @@ def select_personal_token(
|
|
|
181
184
|
do_activate(token_name)
|
|
182
185
|
|
|
183
186
|
|
|
184
|
-
def do_activate(
|
|
185
|
-
token_name: Optional[str],
|
|
186
|
-
) -> Optional[TokenFile]:
|
|
187
|
+
def do_activate(token_name: Optional[str]) -> Optional[TokenFile]:
|
|
187
188
|
if token_name is not None:
|
|
188
189
|
try:
|
|
189
190
|
token_file = settings.get_token_file(token_name)
|
|
@@ -224,8 +225,6 @@ def list_and_select_personal_token(
|
|
|
224
225
|
sa_tokens = settings.list_service_account_tokens()
|
|
225
226
|
personal_tokens.extend(sa_tokens)
|
|
226
227
|
|
|
227
|
-
# merged = _merge_local_tokens_with_cloud(personal_tokens)
|
|
228
|
-
|
|
229
228
|
selected_token = _prompt_choice(personal_tokens, skip_prompt=skip_prompt, info_message=info_message)
|
|
230
229
|
if selected_token is not None:
|
|
231
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,7 +84,7 @@ 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
|
|
87
|
+
if account:
|
|
88
88
|
token = settings.get_token_file(account.credentials_file)
|
|
89
89
|
if token.type != "authorized_user":
|
|
90
90
|
ErrorPrinter.print_hint(
|
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/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
12
|
from cli.broker.brokers import app as broker_app
|
|
13
13
|
from cli.cloud.cloud_cli import app as cloud_app
|
|
14
14
|
from cli.connect.connect import app as connect_app
|
|
15
15
|
from cli.settings import settings
|
|
16
|
-
from cli.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
|
|
17
20
|
from cli.tools.tools import app as tools_app
|
|
18
21
|
from cli.topology.cmd import app as topology_app
|
|
19
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
|
|
|
@@ -90,14 +115,16 @@ app.add_typer(
|
|
|
90
115
|
name="cloud",
|
|
91
116
|
help="Manage resources in RemotiveCloud",
|
|
92
117
|
)
|
|
93
|
-
app.add_typer(
|
|
94
|
-
topology_app,
|
|
95
|
-
name="topology",
|
|
96
|
-
help="""
|
|
97
|
-
RemotiveTopology actions
|
|
98
|
-
|
|
99
|
-
Read more at https://docs.remotivelabs.com/docs/remotive-topology
|
|
100
|
-
""",
|
|
101
|
-
)
|
|
102
118
|
app.add_typer(connect_app, name="connect", help="Integrations with other systems")
|
|
103
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,5 +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
|
|
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
|
|
2
8
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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,70 +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 Any, Dict, Optional
|
|
6
|
+
from typing import Any, Optional
|
|
8
7
|
|
|
9
8
|
from dacite import from_dict
|
|
10
9
|
|
|
11
10
|
from cli.settings.token_file import TokenFile
|
|
12
11
|
|
|
13
12
|
|
|
14
|
-
def upgrade_config(config: dict[str, Any]) -> Optional[dict[str, Any]]:
|
|
15
|
-
"""
|
|
16
|
-
Reads a JSON config from in_path, replaces each account's 'credentials_name'
|
|
17
|
-
with 'credentials_file' (by calling get_filename_for_name), and writes the result
|
|
18
|
-
back to out_path (or overwrites in_path if out_path is None).
|
|
19
|
-
"""
|
|
20
|
-
from cli.settings import TokenNotFoundError, settings
|
|
21
|
-
|
|
22
|
-
accounts = config.get("accounts", {})
|
|
23
|
-
to_delete = []
|
|
24
|
-
found_old = False
|
|
25
|
-
for account, info in list(accounts.items()):
|
|
26
|
-
cred_name = info.pop("credentials_name", None)
|
|
27
|
-
if not cred_name:
|
|
28
|
-
continue
|
|
29
|
-
found_old = True
|
|
30
|
-
try:
|
|
31
|
-
cred_file = settings.get_token_file(cred_name).get_token_file_name()
|
|
32
|
-
except TokenNotFoundError:
|
|
33
|
-
# schedule this account for removal
|
|
34
|
-
to_delete.append(account)
|
|
35
|
-
print(f"Dropping account {account!r}: token file for {cred_name} not found")
|
|
36
|
-
continue
|
|
37
|
-
|
|
38
|
-
info["credentials_file"] = cred_file
|
|
39
|
-
|
|
40
|
-
# actually remove them
|
|
41
|
-
for account in to_delete:
|
|
42
|
-
del accounts[account]
|
|
43
|
-
|
|
44
|
-
if found_old:
|
|
45
|
-
return config
|
|
46
|
-
return None
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def _from_dict(data: dict[str, Any]) -> ConfigFile:
|
|
50
|
-
from cli.settings import settings
|
|
51
|
-
|
|
52
|
-
config = upgrade_config(data)
|
|
53
|
-
if config is not None:
|
|
54
|
-
print("Migrating old configuration format")
|
|
55
|
-
updated_config: ConfigFile = from_dict(ConfigFile, config)
|
|
56
|
-
settings.write_config_file(updated_config)
|
|
57
|
-
return updated_config
|
|
58
|
-
return from_dict(ConfigFile, data)
|
|
59
|
-
|
|
60
|
-
|
|
61
13
|
def loads(data: str) -> ConfigFile:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return _from_dict(d)
|
|
65
|
-
except JSONDecodeError as e:
|
|
66
|
-
# ErrorPrinter.print_generic_error("Invalid json format, config.json")
|
|
67
|
-
raise JSONDecodeError(
|
|
68
|
-
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
|
|
69
|
-
)
|
|
14
|
+
d = json.loads(data)
|
|
15
|
+
return from_dict(ConfigFile, d)
|
|
70
16
|
|
|
71
17
|
|
|
72
18
|
def dumps(config: ConfigFile) -> str:
|
|
@@ -84,32 +30,30 @@ class Account:
|
|
|
84
30
|
class ConfigFile:
|
|
85
31
|
version: str = "1.0"
|
|
86
32
|
active: Optional[str] = None
|
|
87
|
-
accounts:
|
|
33
|
+
accounts: dict[str, Account] = dataclasses.field(default_factory=dict)
|
|
88
34
|
|
|
89
35
|
def get_active_default_organisation(self) -> Optional[str]:
|
|
90
36
|
active_account = self.get_active()
|
|
91
|
-
return active_account.default_organization if active_account
|
|
37
|
+
return active_account.default_organization if active_account else None
|
|
92
38
|
|
|
93
39
|
def get_active(self) -> Optional[Account]:
|
|
94
|
-
if self.active
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
40
|
+
if not self.active:
|
|
41
|
+
return None
|
|
42
|
+
account = self.get_account(self.active)
|
|
43
|
+
if not account:
|
|
98
44
|
raise KeyError(f"Activated account {self.active} is not a valid account")
|
|
99
|
-
return
|
|
45
|
+
return account
|
|
100
46
|
|
|
101
47
|
def activate(self, email: str) -> None:
|
|
102
|
-
account = self.
|
|
103
|
-
|
|
104
|
-
if account is not None:
|
|
105
|
-
self.active = email
|
|
106
|
-
else:
|
|
48
|
+
account = self.get_account(email)
|
|
49
|
+
if not account:
|
|
107
50
|
raise KeyError(f"Account {email} does not exists")
|
|
51
|
+
self.active = email
|
|
108
52
|
|
|
109
53
|
def get_account(self, email: str) -> Optional[Account]:
|
|
110
|
-
if self.accounts:
|
|
111
|
-
return
|
|
112
|
-
return None
|
|
54
|
+
if not self.accounts:
|
|
55
|
+
return None
|
|
56
|
+
return self.accounts.get(email, None)
|
|
113
57
|
|
|
114
58
|
def remove_account(self, email: str) -> None:
|
|
115
59
|
if self.accounts:
|
|
@@ -119,7 +63,7 @@ class ConfigFile:
|
|
|
119
63
|
if self.accounts is None:
|
|
120
64
|
self.accounts = {}
|
|
121
65
|
|
|
122
|
-
account = self.
|
|
66
|
+
account = self.get_account(email)
|
|
123
67
|
if not account:
|
|
124
68
|
account = Account(credentials_file=token_file.get_token_file_name())
|
|
125
69
|
else:
|
|
@@ -130,7 +74,7 @@ class ConfigFile:
|
|
|
130
74
|
if self.accounts is None:
|
|
131
75
|
self.accounts = {}
|
|
132
76
|
|
|
133
|
-
account = self.
|
|
77
|
+
account = self.get_account(email)
|
|
134
78
|
if not account:
|
|
135
79
|
raise KeyError(f"Account with email {email} has not been initialized with token")
|
|
136
80
|
|
|
@@ -139,3 +83,11 @@ class ConfigFile:
|
|
|
139
83
|
account.default_organization = default_organization
|
|
140
84
|
|
|
141
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,13 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
import shutil
|
|
5
4
|
import stat
|
|
6
5
|
import sys
|
|
7
|
-
from dataclasses import dataclass
|
|
8
6
|
from json import JSONDecodeError
|
|
9
7
|
from pathlib import Path
|
|
10
|
-
from typing import Optional, Tuple
|
|
8
|
+
from typing import Optional, Tuple
|
|
11
9
|
|
|
12
10
|
from rich.console import Console
|
|
13
11
|
|
|
@@ -21,11 +19,8 @@ err_console = Console(stderr=True)
|
|
|
21
19
|
|
|
22
20
|
|
|
23
21
|
CONFIG_DIR_PATH = Path.home() / ".config" / "remotive"
|
|
24
|
-
INCORRECT_CONFIG_DIR_PATH = Path.home() / ".config" / ".remotive"
|
|
25
|
-
DEPRECATED_CONFIG_DIR_PATH = Path.home() / ".remotive"
|
|
26
|
-
|
|
27
22
|
CLI_CONFIG_FILE_NAME = "config.json"
|
|
28
|
-
|
|
23
|
+
|
|
29
24
|
|
|
30
25
|
TokenFileMetadata = Tuple[TokenFile, Path]
|
|
31
26
|
|
|
@@ -34,67 +29,26 @@ class InvalidSettingsFilePathError(Exception):
|
|
|
34
29
|
"""Raised when trying to access an invalid settings file or file path"""
|
|
35
30
|
|
|
36
31
|
|
|
37
|
-
class NotFoundError(Exception):
|
|
38
|
-
"""Raised when a token cannot be found in settings"""
|
|
39
|
-
|
|
40
|
-
|
|
41
32
|
class TokenNotFoundError(Exception):
|
|
42
33
|
"""Raised when a token cannot be found in settings"""
|
|
43
34
|
|
|
44
35
|
|
|
45
|
-
@dataclass()
|
|
46
|
-
class CliConfigFile:
|
|
47
|
-
default_organisation: Union[str, None]
|
|
48
|
-
|
|
49
|
-
|
|
50
36
|
class Settings:
|
|
51
37
|
"""
|
|
52
|
-
Settings for the remotive CLI
|
|
38
|
+
Settings handles tokens and other config for the remotive CLI
|
|
53
39
|
"""
|
|
54
40
|
|
|
55
41
|
config_dir: Path
|
|
56
42
|
|
|
57
|
-
def __init__(self, config_dir: Path
|
|
43
|
+
def __init__(self, config_dir: Path) -> None:
|
|
58
44
|
self.config_dir = config_dir
|
|
59
|
-
self._active_secret_token_path = self.config_dir / ACTIVE_TOKEN_FILE_NAME
|
|
60
|
-
self._cli_config = self.config_dir / CLI_CONFIG_FILE_NAME
|
|
61
|
-
|
|
62
|
-
if self.config_dir.exists():
|
|
63
|
-
return
|
|
64
|
-
|
|
65
|
-
# create the config dir and try to migrate legacy config dirs if they exist
|
|
66
45
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
# def _write_properties(self, filepath: Path, props: CliConfigFile) -> None:
|
|
72
|
-
# with open(filepath, "w", encoding="utf-8") as file:
|
|
73
|
-
# # keys = sorted(props.keys()) if sort_keys else props.keys()
|
|
74
|
-
# # for key in keys:
|
|
75
|
-
# file.write(f"default_organisation={props.default_organisation}\n")
|
|
76
|
-
|
|
77
|
-
def _read_properties(self, filepath: Path) -> CliConfigFile:
|
|
78
|
-
props = {}
|
|
79
|
-
with open(filepath, "r", encoding="utf-8") as file:
|
|
80
|
-
for line_num, line in enumerate(file, start=1):
|
|
81
|
-
line_stripped = line.strip()
|
|
82
|
-
if not line_stripped or line_stripped.startswith("#"):
|
|
83
|
-
continue
|
|
84
|
-
if "=" not in line_stripped:
|
|
85
|
-
raise ValueError(f"Invalid line format at line {line_num}: {line}")
|
|
86
|
-
key, value = line_stripped.split("=", 1)
|
|
87
|
-
key, value = key.strip(), value.strip()
|
|
88
|
-
if key in props:
|
|
89
|
-
raise ValueError(f"Duplicate key '{key}' found at line {line_num}")
|
|
90
|
-
props[key] = value
|
|
91
|
-
if "default_organisation" in props:
|
|
92
|
-
return CliConfigFile(default_organisation=props["default_organisation"])
|
|
93
|
-
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())
|
|
94
49
|
|
|
95
50
|
def set_default_organisation(self, organisation: str) -> None:
|
|
96
51
|
cli_config = self.get_cli_config()
|
|
97
|
-
|
|
98
52
|
try:
|
|
99
53
|
token = settings.get_active_token_file()
|
|
100
54
|
cli_config.set_account_field(token.account.email, organisation)
|
|
@@ -108,7 +62,6 @@ class Settings:
|
|
|
108
62
|
return self._read_config_file()
|
|
109
63
|
except TokenNotFoundError:
|
|
110
64
|
return ConfigFile()
|
|
111
|
-
# self._write_properties(CONFIG_DIR_PATH / CLI_CONFIG_FILE_NAME, [])
|
|
112
65
|
|
|
113
66
|
def get_active_token(self) -> str:
|
|
114
67
|
"""
|
|
@@ -121,30 +74,22 @@ class Settings:
|
|
|
121
74
|
"""
|
|
122
75
|
Get the current active token file
|
|
123
76
|
"""
|
|
124
|
-
|
|
125
77
|
active_account = self.get_cli_config().get_active()
|
|
126
78
|
if active_account is not None:
|
|
127
79
|
token_file_name = active_account.credentials_file
|
|
128
80
|
return self._read_token_file(self.config_dir / token_file_name)
|
|
129
81
|
|
|
130
82
|
raise TokenNotFoundError
|
|
131
|
-
# if not self._active_secret_token_path.exists():
|
|
132
|
-
# raise TokenNotFoundError("no active token file found")
|
|
133
|
-
# return self._read_token_file(self._active_secret_token_path)
|
|
134
83
|
|
|
135
|
-
def activate_token(self,
|
|
84
|
+
def activate_token(self, token_file: TokenFile) -> None:
|
|
136
85
|
"""
|
|
137
86
|
Activate a token by name or path
|
|
138
87
|
|
|
139
88
|
The token secret will be set as the current active secret.
|
|
140
89
|
"""
|
|
141
|
-
# token_file = self.get_token_file(name)
|
|
142
90
|
cli_config = self.get_cli_config()
|
|
143
|
-
cli_config.activate(
|
|
144
|
-
# if token_file.account.email not in cli_config.accounts:
|
|
145
|
-
# cli_config.set_account_field(token_file.account.email)
|
|
91
|
+
cli_config.activate(token_file.account.email)
|
|
146
92
|
self._write_config_file(cli_config)
|
|
147
|
-
# self._write_token_file(self._active_secret_token_path, token_file)
|
|
148
93
|
|
|
149
94
|
def clear_active_token(self) -> None:
|
|
150
95
|
"""
|
|
@@ -154,9 +99,12 @@ class Settings:
|
|
|
154
99
|
config.active = None
|
|
155
100
|
self._write_config_file(config)
|
|
156
101
|
|
|
157
|
-
# self._active_secret_token_path.unlink(missing_ok=True)
|
|
158
|
-
|
|
159
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
|
+
"""
|
|
160
108
|
tokens = [t for t in self.list_personal_tokens() if t.account is not None and t.account.email == email]
|
|
161
109
|
if len(tokens) > 0:
|
|
162
110
|
return tokens[0]
|
|
@@ -169,11 +117,15 @@ class Settings:
|
|
|
169
117
|
"""
|
|
170
118
|
Get a token file by name or path
|
|
171
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
|
|
172
125
|
if Path(name).exists():
|
|
173
126
|
return self._read_token_file(Path(name))
|
|
174
|
-
if Path(CONFIG_DIR_PATH / name).exists():
|
|
175
|
-
return self._read_token_file(Path(CONFIG_DIR_PATH / name))
|
|
176
127
|
|
|
128
|
+
# 3. Try name
|
|
177
129
|
return self._get_token_by_name(name)[0]
|
|
178
130
|
|
|
179
131
|
def remove_token_file(self, name: str) -> None:
|
|
@@ -189,41 +141,30 @@ class Settings:
|
|
|
189
141
|
|
|
190
142
|
# TODO: what about the active token?
|
|
191
143
|
path = self._get_token_by_name(name)[1]
|
|
192
|
-
# print("Deleting", path)
|
|
193
144
|
return path.unlink()
|
|
194
145
|
|
|
195
|
-
|
|
196
|
-
# """
|
|
197
|
-
# Activates a short lived token
|
|
198
|
-
# """
|
|
199
|
-
# token_file = tf.loads(token)
|
|
200
|
-
# self._write_token_file(self._active_secret_token_path, token_file)
|
|
201
|
-
# return token_file
|
|
202
|
-
|
|
203
|
-
def add_personal_token(
|
|
204
|
-
self,
|
|
205
|
-
token: str,
|
|
206
|
-
activate: bool = False,
|
|
207
|
-
overwrite_if_exists: bool = False,
|
|
208
|
-
) -> TokenFile:
|
|
146
|
+
def add_personal_token(self, token: str, activate: bool = False, overwrite_if_exists: bool = False) -> TokenFile:
|
|
209
147
|
"""
|
|
210
148
|
Add a personal token
|
|
211
149
|
"""
|
|
212
|
-
|
|
213
|
-
file
|
|
214
|
-
|
|
150
|
+
file = tf.loads(token)
|
|
151
|
+
if file.type != "authorized_user":
|
|
152
|
+
raise ValueError("Token type MUST be authorized_user")
|
|
153
|
+
|
|
154
|
+
file_name = file.get_token_file_name()
|
|
155
|
+
path = self.config_dir / file_name
|
|
215
156
|
if path.exists() and not overwrite_if_exists:
|
|
216
157
|
raise FileExistsError(f"Token file already exists: {path}")
|
|
217
158
|
|
|
218
|
-
self._write_token_file(path,
|
|
159
|
+
self._write_token_file(path, file)
|
|
219
160
|
cli_config = self.get_cli_config()
|
|
220
|
-
cli_config.init_account(email=
|
|
161
|
+
cli_config.init_account(email=file.account.email, token_file=file)
|
|
221
162
|
self._write_config_file(cli_config)
|
|
222
163
|
|
|
223
164
|
if activate:
|
|
224
|
-
self.activate_token(
|
|
165
|
+
self.activate_token(file)
|
|
225
166
|
|
|
226
|
-
return
|
|
167
|
+
return file
|
|
227
168
|
|
|
228
169
|
def list_personal_tokens(self) -> list[TokenFile]:
|
|
229
170
|
"""
|
|
@@ -237,33 +178,26 @@ class Settings:
|
|
|
237
178
|
"""
|
|
238
179
|
return [f[1] for f in self._list_personal_tokens()]
|
|
239
180
|
|
|
240
|
-
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
|
+
"""
|
|
241
185
|
token_file = tf.loads(token)
|
|
242
186
|
if token_file.type != "service_account":
|
|
243
187
|
raise ValueError("Token type MUST be service_account")
|
|
188
|
+
|
|
244
189
|
file = token_file.get_token_file_name()
|
|
245
190
|
path = self.config_dir / file
|
|
191
|
+
if path.exists() and not overwrite_if_exists:
|
|
192
|
+
raise FileExistsError(f"Token file already exists: {path}")
|
|
246
193
|
|
|
247
194
|
self._write_token_file(path, token_file)
|
|
248
|
-
print(f"Service account token stored at {path}")
|
|
249
195
|
cli_config = self.get_cli_config()
|
|
250
196
|
cli_config.init_account(email=token_file.account.email, token_file=token_file)
|
|
251
197
|
self._write_config_file(cli_config)
|
|
252
198
|
|
|
253
|
-
# if activate:
|
|
254
|
-
# self.activate_token(token_file.account.email)
|
|
255
|
-
|
|
256
199
|
return token_file
|
|
257
200
|
|
|
258
|
-
# token_file = tf.loads(token)
|
|
259
|
-
# file = f"{SERVICE_ACCOUNT_TOKEN_FILE_PREFIX}{service_account}-{token_file.name}.json"
|
|
260
|
-
# path = self.config_dir / file
|
|
261
|
-
# if path.exists():
|
|
262
|
-
# raise FileExistsError(f"Token file already exists: {path}")
|
|
263
|
-
|
|
264
|
-
# self._write_token_file(path, token_file)
|
|
265
|
-
# return token_file
|
|
266
|
-
|
|
267
201
|
def list_service_account_tokens(self) -> list[TokenFile]:
|
|
268
202
|
"""
|
|
269
203
|
List all service account tokens
|
|
@@ -290,7 +224,11 @@ class Settings:
|
|
|
290
224
|
return matches[0]
|
|
291
225
|
|
|
292
226
|
def _list_token_files(self, prefix: str = "") -> list[TokenFileMetadata]:
|
|
293
|
-
"""
|
|
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
|
+
"""
|
|
294
232
|
|
|
295
233
|
def is_valid_json(path: Path) -> bool:
|
|
296
234
|
try:
|
|
@@ -306,12 +244,13 @@ class Settings:
|
|
|
306
244
|
tf.PERSONAL_TOKEN_FILE_PREFIX
|
|
307
245
|
)
|
|
308
246
|
has_correct_prefix = path.is_file() and path.name.startswith(prefix)
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
312
252
|
|
|
313
253
|
paths = [path for path in self.config_dir.iterdir() if is_valid_token_file(path)]
|
|
314
|
-
|
|
315
254
|
return [(self._read_token_file(token_file), token_file) for token_file in paths]
|
|
316
255
|
|
|
317
256
|
def _read_token_file(self, path: Path) -> TokenFile:
|
|
@@ -338,8 +277,11 @@ class Settings:
|
|
|
338
277
|
return self._write_config_file(config)
|
|
339
278
|
|
|
340
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
|
+
"""
|
|
341
283
|
data = config_file.dumps(config)
|
|
342
|
-
path = self._write_file(self.
|
|
284
|
+
path = self._write_file(self.config_file_path, data)
|
|
343
285
|
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
344
286
|
return path
|
|
345
287
|
|
|
@@ -350,43 +292,8 @@ class Settings:
|
|
|
350
292
|
path.write_text(data, encoding="utf8")
|
|
351
293
|
return path
|
|
352
294
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
return
|
|
356
|
-
|
|
357
|
-
sys.stderr.write(f"migrating deprecated config directory {path} to {self.config_dir}\n")
|
|
358
|
-
shutil.copytree(str(path), str(self.config_dir), dirs_exist_ok=True)
|
|
359
|
-
secret = path / ACTIVE_TOKEN_FILE_NAME
|
|
360
|
-
if secret.exists():
|
|
361
|
-
sys.stderr.write(f"Removing old activated token {secret}")
|
|
362
|
-
secret.unlink(missing_ok=True)
|
|
363
|
-
# value = secret.read_text(encoding="utf-8").strip()
|
|
364
|
-
# The existing token file might either be a token file, or simply a string. We handle both cases...
|
|
365
|
-
# try:
|
|
366
|
-
# token = tf.loads(value)
|
|
367
|
-
# except JSONDecodeError:
|
|
368
|
-
# token = tf.TokenFile(
|
|
369
|
-
# version="1.0",
|
|
370
|
-
# type="service-account" if value.startswith("sa") else "authorized_user",
|
|
371
|
-
# name="MigratedActiveToken",
|
|
372
|
-
# token=value,
|
|
373
|
-
# created=str(datetime.datetime.now().isoformat()),
|
|
374
|
-
# expires="unknown",
|
|
375
|
-
# account=TokenFileAccount(email="unknown@remotivecloud.com"),
|
|
376
|
-
# )
|
|
377
|
-
# self.add_and_activate_short_lived_cli_token(tf.dumps(token))
|
|
378
|
-
shutil.rmtree(str(path))
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
def create_settings() -> Settings:
|
|
382
|
-
"""Create remotive CLI config directory and return its settings instance"""
|
|
383
|
-
return Settings(
|
|
384
|
-
CONFIG_DIR_PATH,
|
|
385
|
-
deprecated_config_dirs=[DEPRECATED_CONFIG_DIR_PATH, INCORRECT_CONFIG_DIR_PATH],
|
|
386
|
-
)
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
settings = create_settings()
|
|
295
|
+
|
|
296
|
+
settings = Settings(CONFIG_DIR_PATH)
|
|
390
297
|
"""
|
|
391
298
|
Global/module-level settings instance. Module-level variables are only loaded once, at import time.
|
|
392
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
|
@@ -7,9 +7,9 @@ from dataclasses import dataclass
|
|
|
7
7
|
from datetime import date, datetime
|
|
8
8
|
from typing import Any, Literal
|
|
9
9
|
|
|
10
|
-
# from cli.settings.core import PERSONAL_TOKEN_FILE_PREFIX, SERVICE_ACCOUNT_TOKEN_FILE_PREFIX
|
|
11
|
-
|
|
12
10
|
DEFAULT_EMAIL = "unknown@remotivecloud.com"
|
|
11
|
+
PERSONAL_TOKEN_FILE_PREFIX = "personal-token-"
|
|
12
|
+
SERVICE_ACCOUNT_TOKEN_FILE_PREFIX = "service-account-token-"
|
|
13
13
|
|
|
14
14
|
TokenType = Literal["authorized_user", "service_account"]
|
|
15
15
|
|
|
@@ -65,10 +65,6 @@ class TokenFileAccount:
|
|
|
65
65
|
email: str
|
|
66
66
|
|
|
67
67
|
|
|
68
|
-
PERSONAL_TOKEN_FILE_PREFIX = "personal-token-"
|
|
69
|
-
SERVICE_ACCOUNT_TOKEN_FILE_PREFIX = "service-account-token-"
|
|
70
|
-
|
|
71
|
-
|
|
72
68
|
@dataclass
|
|
73
69
|
class TokenFile:
|
|
74
70
|
version: str
|
cli/topology/cmd.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import dataclasses
|
|
2
4
|
import datetime
|
|
3
5
|
from typing import Any
|
|
@@ -22,30 +24,31 @@ class Subscription:
|
|
|
22
24
|
type: str
|
|
23
25
|
display_name: str
|
|
24
26
|
feature: str
|
|
25
|
-
start_date: str
|
|
26
|
-
end_date: str
|
|
27
|
+
start_date: str # TODO: add datetime
|
|
28
|
+
end_date: str # TODO: add datetime
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
def _print_current_subscription(subscription_info: dict[str, Any]) -> None:
|
|
30
|
-
subscription_type = subscription_info
|
|
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)
|
|
31
40
|
|
|
32
41
|
if subscription_type == "trial":
|
|
33
|
-
expires
|
|
34
|
-
|
|
35
|
-
console.print(f"Your Topology trial expired {subscription_info['endDate']}, please contact support@remotivelabs.com")
|
|
42
|
+
if expires and expires < now:
|
|
43
|
+
console.print(f"Your Topology trial expired {end_date_str}, please contact support@remotivelabs.com")
|
|
36
44
|
else:
|
|
37
|
-
console.print(f"You already have an active topology trial, it expires {
|
|
38
|
-
# A paid subscription might not have an endDate
|
|
39
|
-
elif subscription_type == "paid":
|
|
40
|
-
if "endDate" in subscription_type:
|
|
41
|
-
expires = datetime.datetime.fromisoformat(subscription_info["endDate"])
|
|
42
|
-
else:
|
|
43
|
-
expires = None
|
|
45
|
+
console.print(f"You already have an active topology trial, it expires {end_date_str}")
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
elif subscription_type == "paid":
|
|
48
|
+
if expires and expires < now:
|
|
49
|
+
console.print(f"Topology subscription has ended, expired {end_date_str}")
|
|
47
50
|
else:
|
|
48
|
-
console.print(f"You already have an active topology subscription, it expires {
|
|
51
|
+
console.print(f"You already have an active topology subscription, it expires {end_date_str or 'Never'}")
|
|
49
52
|
|
|
50
53
|
else:
|
|
51
54
|
ErrorPrinter.print_generic_error("Unexpected exception, please contact support@remotivelabs.com")
|
cli/utils/rest_helper.py
CHANGED
|
@@ -52,7 +52,7 @@ class RestHelper:
|
|
|
52
52
|
# token = os.environ["REMOTIVE_CLOUD_AUTH_TOKEN"]
|
|
53
53
|
# headers = {"Authorization": "Bearer " + token}
|
|
54
54
|
|
|
55
|
-
__headers: Dict[str, str] = {"User-Agent": f"remotivelabs-cli
|
|
55
|
+
__headers: Dict[str, str] = {"User-Agent": f"remotivelabs-cli/{version('remotivelabs-cli')}"}
|
|
56
56
|
__org: str = ""
|
|
57
57
|
|
|
58
58
|
__token: str = ""
|
|
@@ -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=
|
|
17
|
+
cli/cloud/auth/cmd.py,sha256=gLmfjIN9Vrytk9BwGH1cq4WeeryOfwTTnQ4yuZrbRcs,2757
|
|
18
18
|
cli/cloud/auth/login.py,sha256=dX6M5ysE0n9Zg3gVT7hJbChxTsmuba-Z-1Or6DCFYis,11511
|
|
19
|
-
cli/cloud/auth_tokens.py,sha256=
|
|
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
|
|
@@ -37,24 +37,28 @@ 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
39
|
cli/errors.py,sha256=djODw6sdMJXzOsuAUOP3N13nfmm1sIP3Pe6tllGdozM,1657
|
|
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/
|
|
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
|
|
51
|
-
cli/topology/cmd.py,sha256=
|
|
55
|
+
cli/topology/cmd.py,sha256=SQ5wi7KDoh4iR2Ed7gyfGLNj6UE0K6UkksmBMSD2XAk,3981
|
|
52
56
|
cli/typer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
53
57
|
cli/typer/typer_utils.py,sha256=8SkvG9aKkfK9fTRsLD9pOBtWn9XSwtOXWg2RAk9FhOI,708
|
|
54
58
|
cli/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
55
|
-
cli/utils/rest_helper.py,sha256=
|
|
56
|
-
remotivelabs_cli-0.2.
|
|
57
|
-
remotivelabs_cli-0.2.
|
|
58
|
-
remotivelabs_cli-0.2.
|
|
59
|
-
remotivelabs_cli-0.2.
|
|
60
|
-
remotivelabs_cli-0.2.
|
|
59
|
+
cli/utils/rest_helper.py,sha256=De-1Z75p-zjA4hZrylVxWn2wqdPB2gvyvA-ixm_dRFo,14141
|
|
60
|
+
remotivelabs_cli-0.2.1.dist-info/LICENSE,sha256=qDPP_yfuv1fF-u7EfexN-cN3M8aFgGVndGhGLovLKz0,608
|
|
61
|
+
remotivelabs_cli-0.2.1.dist-info/METADATA,sha256=gJSJmVNokErSbGtxifX0Z2uMMFdXy77NC4LLD6Owx8A,1428
|
|
62
|
+
remotivelabs_cli-0.2.1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
63
|
+
remotivelabs_cli-0.2.1.dist-info/entry_points.txt,sha256=lvDhPgagLqW_KTnLPCwKSqfYlEp-1uYVosRiPjsVj10,45
|
|
64
|
+
remotivelabs_cli-0.2.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|