remotivelabs-cli 0.2.0a2__py3-none-any.whl → 0.2.2__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 +54 -22
- cli/settings/__init__.py +19 -3
- cli/settings/config_file.py +26 -74
- cli/settings/core.py +70 -149
- 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/state_file.py +32 -0
- cli/settings/token_file.py +92 -61
- cli/topology/cmd.py +19 -16
- cli/typer/typer_utils.py +1 -1
- cli/utils/rest_helper.py +1 -1
- cli/utils/time.py +11 -0
- cli/utils/version_check.py +112 -0
- {remotivelabs_cli-0.2.0a2.dist-info → remotivelabs_cli-0.2.2.dist-info}/METADATA +3 -1
- {remotivelabs_cli-0.2.0a2.dist-info → remotivelabs_cli-0.2.2.dist-info}/RECORD +26 -19
- /cli/settings/{migrate_token_file.py → migration/migrate_token_file.py} +0 -0
- {remotivelabs_cli-0.2.0a2.dist-info → remotivelabs_cli-0.2.2.dist-info}/LICENSE +0 -0
- {remotivelabs_cli-0.2.0a2.dist-info → remotivelabs_cli-0.2.2.dist-info}/WHEEL +0 -0
- {remotivelabs_cli-0.2.0a2.dist-info → remotivelabs_cli-0.2.2.dist-info}/entry_points.txt +0 -0
cli/settings/core.py
CHANGED
|
@@ -1,31 +1,27 @@
|
|
|
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
|
|
|
10
|
+
from pydantic import ValidationError
|
|
12
11
|
from rich.console import Console
|
|
13
12
|
|
|
14
13
|
from cli.errors import ErrorPrinter
|
|
15
14
|
from cli.settings import config_file, token_file
|
|
16
15
|
from cli.settings import token_file as tf
|
|
17
16
|
from cli.settings.config_file import ConfigFile
|
|
17
|
+
from cli.settings.state_file import StateFile
|
|
18
18
|
from cli.settings.token_file import TokenFile
|
|
19
19
|
|
|
20
20
|
err_console = Console(stderr=True)
|
|
21
21
|
|
|
22
|
-
|
|
23
22
|
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
23
|
CLI_CONFIG_FILE_NAME = "config.json"
|
|
28
|
-
|
|
24
|
+
CLI_INTERNAL_STATE_FILE_NAME = "app-state.json"
|
|
29
25
|
|
|
30
26
|
TokenFileMetadata = Tuple[TokenFile, Path]
|
|
31
27
|
|
|
@@ -34,67 +30,30 @@ class InvalidSettingsFilePathError(Exception):
|
|
|
34
30
|
"""Raised when trying to access an invalid settings file or file path"""
|
|
35
31
|
|
|
36
32
|
|
|
37
|
-
class NotFoundError(Exception):
|
|
38
|
-
"""Raised when a token cannot be found in settings"""
|
|
39
|
-
|
|
40
|
-
|
|
41
33
|
class TokenNotFoundError(Exception):
|
|
42
34
|
"""Raised when a token cannot be found in settings"""
|
|
43
35
|
|
|
44
36
|
|
|
45
|
-
@dataclass()
|
|
46
|
-
class CliConfigFile:
|
|
47
|
-
default_organisation: Union[str, None]
|
|
48
|
-
|
|
49
|
-
|
|
50
37
|
class Settings:
|
|
51
38
|
"""
|
|
52
|
-
Settings for the remotive CLI
|
|
39
|
+
Settings handles tokens and other config for the remotive CLI
|
|
53
40
|
"""
|
|
54
41
|
|
|
55
42
|
config_dir: Path
|
|
56
43
|
|
|
57
|
-
def __init__(self, config_dir: Path
|
|
44
|
+
def __init__(self, config_dir: Path) -> None:
|
|
58
45
|
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
46
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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)
|
|
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())
|
|
94
54
|
|
|
95
55
|
def set_default_organisation(self, organisation: str) -> None:
|
|
96
56
|
cli_config = self.get_cli_config()
|
|
97
|
-
|
|
98
57
|
try:
|
|
99
58
|
token = settings.get_active_token_file()
|
|
100
59
|
cli_config.set_account_field(token.account.email, organisation)
|
|
@@ -108,7 +67,6 @@ class Settings:
|
|
|
108
67
|
return self._read_config_file()
|
|
109
68
|
except TokenNotFoundError:
|
|
110
69
|
return ConfigFile()
|
|
111
|
-
# self._write_properties(CONFIG_DIR_PATH / CLI_CONFIG_FILE_NAME, [])
|
|
112
70
|
|
|
113
71
|
def get_active_token(self) -> str:
|
|
114
72
|
"""
|
|
@@ -121,30 +79,22 @@ class Settings:
|
|
|
121
79
|
"""
|
|
122
80
|
Get the current active token file
|
|
123
81
|
"""
|
|
124
|
-
|
|
125
82
|
active_account = self.get_cli_config().get_active()
|
|
126
83
|
if active_account is not None:
|
|
127
84
|
token_file_name = active_account.credentials_file
|
|
128
85
|
return self._read_token_file(self.config_dir / token_file_name)
|
|
129
86
|
|
|
130
87
|
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
88
|
|
|
135
|
-
def activate_token(self,
|
|
89
|
+
def activate_token(self, token_file: TokenFile) -> None:
|
|
136
90
|
"""
|
|
137
91
|
Activate a token by name or path
|
|
138
92
|
|
|
139
93
|
The token secret will be set as the current active secret.
|
|
140
94
|
"""
|
|
141
|
-
# token_file = self.get_token_file(name)
|
|
142
95
|
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)
|
|
96
|
+
cli_config.activate(token_file.account.email)
|
|
146
97
|
self._write_config_file(cli_config)
|
|
147
|
-
# self._write_token_file(self._active_secret_token_path, token_file)
|
|
148
98
|
|
|
149
99
|
def clear_active_token(self) -> None:
|
|
150
100
|
"""
|
|
@@ -154,9 +104,12 @@ class Settings:
|
|
|
154
104
|
config.active = None
|
|
155
105
|
self._write_config_file(config)
|
|
156
106
|
|
|
157
|
-
# self._active_secret_token_path.unlink(missing_ok=True)
|
|
158
|
-
|
|
159
107
|
def get_token_file_by_email(self, email: str) -> Optional[TokenFile]:
|
|
108
|
+
"""
|
|
109
|
+
Get a token file by email.
|
|
110
|
+
|
|
111
|
+
If multiple tokens are found, the first one is returned.
|
|
112
|
+
"""
|
|
160
113
|
tokens = [t for t in self.list_personal_tokens() if t.account is not None and t.account.email == email]
|
|
161
114
|
if len(tokens) > 0:
|
|
162
115
|
return tokens[0]
|
|
@@ -169,11 +122,15 @@ class Settings:
|
|
|
169
122
|
"""
|
|
170
123
|
Get a token file by name or path
|
|
171
124
|
"""
|
|
125
|
+
# 1. Try relative path
|
|
126
|
+
if (self.config_dir / name).exists():
|
|
127
|
+
return self._read_token_file(self.config_dir / name)
|
|
128
|
+
|
|
129
|
+
# 2. Try absolute path
|
|
172
130
|
if Path(name).exists():
|
|
173
131
|
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
132
|
|
|
133
|
+
# 3. Try name
|
|
177
134
|
return self._get_token_by_name(name)[0]
|
|
178
135
|
|
|
179
136
|
def remove_token_file(self, name: str) -> None:
|
|
@@ -189,41 +146,39 @@ class Settings:
|
|
|
189
146
|
|
|
190
147
|
# TODO: what about the active token?
|
|
191
148
|
path = self._get_token_by_name(name)[1]
|
|
192
|
-
# print("Deleting", path)
|
|
193
149
|
return path.unlink()
|
|
194
150
|
|
|
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:
|
|
151
|
+
def add_personal_token(self, token: str, activate: bool = False, overwrite_if_exists: bool = False) -> TokenFile:
|
|
209
152
|
"""
|
|
210
153
|
Add a personal token
|
|
211
154
|
"""
|
|
212
|
-
|
|
213
|
-
file
|
|
214
|
-
|
|
155
|
+
file = tf.loads(token)
|
|
156
|
+
if file.type != "authorized_user":
|
|
157
|
+
raise ValueError("Token type MUST be authorized_user")
|
|
158
|
+
|
|
159
|
+
file_name = file.get_token_file_name()
|
|
160
|
+
path = self.config_dir / file_name
|
|
215
161
|
if path.exists() and not overwrite_if_exists:
|
|
216
162
|
raise FileExistsError(f"Token file already exists: {path}")
|
|
217
163
|
|
|
218
|
-
self._write_token_file(path,
|
|
164
|
+
self._write_token_file(path, file)
|
|
219
165
|
cli_config = self.get_cli_config()
|
|
220
|
-
cli_config.init_account(email=
|
|
166
|
+
cli_config.init_account(email=file.account.email, token_file=file)
|
|
221
167
|
self._write_config_file(cli_config)
|
|
222
168
|
|
|
223
169
|
if activate:
|
|
224
|
-
self.activate_token(
|
|
170
|
+
self.activate_token(file)
|
|
225
171
|
|
|
226
|
-
return
|
|
172
|
+
return file
|
|
173
|
+
|
|
174
|
+
def read_state_file(self) -> StateFile:
|
|
175
|
+
try:
|
|
176
|
+
return StateFile.loads(self.state_file_path.read_text(encoding="utf-8"))
|
|
177
|
+
except Exception:
|
|
178
|
+
return StateFile()
|
|
179
|
+
|
|
180
|
+
def write_state_file(self, state_file: StateFile) -> None:
|
|
181
|
+
self._write_file(self.state_file_path, state_file.dumps())
|
|
227
182
|
|
|
228
183
|
def list_personal_tokens(self) -> list[TokenFile]:
|
|
229
184
|
"""
|
|
@@ -237,33 +192,26 @@ class Settings:
|
|
|
237
192
|
"""
|
|
238
193
|
return [f[1] for f in self._list_personal_tokens()]
|
|
239
194
|
|
|
240
|
-
def add_service_account_token(self, token: str) -> TokenFile:
|
|
195
|
+
def add_service_account_token(self, token: str, overwrite_if_exists: bool = False) -> TokenFile:
|
|
196
|
+
"""
|
|
197
|
+
Add a service account token
|
|
198
|
+
"""
|
|
241
199
|
token_file = tf.loads(token)
|
|
242
200
|
if token_file.type != "service_account":
|
|
243
201
|
raise ValueError("Token type MUST be service_account")
|
|
202
|
+
|
|
244
203
|
file = token_file.get_token_file_name()
|
|
245
204
|
path = self.config_dir / file
|
|
205
|
+
if path.exists() and not overwrite_if_exists:
|
|
206
|
+
raise FileExistsError(f"Token file already exists: {path}")
|
|
246
207
|
|
|
247
208
|
self._write_token_file(path, token_file)
|
|
248
|
-
print(f"Service account token stored at {path}")
|
|
249
209
|
cli_config = self.get_cli_config()
|
|
250
210
|
cli_config.init_account(email=token_file.account.email, token_file=token_file)
|
|
251
211
|
self._write_config_file(cli_config)
|
|
252
212
|
|
|
253
|
-
# if activate:
|
|
254
|
-
# self.activate_token(token_file.account.email)
|
|
255
|
-
|
|
256
213
|
return token_file
|
|
257
214
|
|
|
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
215
|
def list_service_account_tokens(self) -> list[TokenFile]:
|
|
268
216
|
"""
|
|
269
217
|
List all service account tokens
|
|
@@ -290,13 +238,17 @@ class Settings:
|
|
|
290
238
|
return matches[0]
|
|
291
239
|
|
|
292
240
|
def _list_token_files(self, prefix: str = "") -> list[TokenFileMetadata]:
|
|
293
|
-
"""
|
|
241
|
+
"""
|
|
242
|
+
list all tokens with the correct prefix in the config dir, but omit files that are not token files
|
|
243
|
+
|
|
244
|
+
TODO: improve is_valid_json and is_valid_token_file using token_file parsing instead
|
|
245
|
+
"""
|
|
294
246
|
|
|
295
247
|
def is_valid_json(path: Path) -> bool:
|
|
296
248
|
try:
|
|
297
249
|
self._read_token_file(path)
|
|
298
250
|
return True
|
|
299
|
-
except JSONDecodeError:
|
|
251
|
+
except (JSONDecodeError, ValidationError):
|
|
300
252
|
# TODO - this should be printed but printing it here causes it to be displayed to many times
|
|
301
253
|
# err_console.print(f"File is not valid json, skipping. {path}")
|
|
302
254
|
return False
|
|
@@ -306,12 +258,13 @@ class Settings:
|
|
|
306
258
|
tf.PERSONAL_TOKEN_FILE_PREFIX
|
|
307
259
|
)
|
|
308
260
|
has_correct_prefix = path.is_file() and path.name.startswith(prefix)
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
261
|
+
is_cli_config = path == self.config_file_path
|
|
262
|
+
is_present_in_cli_config_accounts = any(
|
|
263
|
+
path.name == account.credentials_file for account in self.get_cli_config().accounts.values()
|
|
264
|
+
)
|
|
265
|
+
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
266
|
|
|
313
267
|
paths = [path for path in self.config_dir.iterdir() if is_valid_token_file(path)]
|
|
314
|
-
|
|
315
268
|
return [(self._read_token_file(token_file), token_file) for token_file in paths]
|
|
316
269
|
|
|
317
270
|
def _read_token_file(self, path: Path) -> TokenFile:
|
|
@@ -338,8 +291,11 @@ class Settings:
|
|
|
338
291
|
return self._write_config_file(config)
|
|
339
292
|
|
|
340
293
|
def _write_config_file(self, config: ConfigFile) -> Path:
|
|
294
|
+
"""
|
|
295
|
+
TODO: add read cache to avoid parsing the config every time we read it
|
|
296
|
+
"""
|
|
341
297
|
data = config_file.dumps(config)
|
|
342
|
-
path = self._write_file(self.
|
|
298
|
+
path = self._write_file(self.config_file_path, data)
|
|
343
299
|
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
344
300
|
return path
|
|
345
301
|
|
|
@@ -350,43 +306,8 @@ class Settings:
|
|
|
350
306
|
path.write_text(data, encoding="utf8")
|
|
351
307
|
return path
|
|
352
308
|
|
|
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()
|
|
309
|
+
|
|
310
|
+
settings = Settings(CONFIG_DIR_PATH)
|
|
390
311
|
"""
|
|
391
312
|
Global/module-level settings instance. Module-level variables are only loaded once, at import time.
|
|
392
313
|
|
|
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]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from dacite import from_dict
|
|
10
|
+
|
|
11
|
+
from cli.utils.time import parse_datetime
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class StateFile:
|
|
16
|
+
version: str = "1.0"
|
|
17
|
+
last_update_check_time: Optional[str] = None
|
|
18
|
+
|
|
19
|
+
def dumps(self) -> str:
|
|
20
|
+
return json.dumps(dataclasses.asdict(self), default=str)
|
|
21
|
+
|
|
22
|
+
def should_perform_update_check(self) -> bool:
|
|
23
|
+
if self.last_update_check_time:
|
|
24
|
+
seconds = (datetime.now() - parse_datetime(self.last_update_check_time)).seconds
|
|
25
|
+
return (seconds / 3600) > 2
|
|
26
|
+
# This will solve the issue
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def loads(data: str) -> StateFile:
|
|
31
|
+
d = json.loads(data)
|
|
32
|
+
return from_dict(StateFile, d)
|