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.
- remotivelabs/cli/__init__.py +0 -0
- remotivelabs/cli/api/cloud/tokens.py +62 -0
- remotivelabs/cli/broker/__init__.py +33 -0
- remotivelabs/cli/broker/defaults.py +1 -0
- remotivelabs/cli/broker/discovery.py +43 -0
- remotivelabs/cli/broker/export.py +92 -0
- remotivelabs/cli/broker/files.py +119 -0
- remotivelabs/cli/broker/lib/__about__.py +4 -0
- remotivelabs/cli/broker/lib/broker.py +625 -0
- remotivelabs/cli/broker/lib/client.py +224 -0
- remotivelabs/cli/broker/lib/helper.py +277 -0
- remotivelabs/cli/broker/lib/signalcreator.py +196 -0
- remotivelabs/cli/broker/license_flows.py +167 -0
- remotivelabs/cli/broker/licenses.py +98 -0
- remotivelabs/cli/broker/playback.py +117 -0
- remotivelabs/cli/broker/record.py +41 -0
- remotivelabs/cli/broker/recording_session/__init__.py +3 -0
- remotivelabs/cli/broker/recording_session/client.py +67 -0
- remotivelabs/cli/broker/recording_session/cmd.py +254 -0
- remotivelabs/cli/broker/recording_session/time.py +49 -0
- remotivelabs/cli/broker/scripting.py +129 -0
- remotivelabs/cli/broker/signals.py +220 -0
- remotivelabs/cli/broker/version.py +31 -0
- remotivelabs/cli/cloud/__init__.py +17 -0
- remotivelabs/cli/cloud/auth/__init__.py +3 -0
- remotivelabs/cli/cloud/auth/cmd.py +128 -0
- remotivelabs/cli/cloud/auth/login.py +283 -0
- remotivelabs/cli/cloud/auth_tokens.py +149 -0
- remotivelabs/cli/cloud/brokers.py +109 -0
- remotivelabs/cli/cloud/configs.py +109 -0
- remotivelabs/cli/cloud/licenses/__init__.py +0 -0
- remotivelabs/cli/cloud/licenses/cmd.py +14 -0
- remotivelabs/cli/cloud/organisations.py +112 -0
- remotivelabs/cli/cloud/projects.py +44 -0
- remotivelabs/cli/cloud/recordings.py +580 -0
- remotivelabs/cli/cloud/recordings_playback.py +274 -0
- remotivelabs/cli/cloud/resumable_upload.py +87 -0
- remotivelabs/cli/cloud/sample_recordings.py +25 -0
- remotivelabs/cli/cloud/service_account_tokens.py +62 -0
- remotivelabs/cli/cloud/service_accounts.py +72 -0
- remotivelabs/cli/cloud/storage/__init__.py +5 -0
- remotivelabs/cli/cloud/storage/cmd.py +76 -0
- remotivelabs/cli/cloud/storage/copy.py +86 -0
- remotivelabs/cli/cloud/storage/uri_or_path.py +45 -0
- remotivelabs/cli/cloud/uri.py +113 -0
- remotivelabs/cli/connect/__init__.py +0 -0
- remotivelabs/cli/connect/connect.py +118 -0
- remotivelabs/cli/connect/protopie/protopie.py +185 -0
- remotivelabs/cli/py.typed +0 -0
- remotivelabs/cli/remotive.py +123 -0
- remotivelabs/cli/settings/__init__.py +20 -0
- remotivelabs/cli/settings/config_file.py +113 -0
- remotivelabs/cli/settings/core.py +333 -0
- remotivelabs/cli/settings/migration/__init__.py +0 -0
- remotivelabs/cli/settings/migration/migrate_all_token_files.py +80 -0
- remotivelabs/cli/settings/migration/migrate_config_file.py +64 -0
- remotivelabs/cli/settings/migration/migrate_legacy_dirs.py +50 -0
- remotivelabs/cli/settings/migration/migrate_token_file.py +52 -0
- remotivelabs/cli/settings/migration/migration_tools.py +38 -0
- remotivelabs/cli/settings/state_file.py +67 -0
- remotivelabs/cli/settings/token_file.py +128 -0
- remotivelabs/cli/tools/__init__.py +0 -0
- remotivelabs/cli/tools/can/__init__.py +0 -0
- remotivelabs/cli/tools/can/can.py +78 -0
- remotivelabs/cli/tools/tools.py +9 -0
- remotivelabs/cli/topology/__init__.py +28 -0
- remotivelabs/cli/topology/all.py +322 -0
- remotivelabs/cli/topology/cli/__init__.py +3 -0
- remotivelabs/cli/topology/cli/run_in_docker.py +58 -0
- remotivelabs/cli/topology/cli/topology_cli.py +16 -0
- remotivelabs/cli/topology/cmd.py +130 -0
- remotivelabs/cli/topology/start_trial.py +134 -0
- remotivelabs/cli/typer/__init__.py +0 -0
- remotivelabs/cli/typer/typer_utils.py +27 -0
- remotivelabs/cli/utils/__init__.py +0 -0
- remotivelabs/cli/utils/console.py +99 -0
- remotivelabs/cli/utils/rest_helper.py +369 -0
- remotivelabs/cli/utils/time.py +11 -0
- remotivelabs/cli/utils/versions.py +120 -0
- remotivelabs_cli-0.5.0a1.dist-info/METADATA +51 -0
- remotivelabs_cli-0.5.0a1.dist-info/RECORD +84 -0
- remotivelabs_cli-0.5.0a1.dist-info/WHEEL +4 -0
- remotivelabs_cli-0.5.0a1.dist-info/entry_points.txt +3 -0
- 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)
|