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,274 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, List, Union
|
|
10
|
+
|
|
11
|
+
import grpc
|
|
12
|
+
import typer
|
|
13
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
14
|
+
|
|
15
|
+
from remotivelabs.cli.broker.lib.broker import Broker, SubscribableSignal
|
|
16
|
+
from remotivelabs.cli.typer import typer_utils
|
|
17
|
+
from remotivelabs.cli.utils.console import print_generic_error, print_generic_message, print_grpc_error, print_hint, print_unformatted
|
|
18
|
+
from remotivelabs.cli.utils.rest_helper import RestHelper as Rest
|
|
19
|
+
|
|
20
|
+
app = typer_utils.create_typer(
|
|
21
|
+
help="""
|
|
22
|
+
Support for playback of a recording on a cloud broker, make sure to always mount a recording first
|
|
23
|
+
"""
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command()
|
|
28
|
+
def play(
|
|
29
|
+
recording_session: str = typer.Argument(..., help="Recording session id", envvar="REMOTIVE_CLOUD_RECORDING_SESSION"),
|
|
30
|
+
broker: str = typer.Option(None, help="Broker to use"),
|
|
31
|
+
project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
|
|
32
|
+
show_progress: bool = typer.Option(False, help="Show progress after started playing"),
|
|
33
|
+
repeat: bool = typer.Option(False, help="Repeat recording - must keep command running in terminal"),
|
|
34
|
+
) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Start playing a recording.
|
|
37
|
+
There is no problem invoking play multiple times since if it is already playing the command will be ignored.
|
|
38
|
+
Use --repeat to have the recording replayed when it reaches the end.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
_do_change_playback_mode("play", recording_session, broker, project, progress_on_play=show_progress, repeat=repeat)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command()
|
|
45
|
+
def pause(
|
|
46
|
+
recording_session: str = typer.Argument(..., help="Recording session id", envvar="REMOTIVE_CLOUD_RECORDING_SESSION"),
|
|
47
|
+
broker: str = typer.Option(None, help="Broker to use"),
|
|
48
|
+
project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Pause a recording
|
|
52
|
+
"""
|
|
53
|
+
_do_change_playback_mode("pause", recording_session, broker, project)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command()
|
|
57
|
+
def progress(
|
|
58
|
+
recording_session: str = typer.Argument(..., help="Recording session id", envvar="REMOTIVE_CLOUD_RECORDING_SESSION"),
|
|
59
|
+
broker: str = typer.Option(None, help="Broker to use"),
|
|
60
|
+
project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
|
|
61
|
+
) -> None:
|
|
62
|
+
"""
|
|
63
|
+
Shows progress of the recording playing.
|
|
64
|
+
Use --repeat to have the recording replayed when it reaches the end.
|
|
65
|
+
"""
|
|
66
|
+
_do_change_playback_mode("status", recording_session, broker, project)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def seek(
|
|
71
|
+
recording_session: str = typer.Argument(..., help="Recording session id", envvar="REMOTIVE_CLOUD_RECORDING_SESSION"),
|
|
72
|
+
seconds: int = typer.Option(..., min=0, help="Target offset in seconds"),
|
|
73
|
+
broker: str = typer.Option(None, help="Broker to use"),
|
|
74
|
+
project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Seek seconds into a recording
|
|
78
|
+
"""
|
|
79
|
+
_do_change_playback_mode("seek", recording_session, broker, project, seconds)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def stop(
|
|
84
|
+
recording_session: str = typer.Argument(..., help="Recording session id", envvar="REMOTIVE_CLOUD_RECORDING_SESSION"),
|
|
85
|
+
broker: str = typer.Option(None, help="Broker to use"),
|
|
86
|
+
project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
|
|
87
|
+
) -> None:
|
|
88
|
+
"""
|
|
89
|
+
Stop playing
|
|
90
|
+
"""
|
|
91
|
+
_do_change_playback_mode("stop", recording_session, broker, project)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Copied from signals.py
|
|
95
|
+
def read_scripted_code_file(file_path: Path) -> bytes:
|
|
96
|
+
# typer checks that the Path exists
|
|
97
|
+
with open(file_path, "rb") as file:
|
|
98
|
+
return file.read()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.command()
|
|
102
|
+
def subscribe( # noqa: PLR0913
|
|
103
|
+
recording_session: str = typer.Argument(..., help="Recording session id", envvar="REMOTIVE_CLOUD_RECORDING_SESSION"),
|
|
104
|
+
broker: str = typer.Option(None, help="Broker to use"),
|
|
105
|
+
signal: List[str] = typer.Option(None, help="Signal names to subscribe to, mandatory when not using script"),
|
|
106
|
+
script: Path = typer.Option(
|
|
107
|
+
None,
|
|
108
|
+
exists=True,
|
|
109
|
+
file_okay=True,
|
|
110
|
+
dir_okay=False,
|
|
111
|
+
writable=False,
|
|
112
|
+
readable=True,
|
|
113
|
+
resolve_path=True,
|
|
114
|
+
help="Supply a path to Lua script that to use for signal transformation",
|
|
115
|
+
),
|
|
116
|
+
on_change_only: bool = typer.Option(default=False, help="Only get signal if value is changed"),
|
|
117
|
+
project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
|
|
118
|
+
) -> None:
|
|
119
|
+
"""
|
|
120
|
+
Allows you to subscribe to signals based on a mounted recording without knowing the broker URL.
|
|
121
|
+
This simplifies when playing recordings from the cloud.
|
|
122
|
+
|
|
123
|
+
Terminal plotting is not yet supported here so we refer to remotive broker signals subscribe --x-plot for this.
|
|
124
|
+
"""
|
|
125
|
+
if script is None:
|
|
126
|
+
if len(signal) == 0:
|
|
127
|
+
print_generic_error("You must use include at least one signal and one namespace or use script when subscribing")
|
|
128
|
+
sys.exit(1)
|
|
129
|
+
if script is not None:
|
|
130
|
+
if len(signal) > 0:
|
|
131
|
+
print_generic_error("You must must not specify --signal when using --script")
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
broker_client = _get_broker_info(project, recording_session, broker, "subscribe")
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
if script is not None:
|
|
138
|
+
script_src = read_scripted_code_file(script)
|
|
139
|
+
broker_client.subscribe_on_script(script_src, lambda sig: print_generic_message(json.dumps(list(sig))), on_change_only)
|
|
140
|
+
else:
|
|
141
|
+
|
|
142
|
+
def to_subscribable_signal(sig: str):
|
|
143
|
+
arr = sig.split(":")
|
|
144
|
+
if len(arr) != 2:
|
|
145
|
+
print_hint(f"--signal must have format namespace:signal ({sig})")
|
|
146
|
+
sys.exit(1)
|
|
147
|
+
return SubscribableSignal(namespace=arr[0], name=arr[1])
|
|
148
|
+
|
|
149
|
+
signals_to_subscribe_to = list(map(to_subscribable_signal, signal))
|
|
150
|
+
broker_client.long_name_subscribe(
|
|
151
|
+
signals_to_subscribe_to, lambda sig: print_generic_message(json.dumps(list(sig))), on_change_only
|
|
152
|
+
)
|
|
153
|
+
print_generic_message("Subscribing to signals, press Ctrl+C to exit")
|
|
154
|
+
except grpc.RpcError as rpc_error:
|
|
155
|
+
print_grpc_error(rpc_error)
|
|
156
|
+
|
|
157
|
+
except Exception as e:
|
|
158
|
+
print_generic_error(str(e))
|
|
159
|
+
sys.exit(1)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _do_change_playback_mode( # noqa: C901, PLR0913, PLR0912
|
|
163
|
+
mode: str,
|
|
164
|
+
recording_session: str,
|
|
165
|
+
brokerstr: str,
|
|
166
|
+
project: str,
|
|
167
|
+
seconds: int | None = None,
|
|
168
|
+
progress_on_play: bool = False,
|
|
169
|
+
repeat: bool = False,
|
|
170
|
+
) -> None:
|
|
171
|
+
response = Rest.handle_get(f"/api/project/{project}/files/recording/{recording_session}", return_response=True)
|
|
172
|
+
if response is None:
|
|
173
|
+
return
|
|
174
|
+
r = json.loads(response.text)
|
|
175
|
+
enabled_recordings: List[Any] = [r for r in r["recordings"] if r["enabled"]]
|
|
176
|
+
files = [{"recording": rec["fileName"], "namespace": rec["metadata"]["namespace"]} for rec in enabled_recordings]
|
|
177
|
+
|
|
178
|
+
broker_name = brokerstr if brokerstr is not None else "personal"
|
|
179
|
+
response = Rest.handle_get(f"/api/project/{project}/brokers/{broker_name}", return_response=True, allow_status_codes=[404])
|
|
180
|
+
if response is None:
|
|
181
|
+
return
|
|
182
|
+
if response.status_code == 404:
|
|
183
|
+
broker_arg = ""
|
|
184
|
+
if brokerstr is not None:
|
|
185
|
+
broker_arg = f" --broker {brokerstr} --ensure-broker-started"
|
|
186
|
+
print_generic_error("You need to mount the recording before you play")
|
|
187
|
+
print_hint(f"remotive cloud recordings mount {recording_session}{broker_arg} --project {project}")
|
|
188
|
+
sys.exit(1)
|
|
189
|
+
|
|
190
|
+
broker_info = json.loads(response.text)
|
|
191
|
+
broker = Broker(broker_info["url"], None)
|
|
192
|
+
|
|
193
|
+
_verify_recording_on_broker(broker, recording_session, mode, project)
|
|
194
|
+
|
|
195
|
+
if mode == "pause":
|
|
196
|
+
broker.pause_play(files, True)
|
|
197
|
+
elif mode == "play":
|
|
198
|
+
broker.play(files, True)
|
|
199
|
+
if progress_on_play or repeat:
|
|
200
|
+
_track_progress(broker, repeat, files)
|
|
201
|
+
elif mode == "seek":
|
|
202
|
+
if seconds is not None:
|
|
203
|
+
broker.seek(files, int(seconds * 1000000), True)
|
|
204
|
+
else:
|
|
205
|
+
broker.seek(files, 0, True)
|
|
206
|
+
elif mode == "stop":
|
|
207
|
+
broker.seek(files, 0, True)
|
|
208
|
+
elif mode == "status":
|
|
209
|
+
_track_progress(broker, repeat, files)
|
|
210
|
+
else:
|
|
211
|
+
raise ValueError(f"Illegal command {mode}")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _track_progress(broker: Broker, repeat: bool, files: List[Any]) -> None:
|
|
215
|
+
p = Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), transient=True)
|
|
216
|
+
t = p.add_task("label", total=1)
|
|
217
|
+
if repeat:
|
|
218
|
+
print_unformatted(":point_right: Keep this command running in terminal to keep the recording play with repeat")
|
|
219
|
+
with p:
|
|
220
|
+
|
|
221
|
+
def print_progress(offset: int, total: int, current_mode: str) -> None:
|
|
222
|
+
p.update(
|
|
223
|
+
t,
|
|
224
|
+
description=f"{(datetime.timedelta(seconds=offset))} / {(datetime.timedelta(seconds=total))} ({current_mode})",
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
broker.listen_on_playback(repeat, files, print_progress)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _verify_recording_on_broker(broker: Broker, recording_session: str, mode: str, project: str) -> None:
|
|
231
|
+
try:
|
|
232
|
+
# Here we try to verify that we are operating on a recording that is mounted on the
|
|
233
|
+
# broker so we can verify this before we try playback and can also present some good
|
|
234
|
+
# error messages
|
|
235
|
+
tmp = os.path.join(tempfile.gettempdir(), os.urandom(24).hex())
|
|
236
|
+
broker.download(".cloud.context", tmp, True)
|
|
237
|
+
with open(tmp, "r", encoding="utf8") as f:
|
|
238
|
+
json_context = json.loads(f.read())
|
|
239
|
+
if json_context["recordingSessionId"] != recording_session:
|
|
240
|
+
print_generic_error(
|
|
241
|
+
f"The recording id mounted is '{json_context['recordingSessionId']}' "
|
|
242
|
+
f"which not the same as you are trying to {mode}, use cmd below to mount this recording"
|
|
243
|
+
)
|
|
244
|
+
print_hint(f"remotive cloud recordings mount {recording_session} --project {project}")
|
|
245
|
+
sys.exit(1)
|
|
246
|
+
except grpc.RpcError as rpc_error:
|
|
247
|
+
if rpc_error.code() == grpc.StatusCode.NOT_FOUND:
|
|
248
|
+
print_generic_error(f"You must use mount to prepare a recording before you can use {mode}")
|
|
249
|
+
print_hint(f"remotive cloud recordings mount {recording_session} --project {project}")
|
|
250
|
+
else:
|
|
251
|
+
print_grpc_error(rpc_error)
|
|
252
|
+
sys.exit(1)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _get_broker_info(project: str, recording_session: str, broker: Union[str, None], mode: str) -> Broker:
|
|
256
|
+
# Verify it exists
|
|
257
|
+
Rest.handle_get(f"/api/project/{project}/files/recording/{recording_session}", return_response=True)
|
|
258
|
+
|
|
259
|
+
broker_name = broker if broker is not None else "personal"
|
|
260
|
+
response = Rest.handle_get(f"/api/project/{project}/brokers/{broker_name}", return_response=True, allow_status_codes=[404])
|
|
261
|
+
if response is None:
|
|
262
|
+
print_generic_error(f"No response from: /api/project/{project}/brokers/{broker_name}")
|
|
263
|
+
sys.exit(1)
|
|
264
|
+
if response.status_code == 404:
|
|
265
|
+
broker_arg = ""
|
|
266
|
+
if broker is not None:
|
|
267
|
+
broker_arg = f"--broker {broker} --ensure-broker-started"
|
|
268
|
+
print_generic_error("You need to mount the recording before you play")
|
|
269
|
+
print_hint(f"remotive cloud recordings mount {recording_session} {broker_arg} --project {project}")
|
|
270
|
+
sys.exit(1)
|
|
271
|
+
broker_info = json.loads(response.text)
|
|
272
|
+
broker_client = Broker(broker_info["url"], None)
|
|
273
|
+
_verify_recording_on_broker(broker_client, recording_session, mode, project)
|
|
274
|
+
return broker_client
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
from rich.progress import wrap_file
|
|
10
|
+
|
|
11
|
+
from remotivelabs.cli.utils.console import print_generic_error, print_success
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def __get_uploaded_bytes(upload_url: str) -> int:
|
|
15
|
+
headers = {"Content-Range": "bytes */*"}
|
|
16
|
+
response = requests.put(upload_url, headers=headers, timeout=60)
|
|
17
|
+
if response.status_code != 308:
|
|
18
|
+
raise ValueError(f"Failed to retrieve upload status: {response.status_code} {response.text}")
|
|
19
|
+
|
|
20
|
+
# Parse the Range header to get the last byte uploaded
|
|
21
|
+
range_header = response.headers.get("Range")
|
|
22
|
+
if range_header:
|
|
23
|
+
last_byte = int(range_header.split("-")[1])
|
|
24
|
+
return last_byte + 1
|
|
25
|
+
return 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def with_resumable_upload_signed_url(signed_url: str, source_file_name: str, content_type: str) -> None:
|
|
29
|
+
"""
|
|
30
|
+
Upload file to file storage with signed url and resumable upload.
|
|
31
|
+
Resumable upload will only work with the same URL and not if a new signed URL is requested with the
|
|
32
|
+
same object id.
|
|
33
|
+
:param content_type:
|
|
34
|
+
:param signed_url:
|
|
35
|
+
:param source_file_name:
|
|
36
|
+
:return:
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
file_size = os.path.getsize(source_file_name)
|
|
40
|
+
headers = {"x-goog-resumable": "start", "content-type": content_type}
|
|
41
|
+
response = requests.post(signed_url, headers=headers, timeout=60)
|
|
42
|
+
if response.status_code not in (200, 201, 308):
|
|
43
|
+
print_generic_error(f"Failed to upload file: {response.status_code} - {response.text}")
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
|
|
46
|
+
upload_url = response.headers["Location"]
|
|
47
|
+
|
|
48
|
+
# Check how many bytes have already been uploaded
|
|
49
|
+
uploaded_bytes = __get_uploaded_bytes(upload_url)
|
|
50
|
+
|
|
51
|
+
# Upload the remaining file in chunks
|
|
52
|
+
# Not sure what a good chunk size is or if we even should have resumable uploads here, probably not..
|
|
53
|
+
chunk_size = 256 * 1024 * 10
|
|
54
|
+
# Upload the file in chunks
|
|
55
|
+
with open(source_file_name, "rb") as f:
|
|
56
|
+
with wrap_file(f, os.stat(source_file_name).st_size, description=f"Uploading {source_file_name}...") as file:
|
|
57
|
+
file.seek(uploaded_bytes) # Seek to the position of the last uploaded byte
|
|
58
|
+
for chunk_start in range(uploaded_bytes, file_size, chunk_size):
|
|
59
|
+
chunk_end = min(chunk_start + chunk_size, file_size) - 1
|
|
60
|
+
chunk = file.read(chunk_end - chunk_start + 1)
|
|
61
|
+
headers = {"Content-Range": f"bytes {chunk_start}-{chunk_end}/{file_size}"}
|
|
62
|
+
response = requests.put(upload_url, headers=headers, data=chunk, timeout=60)
|
|
63
|
+
if response.status_code not in (200, 201, 308):
|
|
64
|
+
print_generic_error(f"Failed to upload file: {response.status_code} - {response.text}")
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
|
|
67
|
+
print_success(f"File {source_file_name} uploaded successfully.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def upload_signed_url(signed_url: str, source_file_name: Path, headers: Dict[str, str]) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Upload file to file storage with signed url and resumable upload.
|
|
73
|
+
Resumable upload will only work with the same URL and not if a new signed URL is requested with the
|
|
74
|
+
same object id.
|
|
75
|
+
:param headers:
|
|
76
|
+
:param signed_url:
|
|
77
|
+
:param source_file_name:
|
|
78
|
+
:return:
|
|
79
|
+
"""
|
|
80
|
+
with open(source_file_name, "rb") as file:
|
|
81
|
+
with wrap_file(file, os.stat(source_file_name).st_size, description=f"Uploading {source_file_name}...") as f:
|
|
82
|
+
response = requests.put(signed_url, headers=headers, timeout=60, data=f)
|
|
83
|
+
if response.status_code not in (200, 201, 308):
|
|
84
|
+
print_generic_error(f"Failed to upload file: {response.status_code} - {response.text}")
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
|
|
87
|
+
print_success(f"File {source_file_name} uploaded successfully.")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from remotivelabs.cli.typer import typer_utils
|
|
6
|
+
from remotivelabs.cli.utils.rest_helper import RestHelper as Rest
|
|
7
|
+
|
|
8
|
+
app = typer_utils.create_typer()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@app.command(name="import", help="Import sample recording into project")
|
|
12
|
+
def do_import(
|
|
13
|
+
recording_session: str = typer.Argument(..., help="Recording session id"),
|
|
14
|
+
project: str = typer.Option(..., help="Project to import sample recording into", envvar="REMOTIVE_CLOUD_PROJECT"),
|
|
15
|
+
) -> None:
|
|
16
|
+
Rest.handle_post(url=f"/api/samples/files/recording/{recording_session}/copy", body=json.dumps({"projectUid": project}))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command("list")
|
|
20
|
+
def list() -> None:
|
|
21
|
+
"""
|
|
22
|
+
List available sample recordings
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
Rest.handle_get("/api/samples/files/recording")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from remotivelabs.cli.settings import settings
|
|
6
|
+
from remotivelabs.cli.typer import typer_utils
|
|
7
|
+
from remotivelabs.cli.utils.console import print_generic_message, print_success
|
|
8
|
+
from remotivelabs.cli.utils.rest_helper import RestHelper as Rest
|
|
9
|
+
|
|
10
|
+
app = typer_utils.create_typer()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# TODO: add add interactive flag to set target directory
|
|
14
|
+
@app.command(name="create", help="Create and download a new service account access token")
|
|
15
|
+
def create(
|
|
16
|
+
expire_in_days: int = typer.Option(default=365, help="Number of this token is valid"),
|
|
17
|
+
service_account: str = typer.Option(..., help="Service account name"),
|
|
18
|
+
project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
|
|
19
|
+
) -> None:
|
|
20
|
+
response = Rest.handle_post(
|
|
21
|
+
url=f"/api/project/{project}/admin/accounts/{service_account}/keys",
|
|
22
|
+
return_response=True,
|
|
23
|
+
body=json.dumps({"daysUntilExpiry": expire_in_days}),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
sat = settings.add_service_account_token(response.text)
|
|
27
|
+
print_success(f"Service account access token added: {sat.name}")
|
|
28
|
+
print_generic_message("This file contains secrets and must be kept safe")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command(name="list", help="List service account access tokens")
|
|
32
|
+
def list_tokens(
|
|
33
|
+
service_account: str = typer.Option(..., help="Service account name"),
|
|
34
|
+
project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
|
|
35
|
+
) -> None:
|
|
36
|
+
Rest.handle_get(f"/api/project/{project}/admin/accounts/{service_account}/keys")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command(name="describe", help="Describe service account access token")
|
|
40
|
+
def describe(
|
|
41
|
+
name: str = typer.Argument(..., help="Access token name"),
|
|
42
|
+
service_account: str = typer.Option(..., help="Service account name"),
|
|
43
|
+
project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
|
|
44
|
+
) -> None:
|
|
45
|
+
Rest.handle_get(f"/api/project/{project}/admin/accounts/{service_account}/keys/{name}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command(name="revoke", help="Revoke service account access token")
|
|
49
|
+
def revoke(
|
|
50
|
+
name: str = typer.Argument(..., help="Access token name"),
|
|
51
|
+
delete: bool = typer.Option(True, help="Also deletes the token after revocation"),
|
|
52
|
+
service_account: str = typer.Option(..., help="Service account name"),
|
|
53
|
+
project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
|
|
54
|
+
) -> None:
|
|
55
|
+
res = Rest.handle_get(f"/api/project/{project}/admin/accounts/{service_account}/keys/{name}", return_response=True)
|
|
56
|
+
if not res.json()["revoked"]:
|
|
57
|
+
Rest.handle_patch(f"/api/project/{project}/admin/accounts/{service_account}/keys/{name}/revoke", quiet=True)
|
|
58
|
+
if delete:
|
|
59
|
+
Rest.handle_delete(f"/api/project/{project}/admin/accounts/{service_account}/keys/{name}", quiet=True)
|
|
60
|
+
token_with_name = [token for token in settings.list_service_account_token_files() if token.name == name]
|
|
61
|
+
if len(token_with_name) > 0:
|
|
62
|
+
settings.remove_token_file(name)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from remotivelabs.cli.cloud import service_account_tokens
|
|
9
|
+
from remotivelabs.cli.typer import typer_utils
|
|
10
|
+
from remotivelabs.cli.utils.rest_helper import RestHelper as Rest
|
|
11
|
+
|
|
12
|
+
app = typer_utils.create_typer()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command(name="list", help="List service-accounts")
|
|
16
|
+
def list_service_accounts(project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT")) -> None:
|
|
17
|
+
Rest.handle_get(f"/api/project/{project}/admin/accounts")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
ROLES_DESCRIPTION = """
|
|
21
|
+
[bold]Supported roles [/bold]
|
|
22
|
+
project/admin - Full project support, view, edit, delete and manage users
|
|
23
|
+
project/user - View, edit, upload, delete but no admin
|
|
24
|
+
project/viewer - View only
|
|
25
|
+
project/storageCreator - Can upload to storage but not view, overwrite or delete
|
|
26
|
+
org/topologyRunner - Can start RemotiveTopology
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command(
|
|
31
|
+
name="create",
|
|
32
|
+
help=f"""
|
|
33
|
+
Create a new service account with one or more roles.
|
|
34
|
+
|
|
35
|
+
[bold]Must only use lowercase letters, digits, or dashes, and must not contain -- or end with a dash.[/bold]
|
|
36
|
+
|
|
37
|
+
remotive cloud service-accounts create --role project/user --role project/storageCreator
|
|
38
|
+
{ROLES_DESCRIPTION}
|
|
39
|
+
""",
|
|
40
|
+
)
|
|
41
|
+
def create_service_account(
|
|
42
|
+
name: str,
|
|
43
|
+
role: List[str] = typer.Option(..., help="Roles to apply"),
|
|
44
|
+
project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
|
|
45
|
+
) -> None:
|
|
46
|
+
data = {"name": name, "roles": role}
|
|
47
|
+
Rest.handle_post(url=f"/api/project/{project}/admin/accounts", body=json.dumps(data))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command(
|
|
51
|
+
name="update",
|
|
52
|
+
help=f"""
|
|
53
|
+
Update an existing service account with one or more roles.
|
|
54
|
+
|
|
55
|
+
remotive cloud service-accounts update --role project/user --role project/storageCreator
|
|
56
|
+
{ROLES_DESCRIPTION}
|
|
57
|
+
""",
|
|
58
|
+
)
|
|
59
|
+
def update_service_account(
|
|
60
|
+
service_account: str = typer.Argument(..., help="Service account name"),
|
|
61
|
+
role: List[str] = typer.Option(..., help="Roles to apply"),
|
|
62
|
+
project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
|
|
63
|
+
) -> None:
|
|
64
|
+
Rest.handle_put(url=f"/api/project/{project}/admin/accounts/{service_account}", body=json.dumps({"roles": role}))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command(name="delete", help="Delete service account")
|
|
68
|
+
def delete_service_account(name: str, project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT")) -> None:
|
|
69
|
+
Rest.handle_delete(url=f"/api/project/{project}/admin/accounts/{name}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
app.add_typer(service_account_tokens.app, name="tokens", help="Manage project service account access tokens")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from typing_extensions import Annotated
|
|
5
|
+
|
|
6
|
+
from remotivelabs.cli.cloud.storage.copy import copy
|
|
7
|
+
from remotivelabs.cli.cloud.storage.uri_or_path import UriOrPath
|
|
8
|
+
from remotivelabs.cli.cloud.storage.uri_or_path import uri as uri_parser
|
|
9
|
+
from remotivelabs.cli.cloud.uri import URI, InvalidURIError, JoinURIError
|
|
10
|
+
from remotivelabs.cli.typer import typer_utils
|
|
11
|
+
from remotivelabs.cli.utils.console import print_hint
|
|
12
|
+
from remotivelabs.cli.utils.rest_helper import RestHelper as Rest
|
|
13
|
+
|
|
14
|
+
HELP = """
|
|
15
|
+
Manage files ([yellow]Beta feature not available for all customers[/yellow])
|
|
16
|
+
|
|
17
|
+
Copy file from local to remote storage and vice versa, list and delete files.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
app = typer_utils.create_typer(rich_markup_mode="rich", help=HELP)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command(name="ls")
|
|
24
|
+
def list_files(
|
|
25
|
+
project: Annotated[str, typer.Option(help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT", show_default=False)],
|
|
26
|
+
uri: Annotated[URI, typer.Argument(help="Remote storage path", parser=URI)] = "rcs://", # type: ignore
|
|
27
|
+
) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Listing remote files
|
|
30
|
+
|
|
31
|
+
This will list files and directories in project top level directory
|
|
32
|
+
remotive cloud storage ls rcs://
|
|
33
|
+
|
|
34
|
+
This will list all files and directories matching the path
|
|
35
|
+
remotive cloud storage ls rcs://fileOrDirectoryPrefix
|
|
36
|
+
|
|
37
|
+
This will list all files and directories in the specified directory
|
|
38
|
+
remotive cloud storage ls rcs://fileOrDirectory/
|
|
39
|
+
"""
|
|
40
|
+
Rest.handle_get(f"/api/project/{project}/files/storage{uri.path}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command(name="rm")
|
|
44
|
+
def delete_file(
|
|
45
|
+
project: Annotated[str, typer.Option(help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT", show_default=False)],
|
|
46
|
+
uri: Annotated[URI, typer.Argument(help="Remote storage path", parser=URI, show_default=False)],
|
|
47
|
+
) -> None:
|
|
48
|
+
"""
|
|
49
|
+
[red]Deletes[/red] a file from remote storage, this cannot be undone :fire:
|
|
50
|
+
|
|
51
|
+
[white]remotive cloud storage rm rcs://directory/filename[/white]
|
|
52
|
+
"""
|
|
53
|
+
Rest.handle_delete(f"/api/project/{project}/files/storage{uri.path}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command(name="cp")
|
|
57
|
+
def copy_file(
|
|
58
|
+
project: Annotated[str, typer.Option(help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT", show_default=False)],
|
|
59
|
+
source: Annotated[UriOrPath, typer.Argument(help="Remote or local path to source file", parser=uri_parser, show_default=False)],
|
|
60
|
+
dest: Annotated[UriOrPath, typer.Argument(help="Remote or local path to destination file", parser=uri_parser, show_default=False)],
|
|
61
|
+
overwrite: Annotated[bool, typer.Option(help="Overwrite existing file on RCS")] = False,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Copies a file to or from remote storage
|
|
65
|
+
|
|
66
|
+
remotive cloud storage cp rcs://dir/filename .
|
|
67
|
+
remotive cloud storage cp rcs://dir/filename filename
|
|
68
|
+
|
|
69
|
+
remotive cloud storage cp filename rcs://dir/
|
|
70
|
+
remotive cloud storage cp filename rcs://dir/filename
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
return copy(project=project, source=source.value, dest=dest.value, overwrite=overwrite)
|
|
74
|
+
except (InvalidURIError, JoinURIError, ValueError, FileNotFoundError, FileExistsError) as e:
|
|
75
|
+
print_hint(str(e))
|
|
76
|
+
sys.exit(1)
|