remotivelabs-cli 0.5.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. remotivelabs/cli/__init__.py +0 -0
  2. remotivelabs/cli/api/cloud/tokens.py +62 -0
  3. remotivelabs/cli/broker/__init__.py +33 -0
  4. remotivelabs/cli/broker/defaults.py +1 -0
  5. remotivelabs/cli/broker/discovery.py +43 -0
  6. remotivelabs/cli/broker/export.py +92 -0
  7. remotivelabs/cli/broker/files.py +119 -0
  8. remotivelabs/cli/broker/lib/__about__.py +4 -0
  9. remotivelabs/cli/broker/lib/broker.py +625 -0
  10. remotivelabs/cli/broker/lib/client.py +224 -0
  11. remotivelabs/cli/broker/lib/helper.py +277 -0
  12. remotivelabs/cli/broker/lib/signalcreator.py +196 -0
  13. remotivelabs/cli/broker/license_flows.py +167 -0
  14. remotivelabs/cli/broker/licenses.py +98 -0
  15. remotivelabs/cli/broker/playback.py +117 -0
  16. remotivelabs/cli/broker/record.py +41 -0
  17. remotivelabs/cli/broker/recording_session/__init__.py +3 -0
  18. remotivelabs/cli/broker/recording_session/client.py +67 -0
  19. remotivelabs/cli/broker/recording_session/cmd.py +254 -0
  20. remotivelabs/cli/broker/recording_session/time.py +49 -0
  21. remotivelabs/cli/broker/scripting.py +129 -0
  22. remotivelabs/cli/broker/signals.py +220 -0
  23. remotivelabs/cli/broker/version.py +31 -0
  24. remotivelabs/cli/cloud/__init__.py +17 -0
  25. remotivelabs/cli/cloud/auth/__init__.py +3 -0
  26. remotivelabs/cli/cloud/auth/cmd.py +128 -0
  27. remotivelabs/cli/cloud/auth/login.py +283 -0
  28. remotivelabs/cli/cloud/auth_tokens.py +149 -0
  29. remotivelabs/cli/cloud/brokers.py +109 -0
  30. remotivelabs/cli/cloud/configs.py +109 -0
  31. remotivelabs/cli/cloud/licenses/__init__.py +0 -0
  32. remotivelabs/cli/cloud/licenses/cmd.py +14 -0
  33. remotivelabs/cli/cloud/organisations.py +112 -0
  34. remotivelabs/cli/cloud/projects.py +44 -0
  35. remotivelabs/cli/cloud/recordings.py +580 -0
  36. remotivelabs/cli/cloud/recordings_playback.py +274 -0
  37. remotivelabs/cli/cloud/resumable_upload.py +87 -0
  38. remotivelabs/cli/cloud/sample_recordings.py +25 -0
  39. remotivelabs/cli/cloud/service_account_tokens.py +62 -0
  40. remotivelabs/cli/cloud/service_accounts.py +72 -0
  41. remotivelabs/cli/cloud/storage/__init__.py +5 -0
  42. remotivelabs/cli/cloud/storage/cmd.py +76 -0
  43. remotivelabs/cli/cloud/storage/copy.py +86 -0
  44. remotivelabs/cli/cloud/storage/uri_or_path.py +45 -0
  45. remotivelabs/cli/cloud/uri.py +113 -0
  46. remotivelabs/cli/connect/__init__.py +0 -0
  47. remotivelabs/cli/connect/connect.py +118 -0
  48. remotivelabs/cli/connect/protopie/protopie.py +185 -0
  49. remotivelabs/cli/py.typed +0 -0
  50. remotivelabs/cli/remotive.py +123 -0
  51. remotivelabs/cli/settings/__init__.py +20 -0
  52. remotivelabs/cli/settings/config_file.py +113 -0
  53. remotivelabs/cli/settings/core.py +333 -0
  54. remotivelabs/cli/settings/migration/__init__.py +0 -0
  55. remotivelabs/cli/settings/migration/migrate_all_token_files.py +80 -0
  56. remotivelabs/cli/settings/migration/migrate_config_file.py +64 -0
  57. remotivelabs/cli/settings/migration/migrate_legacy_dirs.py +50 -0
  58. remotivelabs/cli/settings/migration/migrate_token_file.py +52 -0
  59. remotivelabs/cli/settings/migration/migration_tools.py +38 -0
  60. remotivelabs/cli/settings/state_file.py +67 -0
  61. remotivelabs/cli/settings/token_file.py +128 -0
  62. remotivelabs/cli/tools/__init__.py +0 -0
  63. remotivelabs/cli/tools/can/__init__.py +0 -0
  64. remotivelabs/cli/tools/can/can.py +78 -0
  65. remotivelabs/cli/tools/tools.py +9 -0
  66. remotivelabs/cli/topology/__init__.py +28 -0
  67. remotivelabs/cli/topology/all.py +322 -0
  68. remotivelabs/cli/topology/cli/__init__.py +3 -0
  69. remotivelabs/cli/topology/cli/run_in_docker.py +58 -0
  70. remotivelabs/cli/topology/cli/topology_cli.py +16 -0
  71. remotivelabs/cli/topology/cmd.py +130 -0
  72. remotivelabs/cli/topology/start_trial.py +134 -0
  73. remotivelabs/cli/typer/__init__.py +0 -0
  74. remotivelabs/cli/typer/typer_utils.py +27 -0
  75. remotivelabs/cli/utils/__init__.py +0 -0
  76. remotivelabs/cli/utils/console.py +99 -0
  77. remotivelabs/cli/utils/rest_helper.py +369 -0
  78. remotivelabs/cli/utils/time.py +11 -0
  79. remotivelabs/cli/utils/versions.py +120 -0
  80. remotivelabs_cli-0.5.0a1.dist-info/METADATA +51 -0
  81. remotivelabs_cli-0.5.0a1.dist-info/RECORD +84 -0
  82. remotivelabs_cli-0.5.0a1.dist-info/WHEEL +4 -0
  83. remotivelabs_cli-0.5.0a1.dist-info/entry_points.txt +3 -0
  84. remotivelabs_cli-0.5.0a1.dist-info/licenses/LICENSE +17 -0
@@ -0,0 +1,20 @@
1
+ from remotivelabs.cli.settings.config_file import Account, ConfigFile
2
+ from remotivelabs.cli.settings.config_file import dumps as dumps_config_file
3
+ from remotivelabs.cli.settings.config_file import loads as loads_config_file
4
+ from remotivelabs.cli.settings.core import InvalidSettingsFilePathError, Settings, settings
5
+ from remotivelabs.cli.settings.token_file import TokenFile
6
+ from remotivelabs.cli.settings.token_file import dumps as dumps_token_file
7
+ from remotivelabs.cli.settings.token_file import loads as loads_token_file
8
+
9
+ __all__ = [
10
+ "settings",
11
+ "InvalidSettingsFilePathError",
12
+ "Settings",
13
+ "TokenFile",
14
+ "ConfigFile",
15
+ "Account",
16
+ "dumps_config_file",
17
+ "loads_config_file",
18
+ "dumps_token_file",
19
+ "loads_token_file",
20
+ ]
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ from pydantic import BaseModel, Field, model_validator
6
+
7
+ from remotivelabs.cli.settings.token_file import TokenFile
8
+
9
+
10
+ class Account(BaseModel):
11
+ """
12
+ Account represents an account in the configuration file.
13
+
14
+ TODO: Add email field to Account
15
+ """
16
+
17
+ credentials_file: str
18
+ default_organization: Optional[str] = None
19
+
20
+
21
+ class ConfigFile(BaseModel):
22
+ """
23
+ ConfigFile represents the configuration file for the CLI.
24
+
25
+ TODO: Should all setters return a new instance of the ConfigFile?
26
+ """
27
+
28
+ version: str = "1.0"
29
+ active: Optional[str] = None
30
+ accounts: dict[str, Account] = Field(default_factory=dict)
31
+
32
+ @model_validator(mode="before")
33
+ @classmethod
34
+ def _validate_json_data(cls, json_data: Any) -> Any:
35
+ """Try to migrate old formats and missing fields as best we can."""
36
+ if not isinstance(json_data, dict):
37
+ return json_data
38
+
39
+ # If the active account is not in accounts, remove it
40
+ if "active" in json_data and json_data["active"] not in json_data["accounts"]:
41
+ del json_data["active"]
42
+
43
+ return json_data
44
+
45
+ def get_active_account(self) -> Optional[Account]:
46
+ if not self.active:
47
+ return None
48
+ account = self.get_account(self.active)
49
+ if not account:
50
+ raise KeyError(f"Activated account {self.active} is not a valid account")
51
+ return account
52
+
53
+ def activate_account(self, email: str) -> None:
54
+ account = self.get_account(email)
55
+ if not account:
56
+ raise KeyError(f"Account {email} does not exists")
57
+ self.active = email
58
+
59
+ def _update_account(self, email: str, **updates: Any) -> None:
60
+ """TODO: Consider using model_copy and always return a new instance of ConfigFile"""
61
+ existing_account = self.get_account(email)
62
+ if existing_account:
63
+ updated_account = existing_account.model_copy(update=updates)
64
+ else:
65
+ updated_account = Account(**updates)
66
+
67
+ new_accounts = {**self.accounts, email: updated_account}
68
+ self.accounts = new_accounts
69
+
70
+ def init_account(self, email: str, token_file: TokenFile) -> None:
71
+ """
72
+ Create a new account with the given email and token file.
73
+ """
74
+ self._update_account(email, credentials_file=token_file.get_token_file_name())
75
+
76
+ def set_default_organization_for_account(self, email: str, default_organization: Optional[str] = None) -> None:
77
+ if not self.get_account(email):
78
+ raise KeyError(f"Account with email {email} has not been initialized with token")
79
+ self._update_account(email, default_organization=default_organization)
80
+
81
+ def get_account(self, email: str) -> Optional[Account]:
82
+ return self.accounts.get(email)
83
+
84
+ def remove_account(self, email: str) -> None:
85
+ self.accounts.pop(email, None)
86
+
87
+ @classmethod
88
+ def from_json_str(cls, data: str) -> ConfigFile:
89
+ return cls.model_validate_json(data)
90
+
91
+ @classmethod
92
+ def from_dict(cls, data: dict[str, Any]) -> ConfigFile:
93
+ return cls.model_validate(data)
94
+
95
+ def to_json_str(self) -> str:
96
+ return self.model_dump_json()
97
+
98
+ def to_dict(self) -> dict[str, Any]:
99
+ return self.model_dump()
100
+
101
+
102
+ def loads(data: str) -> ConfigFile:
103
+ """
104
+ Creates a ConfigFile from a JSON string.
105
+ """
106
+ return ConfigFile.from_json_str(data)
107
+
108
+
109
+ def dumps(config: ConfigFile) -> str:
110
+ """
111
+ Returns the JSON string representation of the ConfigFile.
112
+ """
113
+ return config.to_json_str()
@@ -0,0 +1,333 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import stat
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from rich.console import Console
10
+
11
+ from remotivelabs.cli.settings import config_file as cf
12
+ from remotivelabs.cli.settings import state_file as sf
13
+ from remotivelabs.cli.settings import token_file as tf
14
+ from remotivelabs.cli.settings.config_file import Account, ConfigFile
15
+ from remotivelabs.cli.settings.state_file import StateFile
16
+ from remotivelabs.cli.settings.token_file import TokenFile
17
+ from remotivelabs.cli.utils.console import print_hint
18
+
19
+ err_console = Console(stderr=True)
20
+
21
+ CONFIG_DIR_PATH = Path.home() / ".config" / "remotive"
22
+ CLI_CONFIG_FILE_NAME = "config.json"
23
+ CLI_INTERNAL_STATE_FILE_NAME = "app-state.json"
24
+
25
+ TOKEN_ENV = "REMOTIVE_CLOUD_AUTH_TOKEN"
26
+ # Deprecated in favour of name used in topology-cli
27
+ DEPR_TOKEN_ENV = "REMOTIVE_CLOUD_ACCESS_TOKEN"
28
+
29
+
30
+ class InvalidSettingsFilePathError(Exception):
31
+ """Raised when trying to access an invalid settings file or file path"""
32
+
33
+
34
+ class Settings:
35
+ """
36
+ Settings handles tokens and other config for the remotive CLI
37
+
38
+ TODO: migrate away from singleton instance
39
+ TODO: How do we handle REMOTIVE_CLOUD_ACCESS_TOKEN in combination with active account? What takes precedence?
40
+ """
41
+
42
+ config_dir: Path
43
+
44
+ def __init__(self, config_dir: Path) -> None:
45
+ self.config_dir = config_dir
46
+ self.config_dir.mkdir(parents=True, exist_ok=True)
47
+ self.config_file_path = self.config_dir / CLI_CONFIG_FILE_NAME
48
+ if not self.config_file_path.exists():
49
+ self._write_config_file(ConfigFile())
50
+ self.state_dir = self.config_dir / "state"
51
+ self.state_file_path = self.state_dir / CLI_INTERNAL_STATE_FILE_NAME
52
+ if not self.state_file_path.exists():
53
+ self._write_state_file(StateFile())
54
+
55
+ def _get_cli_config(self) -> ConfigFile:
56
+ return self._read_config_file()
57
+
58
+ def _get_state_file(self) -> StateFile:
59
+ return self._read_state_file()
60
+
61
+ def should_perform_update_check(self) -> bool:
62
+ """
63
+ Check if we should perform an update check.
64
+ """
65
+ return self._get_state_file().should_perform_update_check()
66
+
67
+ def set_default_organisation(self, organisation: str) -> None:
68
+ """
69
+ Set the default organization for the active account
70
+
71
+ TODO: Raise error, dont sys.exit
72
+ """
73
+ config = self._get_cli_config()
74
+ active_account = config.get_active_account()
75
+ if not active_account:
76
+ print_hint("You must have an account activated in order to set default organization")
77
+ sys.exit(1)
78
+ active_account.default_organization = organisation
79
+ self._write_config_file(config)
80
+
81
+ def get_active_account(self) -> Account | None:
82
+ """
83
+ Get the current active account
84
+
85
+ TODO: Add email field to Account
86
+ """
87
+ return self._get_cli_config().get_active_account()
88
+
89
+ def get_active_token_file(self) -> TokenFile | None:
90
+ """
91
+ Get the token file for the current active account
92
+ """
93
+ active_account = self.get_active_account()
94
+ return self._read_token_file(active_account.credentials_file) if active_account else None
95
+
96
+ def get_active_token(self) -> str | None:
97
+ """
98
+ Get the token secret for the current active account or token specified by env variable
99
+ """
100
+
101
+ token = os.environ[DEPR_TOKEN_ENV] if DEPR_TOKEN_ENV in os.environ else None
102
+ if not token:
103
+ token = os.environ[TOKEN_ENV] if TOKEN_ENV in os.environ else None
104
+ if token:
105
+ return token
106
+
107
+ token_file = self.get_active_token_file()
108
+ return token_file.token if token_file else None
109
+
110
+ def activate_token(self, token_file: TokenFile) -> TokenFile:
111
+ """
112
+ Activate a token by name or path
113
+
114
+ The token secret will be set as the current active secret.
115
+
116
+ Returns the activated token file
117
+ """
118
+ config = self._get_cli_config()
119
+ config.activate_account(token_file.account.email)
120
+ self._write_config_file(config)
121
+ return token_file
122
+
123
+ def is_active_account(self, email: str) -> bool:
124
+ """
125
+ Returns True if the given email is the active account
126
+ """
127
+ return self._get_cli_config().active == email
128
+
129
+ def clear_active_account(self) -> None:
130
+ """
131
+ Clear the current active token
132
+ """
133
+ config = self._get_cli_config()
134
+ config.active = None
135
+ self._write_config_file(config)
136
+
137
+ def get_token_file_by_email(self, email: str) -> Optional[TokenFile]:
138
+ """
139
+ Get a token file by email.
140
+
141
+ If multiple tokens are found, the first one is returned.
142
+ """
143
+ accounts = self._get_cli_config().accounts.get(email)
144
+ return self._read_token_file(accounts.credentials_file) if accounts else None
145
+
146
+ def get_token_file(self, name: str) -> TokenFile | None:
147
+ """
148
+ Get a token file by name or path
149
+ """
150
+ # 1. Try relative path
151
+ if (self.config_dir / name).exists():
152
+ return self._read_token_file(name)
153
+
154
+ # 2. Try name
155
+ return self._get_token_by_name(name)
156
+
157
+ def remove_token_file(self, name: str) -> None:
158
+ """
159
+ Remove a token file by name or path
160
+ """
161
+ token_file = self.get_token_file(name)
162
+ if not token_file:
163
+ return
164
+
165
+ # If the token is active, clear it first
166
+ email = token_file.account.email
167
+ if self.is_active_account(email):
168
+ self.clear_active_account()
169
+
170
+ # Remove the token file
171
+ path = self.config_dir / self._get_cli_config().accounts[email].credentials_file
172
+ path.unlink()
173
+
174
+ # Remove the account from the config file
175
+ config = self._get_cli_config()
176
+ config.remove_account(email)
177
+ self._write_config_file(config)
178
+
179
+ def add_personal_token(self, token: str, activate: bool = False, overwrite_if_exists: bool = False) -> TokenFile:
180
+ """
181
+ Add a personal token
182
+ """
183
+ token_file = tf.loads(token)
184
+ if token_file.type != "authorized_user":
185
+ raise ValueError("Token type MUST be authorized_user")
186
+
187
+ token_file = self.add_token_as_account(token_file, overwrite_if_exists)
188
+
189
+ if activate:
190
+ self.activate_token(token_file)
191
+
192
+ return token_file
193
+
194
+ def add_service_account_token(self, token: str, overwrite_if_exists: bool = False) -> TokenFile:
195
+ """
196
+ Add a service account token
197
+ """
198
+ token_file = tf.loads(token)
199
+ if token_file.type != "service_account":
200
+ raise ValueError("Token type MUST be service_account")
201
+
202
+ return self.add_token_as_account(token_file, overwrite_if_exists)
203
+
204
+ def add_token_as_account(self, token_file: TokenFile, overwrite_if_exists: bool = False) -> TokenFile:
205
+ """
206
+ Add an account to the config file
207
+ """
208
+ file_name = token_file.get_token_file_name()
209
+ path = self.config_dir / file_name
210
+ if path.exists() and not overwrite_if_exists:
211
+ raise FileExistsError(f"Token file already exists: {path}")
212
+
213
+ self._write_token_file(path, token_file)
214
+ cli_config = self._get_cli_config()
215
+ cli_config.init_account(email=token_file.account.email, token_file=token_file)
216
+ self._write_config_file(cli_config)
217
+
218
+ return token_file
219
+
220
+ def list_accounts(self) -> dict[str, Account]:
221
+ """
222
+ List all accounts
223
+ """
224
+ return self._get_cli_config().accounts
225
+
226
+ def list_personal_accounts(self) -> dict[str, Account]:
227
+ """
228
+ List all personal accounts
229
+
230
+ TODO: add account type to Account
231
+ """
232
+ accounts = self.list_accounts()
233
+ return {
234
+ email: account
235
+ for email, account in accounts.items()
236
+ if self._read_token_file(account.credentials_file).type == "authorized_user"
237
+ }
238
+
239
+ def list_service_accounts(self) -> dict[str, Account]:
240
+ """
241
+ List all personal accounts
242
+
243
+ TODO: add account type to Account
244
+ """
245
+ accounts = self.list_accounts()
246
+ return {
247
+ email: account
248
+ for email, account in accounts.items()
249
+ if self._read_token_file(account.credentials_file).type == "service_account"
250
+ }
251
+
252
+ def list_token_files(self) -> list[TokenFile]:
253
+ """
254
+ List all token files
255
+ """
256
+ accounts = self._get_cli_config().accounts.values()
257
+ return [self._read_token_file(account.credentials_file) for account in accounts]
258
+
259
+ def list_personal_token_files(self) -> list[TokenFile]:
260
+ """
261
+ List all personal token files
262
+ """
263
+ return [token_file for token_file in self.list_token_files() if token_file.type == "authorized_user"]
264
+
265
+ def list_service_account_token_files(self) -> list[TokenFile]:
266
+ """
267
+ List all service account token files
268
+ """
269
+ return [token_file for token_file in self.list_token_files() if token_file.type == "service_account"]
270
+
271
+ def set_last_update_check_time(self, timestamp: str) -> None:
272
+ """
273
+ Sets the timestamp of the last self update check
274
+ """
275
+ state = self._read_state_file()
276
+ state.last_update_check_time = timestamp
277
+ self._write_state_file(state)
278
+
279
+ def _get_token_by_name(self, name: str) -> TokenFile | None:
280
+ """
281
+ Token name is only available as a property of TokenFile, so we must iterate over all tokens to find the right one
282
+ """
283
+ token_files = self.list_token_files()
284
+ matches = [token_file for token_file in token_files if token_file.name == name]
285
+ if len(matches) != 1:
286
+ return None
287
+ return matches[0]
288
+
289
+ def _read_token_file(self, file_name: str) -> TokenFile:
290
+ path = self.config_dir / file_name
291
+ data = self._read_file(path)
292
+ return tf.loads(data)
293
+
294
+ def _write_token_file(self, path: Path, token: TokenFile) -> Path:
295
+ data = tf.dumps(token)
296
+ return self._write_file(path, data)
297
+
298
+ def _read_config_file(self) -> ConfigFile:
299
+ data = self._read_file(self.config_file_path)
300
+ return cf.loads(data)
301
+
302
+ def _write_config_file(self, config: ConfigFile) -> Path:
303
+ data = cf.dumps(config)
304
+ return self._write_file(self.config_file_path, data)
305
+
306
+ def _read_state_file(self) -> StateFile:
307
+ data = self._read_file(self.state_file_path)
308
+ return sf.loads(data)
309
+
310
+ def _write_state_file(self, state: StateFile) -> Path:
311
+ data = sf.dumps(state)
312
+ return self._write_file(self.state_file_path, data)
313
+
314
+ def _read_file(self, path: Path) -> str:
315
+ if not path.exists():
316
+ raise FileNotFoundError(f"File could not be found: {path}")
317
+ return path.read_text(encoding="utf-8")
318
+
319
+ def _write_file(self, path: Path, data: str) -> Path:
320
+ if self.config_dir not in path.parents:
321
+ raise InvalidSettingsFilePathError(f"file {path} not in settings dir {self.config_dir}")
322
+ path.parent.mkdir(parents=True, exist_ok=True)
323
+ path.write_text(data, encoding="utf8")
324
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
325
+ return path
326
+
327
+
328
+ settings = Settings(CONFIG_DIR_PATH)
329
+ """
330
+ Global/module-level settings instance. Module-level variables are only loaded once, at import time.
331
+
332
+ TODO: Migrate away from singleton instance
333
+ """
File without changes
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from remotivelabs.cli.settings.core import Settings
4
+ from remotivelabs.cli.settings.migration.migrate_token_file import InvalidTokenError, UnsupportedTokenVersionError, migrate_legacy_token
5
+ from remotivelabs.cli.settings.migration.migration_tools import list_token_files
6
+ from remotivelabs.cli.settings.token_file import TokenFile, dumps
7
+
8
+ ACTIVE_TOKEN_FILE_NAME = "cloud.secret.token"
9
+
10
+
11
+ def migrate_legacy_tokens(tokens: list[TokenFile]) -> tuple[list[TokenFile], set[str]]:
12
+ """
13
+ Determine which tokens can be updated and which should be removed.
14
+
15
+ Returns:
16
+ tuple of (updated_tokens, invalid_tokens)
17
+ """
18
+ updated_tokens: list[TokenFile] = []
19
+ invalid_tokens: set[str] = set()
20
+
21
+ for token in tokens:
22
+ try:
23
+ migrated_token = migrate_legacy_token(token)
24
+ if migrated_token.version != token.version:
25
+ updated_tokens.append(migrated_token)
26
+ except (InvalidTokenError, UnsupportedTokenVersionError):
27
+ # Token not valid or unsupported version, mark for removal
28
+ invalid_tokens.add(token.name)
29
+
30
+ return updated_tokens, invalid_tokens
31
+
32
+
33
+ def _write_updated_tokens(settings: Settings, updated_tokens: list[TokenFile]) -> None:
34
+ for updated_token in updated_tokens:
35
+ settings.remove_token_file(name=updated_token.name)
36
+ if updated_token.type == "authorized_user":
37
+ settings.add_personal_token(dumps(updated_token), overwrite_if_exists=True)
38
+ elif updated_token.type == "service_account":
39
+ settings.add_service_account_token(dumps(updated_token))
40
+ else:
41
+ raise ValueError(f"Unsupported token type: {updated_token.type}")
42
+
43
+
44
+ def _remove_invalid_tokens(settings: Settings, invalid_tokens: set[str]) -> None:
45
+ for token_name in invalid_tokens:
46
+ settings.remove_token_file(name=token_name)
47
+
48
+
49
+ def _remove_old_secret_file(settings: Settings) -> bool:
50
+ old_activated_secret_file = settings.config_dir / ACTIVE_TOKEN_FILE_NAME
51
+ old_secret_exists = old_activated_secret_file.exists()
52
+ if old_secret_exists:
53
+ old_activated_secret_file.unlink(missing_ok=True)
54
+ return old_secret_exists
55
+
56
+
57
+ def migrate_any_legacy_tokens(settings: Settings) -> bool:
58
+ """
59
+ Migrate any legacy tokens to the latest TokenFile format.
60
+
61
+ If the legacy secret file exists (cloud.secret.token), it will be removed.
62
+
63
+ Returns True if any tokens were migrated, False otherwise.
64
+ """
65
+ tokens = list_token_files(settings.config_dir)
66
+
67
+ # Get tokens to update/remove
68
+ updated_tokens, invalid_tokens = migrate_legacy_tokens(tokens)
69
+
70
+ # Perform file operations
71
+ _write_updated_tokens(settings, updated_tokens)
72
+ _remove_invalid_tokens(settings, invalid_tokens)
73
+
74
+ # Remove old secret file if exists
75
+ old_secret_removed = _remove_old_secret_file(settings)
76
+ if old_secret_removed:
77
+ return True # We migrated at least one token
78
+
79
+ # only return True if we migrated at least one token
80
+ return len(updated_tokens) + len(invalid_tokens) > 0
@@ -0,0 +1,64 @@
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 remotivelabs.cli.settings.config_file import ConfigFile, loads
9
+ from remotivelabs.cli.settings.core import Settings
10
+ from remotivelabs.cli.settings.migration.migration_tools import get_token_file
11
+
12
+
13
+ def migrate_account_data(config: dict[str, Any], settings: Settings) -> Optional[dict[str, Any]]:
14
+ """
15
+ Migrates Account property credentials_name to credentials_file
16
+ """
17
+ accounts = config.get("accounts", {})
18
+ to_delete = []
19
+ found_old = False
20
+ for account_email, account_info in list(accounts.items()):
21
+ cred_name = account_info.pop("credentials_name", None)
22
+ if not cred_name:
23
+ continue
24
+
25
+ # found legacy account, try to migrate it, or drop it...
26
+ found_old = True
27
+
28
+ token_file = get_token_file(cred_name, settings.config_dir)
29
+ if not token_file:
30
+ sys.stderr.write(f"Dropping account {account_email!r}: credentials file for {cred_name} not found")
31
+ to_delete.append(account_email)
32
+ continue
33
+
34
+ cred_file = token_file.get_token_file_name()
35
+ if not cred_file:
36
+ sys.stderr.write(f"Dropping account {account_email!r}: credentials file for {cred_name} not found")
37
+ to_delete.append(account_email)
38
+ continue
39
+
40
+ account_info["credentials_file"] = cred_file
41
+
42
+ # actually remove them (also remove active if it was the one being removed)
43
+ for account_email in to_delete:
44
+ del accounts[account_email]
45
+ if config.get("active", None) == account_email:
46
+ config["active"] = None
47
+
48
+ return config if found_old else None
49
+
50
+
51
+ def migrate_config_file(path: Path, settings: Settings) -> ConfigFile:
52
+ """
53
+ Migrates data in config file to new format
54
+ """
55
+ data = path.read_text()
56
+ loaded_data: dict[str, Any] = json.loads(data)
57
+ migrated_data = migrate_account_data(loaded_data, settings)
58
+ if not migrated_data:
59
+ return loads(data)
60
+
61
+ sys.stderr.write("Migrating old configuration format")
62
+ migrated_config: ConfigFile = ConfigFile.from_dict(migrated_data)
63
+ settings._write_config_file(migrated_config)
64
+ 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)