devstack-cli 9.0.1__py3-none-any.whl → 10.0.0__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.
- cli.py +1376 -398
- {devstack_cli-9.0.1.dist-info → devstack_cli-10.0.0.dist-info}/METADATA +1 -1
- devstack_cli-10.0.0.dist-info/RECORD +9 -0
- {devstack_cli-9.0.1.dist-info → devstack_cli-10.0.0.dist-info}/WHEEL +1 -1
- version.py +5 -5
- devstack_cli-9.0.1.dist-info/RECORD +0 -9
- {devstack_cli-9.0.1.dist-info → devstack_cli-10.0.0.dist-info}/LICENSE +0 -0
- {devstack_cli-9.0.1.dist-info → devstack_cli-10.0.0.dist-info}/entry_points.txt +0 -0
- {devstack_cli-9.0.1.dist-info → devstack_cli-10.0.0.dist-info}/top_level.txt +0 -0
cli.py
CHANGED
@@ -1,42 +1,69 @@
|
|
1
1
|
import argparse
|
2
2
|
import asyncio
|
3
|
+
import configparser
|
3
4
|
import contextlib
|
5
|
+
import datetime
|
4
6
|
import functools
|
5
7
|
import io
|
8
|
+
import itertools
|
9
|
+
import json
|
6
10
|
import logging
|
11
|
+
import os
|
7
12
|
import pathlib
|
13
|
+
import readline
|
8
14
|
import shlex
|
15
|
+
import shutil
|
16
|
+
import signal
|
9
17
|
import stat
|
10
18
|
import string
|
11
19
|
import sys
|
12
|
-
import tempfile
|
13
20
|
import termios
|
14
|
-
import time
|
15
21
|
import tty
|
16
22
|
import typing
|
17
23
|
|
24
|
+
import aiofiles
|
25
|
+
import aiohttp
|
26
|
+
import asyncssh
|
18
27
|
import paramiko
|
19
28
|
import paramiko.sftp_client
|
20
29
|
import rich.console
|
30
|
+
import rich.highlighter
|
31
|
+
import rich.json
|
21
32
|
import rich.logging
|
22
33
|
import rich.markup
|
34
|
+
import rich.pretty
|
23
35
|
import rich.progress
|
24
|
-
import version
|
25
36
|
import watchdog.events
|
26
37
|
import watchdog.observers
|
38
|
+
import yarl
|
39
|
+
|
40
|
+
import version
|
41
|
+
|
42
|
+
|
43
|
+
def sigint_handler(signum: int, frame, *, cli: 'Cli') -> None:
|
44
|
+
if cli.terminal_process is None:
|
45
|
+
return
|
46
|
+
#cli.terminal_process.send_signal(signal.SIGINT)
|
47
|
+
cli.terminal_process.stdin.write('\x03')
|
48
|
+
|
27
49
|
|
28
|
-
REMOTE_USERNAME = 'devstack-user'
|
29
|
-
REMOTE_SOURCE_DIRECTORY = '/home/devstack-user/starflows'
|
30
|
-
REMOTE_OUTPUT_DIRECTORY = '/home/devstack-user/starflows-output'
|
31
50
|
EVENT_DEBOUNCE_SECONDS = .5
|
51
|
+
RETRY_DELAY_SECONDS = 30
|
32
52
|
|
33
|
-
logging.basicConfig(level=logging.INFO, handlers=[
|
53
|
+
logging.basicConfig(level=logging.INFO, handlers=[], format='%(message)s')
|
34
54
|
logger = logging.getLogger('cli')
|
55
|
+
logger.addHandler(rich.logging.RichHandler())
|
56
|
+
json_logger = logging.getLogger('cli-json')
|
57
|
+
json_logger.addHandler(rich.logging.RichHandler(highlighter=rich.highlighter.JSONHighlighter()))
|
35
58
|
|
36
59
|
class SubprocessError(Exception):
|
37
60
|
"""A subprocess call returned with non-zero."""
|
38
61
|
|
39
62
|
|
63
|
+
class InitializationError(Exception):
|
64
|
+
"""Initialization of devstack-cli failed"""
|
65
|
+
|
66
|
+
|
40
67
|
class FileSystemEventHandlerToQueue(watchdog.events.FileSystemEventHandler):
|
41
68
|
def __init__(
|
42
69
|
self: 'FileSystemEventHandlerToQueue',
|
@@ -78,6 +105,7 @@ async def run_subprocess(
|
|
78
105
|
print_stdout: bool = True,
|
79
106
|
capture_stderr: bool = True,
|
80
107
|
print_stderr: bool = True,
|
108
|
+
print_to_debug_log: bool = False,
|
81
109
|
) -> None:
|
82
110
|
args_str = ' '.join(args)
|
83
111
|
process = await asyncio.create_subprocess_exec(
|
@@ -110,7 +138,10 @@ async def run_subprocess(
|
|
110
138
|
if capture_stdout and stdout_readline in done:
|
111
139
|
stdout_line = await stdout_readline
|
112
140
|
if print_stdout and stdout_line.decode().strip():
|
113
|
-
|
141
|
+
if print_to_debug_log:
|
142
|
+
logger.debug('%s: %s', name, stdout_line.decode().strip())
|
143
|
+
else:
|
144
|
+
logger.info('%s: %s', name, stdout_line.decode().strip())
|
114
145
|
stdout += stdout_line + b'\n'
|
115
146
|
stdout_readline = asyncio.create_task(process.stdout.readline())
|
116
147
|
pending.add(stdout_readline)
|
@@ -147,157 +178,856 @@ async def run_subprocess(
|
|
147
178
|
|
148
179
|
|
149
180
|
def _get_event_significant_path(event: watchdog.events.FileSystemEvent) -> str:
|
150
|
-
if hasattr(event, 'dest_path'):
|
181
|
+
if hasattr(event, 'dest_path') and event.dest_path != '':
|
151
182
|
return event.dest_path
|
152
183
|
return event.src_path
|
153
184
|
|
154
185
|
|
155
|
-
def
|
156
|
-
return other == self or other in self.parents
|
186
|
+
def _is_relative_to(self: pathlib.Path, other: pathlib.Path) -> bool:
|
187
|
+
return pathlib.Path(other) == pathlib.Path(self) or pathlib.Path(other) in pathlib.Path(self).parents
|
188
|
+
|
189
|
+
|
190
|
+
async def _create_temp_file(
|
191
|
+
*,
|
192
|
+
exit_stack: contextlib.AsyncExitStack,
|
193
|
+
content: typing.Union[bytes, str],
|
194
|
+
) -> aiofiles.tempfile.NamedTemporaryFile:
|
195
|
+
temp_file = await exit_stack.enter_async_context(
|
196
|
+
aiofiles.tempfile.NamedTemporaryFile(
|
197
|
+
'wb+',
|
198
|
+
delete=False,
|
199
|
+
),
|
200
|
+
)
|
201
|
+
await temp_file.write(content.encode() if isinstance(content, str) else content)
|
202
|
+
await temp_file.close()
|
203
|
+
return temp_file
|
157
204
|
|
158
205
|
|
159
206
|
class Cli:
|
160
|
-
def __init__(self: 'Cli'
|
161
|
-
|
162
|
-
self.
|
163
|
-
self.
|
164
|
-
|
165
|
-
self.
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
self.
|
176
|
-
self.
|
177
|
-
self.filesystem_watch_task = None
|
178
|
-
self.known_hosts_file = None
|
207
|
+
def __init__(self: 'Cli') -> None:
|
208
|
+
self.config_file: typing.Optional[pathlib.Path] = None
|
209
|
+
self.args: typing.Optional[argparse.Namespace] = None
|
210
|
+
self.config: typing.Optional[configparser.ConfigParser] = None
|
211
|
+
self.password: typing.Optional[str] = None
|
212
|
+
self.session: typing.Optional[aiohttp.ClientSession] = None
|
213
|
+
self.workspace_url: typing.Optional[yarl.URL] = None
|
214
|
+
self.sync_task: typing.Optional[asyncio.Task] = None
|
215
|
+
self.port_forwarding_task: typing.Optional[asyncio.Task] = None
|
216
|
+
self.logs_task: typing.Optional[asyncio.Task] = None
|
217
|
+
self.exit_stack: typing.Optional[contextlib.AsyncExitStack] = None
|
218
|
+
self.cdes: typing.List[dict] = []
|
219
|
+
self.cde: typing.Optional[dict] = None
|
220
|
+
self.cde_type: typing.Optional[dict] = None
|
221
|
+
self.ssh_client: typing.Optional[paramiko.SSHClient] = None
|
222
|
+
self.sftp_client: typing.Optional[paramiko.sftp_client.SFTPClient] = None
|
223
|
+
self.known_hosts_file: typing.Optional[aiofiles.tempfile.NamedTemporaryFile] = None
|
179
224
|
self.console = rich.console.Console()
|
180
|
-
|
181
|
-
|
225
|
+
self._fd = sys.stdin.fileno()
|
226
|
+
self._tcattr = termios.tcgetattr(self._fd)
|
227
|
+
self.terminal_process = None
|
228
|
+
|
229
|
+
@property
|
230
|
+
def cde_name(self: 'Cli') -> typing.Optional[str]:
|
231
|
+
return self.cde['name'] if self.cde is not None else None
|
232
|
+
|
233
|
+
@property
|
234
|
+
def is_cde_running(self: 'Cli') -> bool:
|
235
|
+
if self.cde is None:
|
236
|
+
return None
|
237
|
+
if not self.cde['exists_remotely']:
|
238
|
+
return False
|
239
|
+
return self.cde['value']['is-running'] and self.cde['provisioning_state'] == 'READY'
|
240
|
+
|
241
|
+
@property
|
242
|
+
def hostname(self: 'Cli') -> typing.Optional[str]:
|
243
|
+
return self.cde['value']['hostname'] if self.cde is not None else None
|
244
|
+
|
245
|
+
@property
|
246
|
+
def local_source_directory(self: 'Cli') -> typing.Optional[pathlib.Path]:
|
247
|
+
return pathlib.Path(os.path.expandvars(self.cde['source_directory'])) if self.cde else None
|
248
|
+
|
249
|
+
@property
|
250
|
+
def local_output_directory(self: 'Cli') -> typing.Optional[pathlib.Path]:
|
251
|
+
return pathlib.Path(os.path.expandvars(self.cde['output_directory'])) if self.cde and self.cde.get('output_directory') else None
|
182
252
|
|
183
253
|
async def run(self: 'Cli') -> None:
|
184
|
-
self.loop = asyncio.get_running_loop()
|
185
|
-
key_queue = asyncio.Queue()
|
186
|
-
await self._prepare_known_hosts()
|
187
254
|
try:
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
255
|
+
self.loop = asyncio.get_running_loop()
|
256
|
+
self.loop.add_signal_handler(
|
257
|
+
signal.SIGWINCH,
|
258
|
+
self._window_resized,
|
259
|
+
)
|
260
|
+
self.key_queue = asyncio.Queue()
|
261
|
+
await self._parse_arguments()
|
262
|
+
# print version after parse_arguments to avoid duplication when using "--version"
|
263
|
+
rich.print(f'Cloudomation devstack-cli {version.MAJOR}+{version.BRANCH_NAME}.{version.BUILD_DATE}.{version.SHORT_SHA}')
|
264
|
+
rich.print('''[bold white on blue]
|
265
|
+
:=+********+=:
|
266
|
+
-+****************+-
|
267
|
+
=**********************=
|
268
|
+
:**************************:
|
269
|
+
-****************************-:=+****+=:
|
270
|
+
.**************=-*************************:
|
271
|
+
=**************. -************************-
|
272
|
+
*************** -********++*************
|
273
|
+
.**************= ::.. *************
|
274
|
+
.=****************: *************:
|
275
|
+
=**************=-. .**************+=:
|
276
|
+
.************+-. .*******************+:
|
277
|
+
**************=: +********************+
|
278
|
+
=*****************+=: +*********************
|
279
|
+
**********************: +********************=
|
280
|
+
**********************= .--::.. *********************
|
281
|
+
=********************** .=*******************************
|
282
|
+
**********************. =********************************=
|
283
|
+
.+********************+=*********************************+
|
284
|
+
-*****************************************************=
|
285
|
+
-+************************************************=.
|
286
|
+
:-=+**************************************+=-.
|
287
|
+
''') # noqa: W291
|
288
|
+
async with self._key_press_to_queue(), \
|
289
|
+
aiohttp.ClientSession(trust_env=True) as self.session, \
|
290
|
+
contextlib.AsyncExitStack() as self.exit_stack:
|
291
|
+
await self._load_global_config()
|
292
|
+
await self._check_config()
|
293
|
+
await self._print_help()
|
294
|
+
await self._process_args()
|
197
295
|
while True:
|
198
|
-
key_press = await key_queue.get()
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
if logs_task is not None and logs_task.done():
|
205
|
-
logs_task = None
|
206
|
-
|
207
|
-
if key_press == 'h':
|
208
|
-
table = rich.table.Table(title='Help')
|
209
|
-
table.add_column('Key', style='cyan')
|
210
|
-
table.add_column('Function')
|
211
|
-
table.add_column('Status')
|
212
|
-
table.add_row('h', 'Print help')
|
213
|
-
table.add_row('v', 'Toggle debug logs', '[green]on' if logger.getEffectiveLevel() == logging.DEBUG else '[red]off')
|
214
|
-
table.add_row('s', 'Toggle file sync', '[red]off' if sync_task is None else '[green]on')
|
215
|
-
table.add_row('p', 'Toggle port forwarding', '[red]off' if port_forwarding_task is None else '[green]on')
|
216
|
-
table.add_row('l', 'Toggle following logs', '[red]off' if logs_task is None else '[green]on')
|
217
|
-
table.add_row('q', 'Quit')
|
218
|
-
rich.print(table)
|
219
|
-
elif key_press == 'v':
|
220
|
-
if logger.getEffectiveLevel() == logging.INFO:
|
221
|
-
logger.info('Enabling debug logs')
|
222
|
-
logger.setLevel(logging.DEBUG)
|
223
|
-
else:
|
224
|
-
logger.info('Disabling debug logs')
|
225
|
-
logger.setLevel(logging.INFO)
|
226
|
-
elif key_press == 's':
|
227
|
-
if sync_task is None:
|
228
|
-
sync_task = asyncio.create_task(self._start_sync())
|
229
|
-
else:
|
230
|
-
sync_task.cancel()
|
231
|
-
try:
|
232
|
-
await sync_task
|
233
|
-
except asyncio.CancelledError:
|
234
|
-
pass
|
235
|
-
except Exception:
|
236
|
-
logger.exception('Error during file sync')
|
237
|
-
sync_task = None
|
238
|
-
elif key_press == 'p':
|
239
|
-
if port_forwarding_task is None:
|
240
|
-
port_forwarding_task = asyncio.create_task(self._start_port_forwarding())
|
241
|
-
else:
|
242
|
-
port_forwarding_task.cancel()
|
243
|
-
try:
|
244
|
-
await port_forwarding_task
|
245
|
-
except asyncio.CancelledError:
|
246
|
-
pass
|
247
|
-
except Exception:
|
248
|
-
logger.exception('Error during port forwarding')
|
249
|
-
port_forwarding_task = None
|
250
|
-
elif key_press == 'l':
|
251
|
-
if logs_task is None:
|
252
|
-
logs_task = asyncio.create_task(self._start_logs())
|
253
|
-
else:
|
254
|
-
logs_task.cancel()
|
255
|
-
try:
|
256
|
-
await logs_task
|
257
|
-
except asyncio.CancelledError:
|
258
|
-
pass
|
259
|
-
except Exception:
|
260
|
-
logger.exception('Error during logs')
|
261
|
-
logs_task = None
|
262
|
-
elif key_press == 'q':
|
263
|
-
break
|
264
|
-
elif ord(key_press) == 10: # return
|
265
|
-
rich.print('')
|
266
|
-
else:
|
267
|
-
logger.debug('Unknown keypress "%s" (%d)', key_press if key_press in string.printable else '?', ord(key_press))
|
268
|
-
finally:
|
269
|
-
await self._reset_keyboard()
|
270
|
-
if port_forwarding_task is not None:
|
271
|
-
port_forwarding_task.cancel()
|
272
|
-
with contextlib.suppress(asyncio.CancelledError):
|
273
|
-
await port_forwarding_task
|
274
|
-
if sync_task is not None:
|
275
|
-
sync_task.cancel()
|
276
|
-
with contextlib.suppress(asyncio.CancelledError):
|
277
|
-
await sync_task
|
278
|
-
if logs_task is not None:
|
279
|
-
logs_task.cancel()
|
280
|
-
with contextlib.suppress(asyncio.CancelledError):
|
281
|
-
await logs_task
|
282
|
-
await self._disconnect_from_rde()
|
283
|
-
finally:
|
284
|
-
await self._cleanup_known_hosts_file()
|
296
|
+
key_press = await self.key_queue.get()
|
297
|
+
await self._handle_key_press(key_press)
|
298
|
+
except InitializationError as ex:
|
299
|
+
logger.error(ex) # noqa: TRY400
|
300
|
+
except Exception:
|
301
|
+
logger.exception('Unhandled exception')
|
285
302
|
|
286
|
-
|
303
|
+
def _window_resized(self: 'Cli', *args, **kwargs) -> None:
|
304
|
+
if self.terminal_process is None:
|
305
|
+
return
|
306
|
+
terminal_size = shutil.get_terminal_size()
|
307
|
+
self.terminal_process.change_terminal_size(terminal_size.columns, terminal_size.lines)
|
308
|
+
|
309
|
+
async def _parse_arguments(self: 'Cli') -> None:
|
310
|
+
config_home = os.environ.get('XDG_CONFIG_HOME', '$HOME/.config')
|
311
|
+
default_config_file = pathlib.Path(os.path.expandvars(config_home)) / 'devstack-cli.conf'
|
312
|
+
parser = argparse.ArgumentParser(
|
313
|
+
fromfile_prefix_chars='@',
|
314
|
+
#formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
315
|
+
)
|
316
|
+
parser.add_argument(
|
317
|
+
'-c', '--config-file',
|
318
|
+
type=str,
|
319
|
+
help='path to a devstack-cli configuration file',
|
320
|
+
default=str(default_config_file),
|
321
|
+
)
|
322
|
+
parser.add_argument(
|
323
|
+
'--workspace-url',
|
324
|
+
type=str,
|
325
|
+
help='the URL of your Cloudomation workspace',
|
326
|
+
)
|
327
|
+
parser.add_argument(
|
328
|
+
'-u', '--user-name',
|
329
|
+
type=str,
|
330
|
+
help='a user name to authenticate to the Cloudomation workspace',
|
331
|
+
)
|
332
|
+
parser.add_argument(
|
333
|
+
'--maximum-uptime-hours',
|
334
|
+
type=int,
|
335
|
+
help='the number of hours before an CDE is automatically stopped',
|
336
|
+
)
|
337
|
+
parser.add_argument(
|
338
|
+
'-n', '--cde-name',
|
339
|
+
type=str,
|
340
|
+
help='the name of the CDE',
|
341
|
+
)
|
342
|
+
parser.add_argument(
|
343
|
+
'-s', '--start',
|
344
|
+
action='store_true',
|
345
|
+
help='start CDE',
|
346
|
+
)
|
347
|
+
parser.add_argument(
|
348
|
+
'--stop',
|
349
|
+
action='store_true',
|
350
|
+
help='stop CDE',
|
351
|
+
)
|
352
|
+
parser.add_argument(
|
353
|
+
'-w', '--wait-running',
|
354
|
+
action='store_true',
|
355
|
+
help='wait until CDE is running. implies "--start".',
|
356
|
+
)
|
357
|
+
parser.add_argument(
|
358
|
+
'-o', '--connect',
|
359
|
+
action='store_true',
|
360
|
+
help='connect to CDE. implies "--start" and "--wait-running".',
|
361
|
+
)
|
362
|
+
parser.add_argument(
|
363
|
+
'-p', '--port-forwarding',
|
364
|
+
action='store_true',
|
365
|
+
help='enable port-forwarding. implies "--start", "--wait-running", and "--connect".',
|
366
|
+
)
|
367
|
+
parser.add_argument(
|
368
|
+
'-f ', '--file-sync',
|
369
|
+
action='store_true',
|
370
|
+
help='enable file-sync implies "--start", "--wait-running", and "--connect".',
|
371
|
+
)
|
372
|
+
parser.add_argument(
|
373
|
+
'-l', '--logs',
|
374
|
+
action='store_true',
|
375
|
+
help='enable following logs implies "--start", "--wait-running", and "--connect".',
|
376
|
+
)
|
377
|
+
parser.add_argument(
|
378
|
+
'-t', '--terminal',
|
379
|
+
action='store_true',
|
380
|
+
help='open interactive terminal implies "--start", "--wait-running", and "--connect".',
|
381
|
+
)
|
382
|
+
parser.add_argument(
|
383
|
+
'-q', '--quit',
|
384
|
+
action='store_true',
|
385
|
+
help='exit after processing command line arguments.',
|
386
|
+
)
|
387
|
+
|
388
|
+
# parser.add_argument(
|
389
|
+
# '-s', '--source-directory',
|
390
|
+
# type=str,
|
391
|
+
# help='a local directory where the sources of the CDE will be stored',
|
392
|
+
# )
|
393
|
+
# parser.add_argument(
|
394
|
+
# '-o', '--output-directory',
|
395
|
+
# type=str,
|
396
|
+
# help='a local directory where the outputs of the CDE will be stored',
|
397
|
+
# )
|
398
|
+
# parser.add_argument(
|
399
|
+
# '--remote-source-directory',
|
400
|
+
# type=str,
|
401
|
+
# help='a remote directory where the sources of the CDE are stored',
|
402
|
+
# )
|
403
|
+
# parser.add_argument(
|
404
|
+
# '--remote-output-directory',
|
405
|
+
# type=str,
|
406
|
+
# help='a remote directory where the outputs of the CDE are stored',
|
407
|
+
# )
|
408
|
+
# parser.add_argument(
|
409
|
+
# '--remote-username',
|
410
|
+
# type=str,
|
411
|
+
# help='the username on the CDE',
|
412
|
+
# )
|
413
|
+
parser.add_argument(
|
414
|
+
'-v', '--verbose',
|
415
|
+
action='store_true',
|
416
|
+
help='enable debug logging',
|
417
|
+
)
|
418
|
+
parser.add_argument(
|
419
|
+
'-V', '--version',
|
420
|
+
action='version',
|
421
|
+
version=f'Cloudomation devstack-cli {version.MAJOR}+{version.BRANCH_NAME}.{version.BUILD_DATE}.{version.SHORT_SHA}',
|
422
|
+
)
|
423
|
+
self.args = parser.parse_args()
|
424
|
+
|
425
|
+
if self.args.port_forwarding:
|
426
|
+
self.args.connect = True
|
427
|
+
if self.args.file_sync:
|
428
|
+
self.args.connect = True
|
429
|
+
if self.args.logs:
|
430
|
+
self.args.connect = True
|
431
|
+
if self.args.terminal:
|
432
|
+
self.args.connect = True
|
433
|
+
if self.args.connect:
|
434
|
+
self.args.wait_running = True
|
435
|
+
if self.args.wait_running:
|
436
|
+
self.args.start = True
|
437
|
+
|
438
|
+
if self.args.verbose:
|
439
|
+
logger.setLevel(logging.DEBUG)
|
440
|
+
json_logger.setLevel(logging.DEBUG)
|
441
|
+
asyncssh.set_log_level(logging.DEBUG)
|
442
|
+
else:
|
443
|
+
logger.setLevel(logging.INFO)
|
444
|
+
json_logger.setLevel(logging.INFO)
|
445
|
+
asyncssh.set_log_level(logging.WARNING)
|
446
|
+
|
447
|
+
@contextlib.asynccontextmanager
|
448
|
+
async def _key_press_to_queue(self: 'Cli'):
|
287
449
|
self._fd = sys.stdin.fileno()
|
288
450
|
self._tcattr = termios.tcgetattr(self._fd)
|
289
451
|
tty.setcbreak(self._fd)
|
290
452
|
def on_stdin() -> None:
|
291
|
-
self.loop.call_soon_threadsafe(
|
453
|
+
self.loop.call_soon_threadsafe(self.key_queue.put_nowait, sys.stdin.buffer.raw.read(1).decode())
|
292
454
|
self.loop.add_reader(sys.stdin, on_stdin)
|
455
|
+
try:
|
456
|
+
yield
|
457
|
+
finally:
|
458
|
+
self.loop.remove_reader(sys.stdin)
|
459
|
+
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._tcattr)
|
293
460
|
|
294
|
-
async def
|
295
|
-
|
461
|
+
async def _load_global_config(self: 'Cli') -> None:
|
462
|
+
self.config_file = pathlib.Path(os.path.expandvars(self.args.config_file))
|
463
|
+
self.config_file.parent.mkdir(parents=True, exist_ok=True) # make sure the config directory exists
|
464
|
+
self.config = configparser.ConfigParser()
|
465
|
+
if not self.config_file.exists():
|
466
|
+
logger.info('No configuration file exists at "%s". Creating a new configuration.', self.config_file)
|
467
|
+
else:
|
468
|
+
logger.info('Loading configuration from %s', self.config_file)
|
469
|
+
async with aiofiles.open(self.config_file, mode='r') as f:
|
470
|
+
config_str = await f.read()
|
471
|
+
self.config.read_string(config_str, source=self.config_file)
|
472
|
+
self.config.setdefault('global', {})
|
473
|
+
|
474
|
+
workspace_url = self.args.workspace_url or self.config['global'].get('workspace_url')
|
475
|
+
if not workspace_url:
|
476
|
+
workspace_url = self._console_input('Enter the URL of your Cloudomation workspace: ', prefill='https://')
|
477
|
+
self.config['global']['workspace_url'] = workspace_url
|
478
|
+
self.workspace_url = yarl.URL(workspace_url)
|
479
|
+
|
480
|
+
user_name = self.args.user_name or self.config['global'].get('user_name')
|
481
|
+
if not user_name:
|
482
|
+
user_name = self._console_input(f'Enter your user-name to authenticate to {workspace_url}: ')
|
483
|
+
self.config['global']['user_name'] = user_name
|
484
|
+
|
485
|
+
self.password = os.environ.get('DEVSTACK_CLI_PASSWORD')
|
486
|
+
if not self.password:
|
487
|
+
self.password = self._console_input(f'Enter your password to authenticate "{user_name}" to {workspace_url}: ', password=True)
|
488
|
+
|
489
|
+
maximum_uptime_hours = self.args.maximum_uptime_hours or self.config['global'].get('maximum_uptime_hours')
|
490
|
+
if not maximum_uptime_hours:
|
491
|
+
while True:
|
492
|
+
maximum_uptime_hours = self._console_input('How many hours should an CDE remain started until it is automatically stopped: ', prefill='8')
|
493
|
+
try:
|
494
|
+
int(maximum_uptime_hours)
|
495
|
+
except ValueError:
|
496
|
+
logger.error('"%s" is not a valid number', maximum_uptime_hours) # noqa: TRY400
|
497
|
+
else:
|
498
|
+
break
|
499
|
+
self.config['global']['maximum_uptime_hours'] = maximum_uptime_hours
|
500
|
+
|
501
|
+
await self._write_config_file()
|
502
|
+
|
503
|
+
async def _write_config_file(self: 'Cli') -> None:
|
504
|
+
logger.debug('Writing configuration file %s', self.config_file)
|
505
|
+
config_str = io.StringIO()
|
506
|
+
self.config.write(config_str)
|
507
|
+
async with aiofiles.open(self.config_file, mode='w') as f:
|
508
|
+
await f.write(config_str.getvalue())
|
509
|
+
|
510
|
+
def _console_input(self: 'Cli', prompt: str, *, password: bool = False, prefill: str = '') -> str:
|
296
511
|
self.loop.remove_reader(sys.stdin)
|
512
|
+
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._tcattr)
|
513
|
+
readline.set_startup_hook(lambda: readline.insert_text(prefill))
|
514
|
+
try:
|
515
|
+
response = self.console.input(prompt, password=password)
|
516
|
+
finally:
|
517
|
+
readline.set_startup_hook()
|
518
|
+
tty.setcbreak(self._fd)
|
519
|
+
def on_stdin() -> None:
|
520
|
+
self.loop.call_soon_threadsafe(self.key_queue.put_nowait, sys.stdin.read(1))
|
521
|
+
self.loop.add_reader(sys.stdin, on_stdin)
|
522
|
+
return response
|
523
|
+
|
524
|
+
async def _check_config(self: 'Cli') -> None:
|
525
|
+
logger.debug('Checking if Cloudomation workspace at %s is alive', self.config['global']['workspace_url'])
|
526
|
+
try:
|
527
|
+
response = await self.session.get(
|
528
|
+
url=self.workspace_url / 'api/latest/alive',
|
529
|
+
)
|
530
|
+
except aiohttp.client_exceptions.ClientConnectorError as ex:
|
531
|
+
raise InitializationError(f'Failed to verify Cloudomation workspace alive: {ex!s}') from ex
|
532
|
+
if response.status != 200:
|
533
|
+
raise InitializationError(f'Failed to verify Cloudomation workspace alive: {response.reason} ({response.status}):\n{await response.text()}')
|
534
|
+
workspace_info = await response.json()
|
535
|
+
logger.info('Connected to Cloudomation workspace %s', self.workspace_url)
|
536
|
+
json_logger.debug(json.dumps(workspace_info, indent=4, sort_keys=True))
|
297
537
|
|
298
|
-
|
299
|
-
|
300
|
-
|
538
|
+
logger.debug('Logging in as "%s" to Cloudomation workspace at %s', self.config['global']['user_name'], self.config['global']['workspace_url'])
|
539
|
+
response = await self.session.post(
|
540
|
+
url=self.workspace_url / 'api/latest/auth/login',
|
541
|
+
json={
|
542
|
+
'user_name': self.config['global']['user_name'],
|
543
|
+
'password': self.password,
|
544
|
+
},
|
545
|
+
)
|
546
|
+
if response.status != 200:
|
547
|
+
raise InitializationError(f'Failed to login to Cloudomation workspace: {response.reason} ({response.status}):\n{await response.text()}')
|
548
|
+
self.user_info = await response.json()
|
549
|
+
logger.info('Logged in to Cloudomation workspace')
|
550
|
+
json_logger.debug(json.dumps(self.user_info, indent=4, sort_keys=True))
|
551
|
+
|
552
|
+
response = await self.session.get(
|
553
|
+
url=self.workspace_url / 'api/latest/object_template/cde-type',
|
554
|
+
params={
|
555
|
+
'by': 'name',
|
556
|
+
},
|
557
|
+
)
|
558
|
+
if response.status != 200:
|
559
|
+
raise InitializationError(f'Failed to fetch "cde-type" object template: {response.reason} ({response.status}):\n{await response.text()}\nIs the "DevStack" bundle installed?')
|
560
|
+
self.cde_type_template = (await response.json())['object_template']
|
561
|
+
logger.debug('The "cde-type" object template')
|
562
|
+
json_logger.debug(json.dumps(self.cde_type_template, indent=4, sort_keys=True))
|
563
|
+
|
564
|
+
response = await self.session.get(
|
565
|
+
url=self.workspace_url / 'api/latest/object_template/cde',
|
566
|
+
params={
|
567
|
+
'by': 'name',
|
568
|
+
},
|
569
|
+
)
|
570
|
+
if response.status != 200:
|
571
|
+
raise InitializationError(f'Failed to fetch "cde" object template: {response.reason} ({response.status}):\n{await response.text()}\nIs the "DevStack" bundle installed?')
|
572
|
+
self.cde_template = (await response.json())['object_template']
|
573
|
+
logger.debug('The "cde" object template')
|
574
|
+
json_logger.debug(json.dumps(self.cde_template, indent=4, sort_keys=True))
|
575
|
+
|
576
|
+
response = await self.session.get(
|
577
|
+
url=self.workspace_url / 'api/latest/custom_object',
|
578
|
+
params={
|
579
|
+
'filter': json.dumps({
|
580
|
+
'field': 'object_template_id',
|
581
|
+
'op': 'eq',
|
582
|
+
'value': self.cde_type_template['id'],
|
583
|
+
}),
|
584
|
+
'plain': 'true',
|
585
|
+
},
|
586
|
+
)
|
587
|
+
if response.status != 200:
|
588
|
+
raise InitializationError(f'Failed to fetch "cde-type" custom objects: {response.reason} ({response.status}):\n{await response.text()}')
|
589
|
+
self.cde_types = await response.json()
|
590
|
+
logger.debug('The "cde-type" custom objects')
|
591
|
+
json_logger.debug(json.dumps(self.cde_types, indent=4, sort_keys=True))
|
592
|
+
|
593
|
+
# logger.info('Using configuration of CDE "%s"', self.cde_name)
|
594
|
+
# json_logger.debug(json.dumps(self.cde_config, indent=4, sort_keys=True))
|
595
|
+
|
596
|
+
async def _print_help(self: 'Cli') -> None:
|
597
|
+
await self._update_cde_list()
|
598
|
+
await self._check_background_tasks()
|
599
|
+
table = rich.table.Table(title='Help')
|
600
|
+
table.add_column('Key', style='cyan bold')
|
601
|
+
table.add_column('Function')
|
602
|
+
table.add_column('Status')
|
603
|
+
|
604
|
+
# global commands
|
605
|
+
table.add_row('h, [SPACE]', 'Print [cyan bold]h[/cyan bold]elp and status')
|
606
|
+
table.add_row('v', 'Toggle [cyan bold]v[/cyan bold]erbose debug logs', '[green]on' if logger.getEffectiveLevel() == logging.DEBUG else '[red]off')
|
607
|
+
table.add_row('q, [ESC]', '[cyan bold]Q[/cyan bold]uit')
|
608
|
+
table.add_row('#', 'DEBUG')
|
609
|
+
table.add_row('n', 'Create [cyan bold]n[/cyan bold]ew CDE')
|
610
|
+
|
611
|
+
# CDE selection
|
612
|
+
if self.cdes:
|
613
|
+
table.add_section()
|
614
|
+
table.add_row('', '== CDE selection ==')
|
615
|
+
for i, cde in enumerate(self.cdes.values(), start=1):
|
616
|
+
cde_type = await self._get_cde_type_of_cde(cde)
|
617
|
+
if not cde_type:
|
618
|
+
continue
|
619
|
+
cde_type_name = cde_type['name']
|
620
|
+
if self.cde and self.cde['name'] == cde['name']:
|
621
|
+
table.add_row(str(i), f"Select \"{cde['name']}\" ({cde_type_name}) CDE", f"[{cde['status_color']}]{cde['status']} [italic default](selected)")
|
622
|
+
else:
|
623
|
+
table.add_row(str(i), f"Select \"{cde['name']}\" ({cde_type_name}) CDE", f"[{cde['status_color']}]{cde['status']}")
|
624
|
+
|
625
|
+
# CDE operations
|
626
|
+
table.add_section()
|
627
|
+
table.add_row('', '== CDE operations ==')
|
628
|
+
if self.cde:
|
629
|
+
table.add_row('w', f'[cyan bold]W[/cyan bold]ait for "{self.cde_name}" CDE to be running')
|
630
|
+
if self.cde['status'] == 'running':
|
631
|
+
table.add_row('o', f"C[cyan bold]o[/cyan bold]nnect to \"{cde['name']}\" CDE")
|
632
|
+
elif self.cde['status'] == 'connected':
|
633
|
+
table.add_row('o', f"Disc[cyan bold]o[/cyan bold]nnect from \"{cde['name']}\" CDE")
|
634
|
+
else:
|
635
|
+
table.add_row('o', f"Connect to \"{cde['name']}\" CDE", 'N/A: CDE is not running', style='bright_black italic')
|
636
|
+
table.add_row('c', f"[cyan bold]C[/cyan bold]onfigure \"{cde['name']}\" CDE")
|
637
|
+
if self.cde['status'] in ('stopped', 'deleted'):
|
638
|
+
table.add_row('s', f'[cyan bold]S[/cyan bold]tart "{self.cde_name}" CDE')
|
639
|
+
elif self.cde['status'] in ('running', 'connected'):
|
640
|
+
table.add_row('s', f'[cyan bold]S[/cyan bold]top "{self.cde_name}" CDE')
|
641
|
+
else:
|
642
|
+
table.add_row('s', 'Start/stop CDE', 'N/A: CDE is transitioning', style='bright_black italic')
|
643
|
+
table.add_row('d', f'[cyan bold]D[/cyan bold]elete "{self.cde_name}" CDE')
|
644
|
+
else:
|
645
|
+
table.add_row('w', 'Wait for CDE to be running', 'N/A: no CDE selected', style='bright_black italic')
|
646
|
+
table.add_row('o', 'Connect to CDE', 'N/A: no CDE selected', style='bright_black italic')
|
647
|
+
table.add_row('c', 'Configure CDE', 'N/A: no CDE selected', style='bright_black italic')
|
648
|
+
table.add_row('s', 'Start/stop CDE', 'N/A: no CDE selected', style='bright_black italic')
|
649
|
+
table.add_row('d', 'Delete CDE', 'N/A: no CDE selected', style='bright_black italic')
|
650
|
+
|
651
|
+
# CDE connection
|
652
|
+
table.add_section()
|
653
|
+
table.add_row('', '== CDE connection ==')
|
654
|
+
if self.cde and self.cde['status'] == 'connected':
|
655
|
+
table.add_row('p', 'Toggle [cyan bold]p[/cyan bold]ort forwarding', '[red]off' if self.port_forwarding_task is None else '[green]on')
|
656
|
+
table.add_row('f', 'Toggle [cyan bold]f[/cyan bold]ile sync', '[red]off' if self.sync_task is None else '[green]on')
|
657
|
+
table.add_row('l', 'Toggle following [cyan bold]l[/cyan bold]ogs', '[red]off' if self.logs_task is None else '[green]on')
|
658
|
+
table.add_row('t', 'Open an interactive terminal session on the CDE')
|
659
|
+
else:
|
660
|
+
table.add_row('p', 'Toggle port forwarding', 'N/A: not connected', style='bright_black italic')
|
661
|
+
table.add_row('f', 'Toggle file sync', 'N/A: not connected', style='bright_black italic')
|
662
|
+
table.add_row('l', 'Toggle following logs', 'N/A: not connected', style='bright_black italic')
|
663
|
+
table.add_row('t', 'Open an interactive terminal session on the CDE', 'N/A: not connected', style='bright_black italic')
|
664
|
+
rich.print(table)
|
665
|
+
|
666
|
+
async def _update_cde_list(self: 'Cli') -> None:
|
667
|
+
logger.info('Fetching updated CDE list from Cloudomation workspace')
|
668
|
+
try:
|
669
|
+
response = await self.session.get(
|
670
|
+
url=self.workspace_url / 'api/latest/custom_object',
|
671
|
+
params={
|
672
|
+
'filter': json.dumps({
|
673
|
+
'and': [
|
674
|
+
{
|
675
|
+
'field': 'object_template_id',
|
676
|
+
'op': 'eq',
|
677
|
+
'value': self.cde_template['id'],
|
678
|
+
},
|
679
|
+
{
|
680
|
+
'field': 'created_by',
|
681
|
+
'op': 'eq',
|
682
|
+
'value': self.user_info['identity_id'],
|
683
|
+
},
|
684
|
+
],
|
685
|
+
}),
|
686
|
+
'plain': 'true',
|
687
|
+
},
|
688
|
+
)
|
689
|
+
except (aiohttp.ClientError, aiohttp.ClientResponseError) as ex:
|
690
|
+
logger.error('Failed to fetch CDE list: %s', str(ex)) # noqa: TRY400
|
691
|
+
return
|
692
|
+
if response.status != 200:
|
693
|
+
logger.error('Failed to fetch CDE list: %s (%s):\n%s', response.reason, response.status, await response.text())
|
694
|
+
return
|
695
|
+
response = await response.json()
|
696
|
+
self.cdes = {
|
697
|
+
cde['name']: {
|
698
|
+
**cde,
|
699
|
+
'exists_remotely': True,
|
700
|
+
}
|
701
|
+
for cde
|
702
|
+
in response
|
703
|
+
}
|
704
|
+
# combine with CDE infos from local config file
|
705
|
+
for cde_config_key, cde_config_value in self.config.items():
|
706
|
+
if not cde_config_key.startswith('cde.'):
|
707
|
+
continue
|
708
|
+
cur_cde_name = cde_config_key[4:]
|
709
|
+
self.cdes.setdefault(cur_cde_name, {}).update({
|
710
|
+
**cde_config_value,
|
711
|
+
'name': cur_cde_name,
|
712
|
+
'exists_locally': True,
|
713
|
+
})
|
714
|
+
# enrich CDE infos with:
|
715
|
+
# - combined status: provisioning_state & is-running & exists locally only
|
716
|
+
# - exists_locally: cde name present in config file
|
717
|
+
# - exists_remotely: remote config exists
|
718
|
+
for cde in self.cdes.values():
|
719
|
+
cde.setdefault('exists_remotely', False)
|
720
|
+
cde.setdefault('exists_locally', False)
|
721
|
+
if not cde['exists_locally']:
|
722
|
+
cde['status'] = 'not configured'
|
723
|
+
cde['status_color'] = 'yellow'
|
724
|
+
elif not cde['exists_remotely']:
|
725
|
+
cde['status'] = 'deleted'
|
726
|
+
cde['status_color'] = 'red'
|
727
|
+
elif cde['provisioning_state'] == 'READY':
|
728
|
+
if cde['value']['is-running']:
|
729
|
+
if cde['value'].get('hostname'):
|
730
|
+
if self.ssh_client is None:
|
731
|
+
cde['status'] = 'running'
|
732
|
+
cde['status_color'] = 'green'
|
733
|
+
else:
|
734
|
+
cde['status'] = 'connected'
|
735
|
+
cde['status_color'] = 'green bold'
|
736
|
+
else:
|
737
|
+
cde['status'] = 'starting'
|
738
|
+
cde['status_color'] = 'blue'
|
739
|
+
else:
|
740
|
+
cde['status'] = 'stopped'
|
741
|
+
cde['status_color'] = 'red'
|
742
|
+
elif cde['provisioning_state'].endswith('_FAILED'):
|
743
|
+
cde['status'] = cde['provisioning_state'].lower()
|
744
|
+
cde['status_color'] = 'red'
|
745
|
+
else:
|
746
|
+
cde['status'] = cde['provisioning_state'].lower()
|
747
|
+
cde['status_color'] = 'blue'
|
748
|
+
|
749
|
+
logger.debug('Your CDEs')
|
750
|
+
json_logger.debug(json.dumps(self.cdes, indent=4, sort_keys=True))
|
751
|
+
|
752
|
+
if self.cde:
|
753
|
+
try:
|
754
|
+
# update selected cde info from fetched list
|
755
|
+
await self._select_cde(self.cde_name, quiet=True)
|
756
|
+
except KeyError:
|
757
|
+
logger.warning('Selected CDE "%s" does not exist any more. Unselecting.', self.cde_name)
|
758
|
+
self.cde = None
|
759
|
+
|
760
|
+
async def _check_background_tasks(self: 'Cli') -> None:
|
761
|
+
if self.sync_task is not None and self.sync_task.done():
|
762
|
+
self.sync_task = None
|
763
|
+
if self.port_forwarding_task is not None and self.port_forwarding_task.done():
|
764
|
+
self.port_forwarding_task = None
|
765
|
+
if self.logs_task is not None and self.logs_task.done():
|
766
|
+
self.logs_task = None
|
767
|
+
if self.ssh_client is not None:
|
768
|
+
transport = self.ssh_client.get_transport()
|
769
|
+
if transport.is_active():
|
770
|
+
try:
|
771
|
+
transport.send_ignore()
|
772
|
+
except EOFError:
|
773
|
+
# connection is closed
|
774
|
+
logger.warning('SSH connection is not alive, disconnecting.')
|
775
|
+
self.ssh_client.close()
|
776
|
+
self.ssh_client = None
|
777
|
+
else:
|
778
|
+
logger.warning('SSH connection is not alive, disconnecting.')
|
779
|
+
self.ssh_client.close()
|
780
|
+
self.ssh_client = None
|
781
|
+
if self.ssh_client is None:
|
782
|
+
# we are not connected to any cde. make sure background tasks are cancelled
|
783
|
+
if self.sync_task:
|
784
|
+
self.sync_task.cancel()
|
785
|
+
self.sync_task = None
|
786
|
+
if self.port_forwarding_task:
|
787
|
+
self.port_forwarding_task.cancel()
|
788
|
+
self.port_forwarding_task = None
|
789
|
+
if self.logs_task:
|
790
|
+
self.logs_task.cancel()
|
791
|
+
self.logs_task = None
|
792
|
+
if self.sftp_client is not None:
|
793
|
+
self.sftp_client.close()
|
794
|
+
self.sftp_client = None
|
795
|
+
|
796
|
+
|
797
|
+
async def _get_cde_type_of_cde(self: 'Cli', cde: dict) -> typing.Optional[dict]:
|
798
|
+
if cde['exists_remotely']:
|
799
|
+
try:
|
800
|
+
cde_type = next(cde_type for cde_type in self.cde_types if cde_type['id'] == cde['value']['cde-type'])
|
801
|
+
except StopIteration:
|
802
|
+
logger.error('CDE type ID "%s" not found', cde['value']['cde-type']) # noqa: TRY400
|
803
|
+
return None
|
804
|
+
elif cde['exists_locally']:
|
805
|
+
try:
|
806
|
+
cde_type = next(cde_type for cde_type in self.cde_types if cde_type['name'] == cde['cde_type'])
|
807
|
+
except StopIteration:
|
808
|
+
logger.error('CDE type "%s" not found', cde['cde_type']) # noqa: TRY400
|
809
|
+
return None
|
810
|
+
else:
|
811
|
+
logger.error('CDE does not exist')
|
812
|
+
return None
|
813
|
+
return cde_type
|
814
|
+
|
815
|
+
async def _process_args(self: 'Cli') -> None:
|
816
|
+
if self.args.cde_name:
|
817
|
+
await self._select_cde(self.args.cde_name)
|
818
|
+
elif 'last_cde_name' in self.config['global']:
|
819
|
+
await self._select_cde(self.config['global']['last_cde_name'])
|
820
|
+
|
821
|
+
if self.args.start:
|
822
|
+
await self._start_cde()
|
823
|
+
elif self.args.stop:
|
824
|
+
await self._stop_cde()
|
825
|
+
|
826
|
+
if self.args.wait_running and self.cde['status'] == 'not configured':
|
827
|
+
await self._configure_cde()
|
828
|
+
|
829
|
+
if self.args.wait_running and self.cde['status'] != 'running':
|
830
|
+
await self._wait_running()
|
831
|
+
|
832
|
+
if self.args.connect:
|
833
|
+
await self._connect_cde()
|
834
|
+
|
835
|
+
if self.args.port_forwarding:
|
836
|
+
await self._start_port_forwarding()
|
837
|
+
|
838
|
+
if self.args.file_sync:
|
839
|
+
await self._start_sync()
|
840
|
+
|
841
|
+
if self.args.logs:
|
842
|
+
await self._start_logs()
|
843
|
+
|
844
|
+
if self.args.terminal:
|
845
|
+
await self._open_terminal()
|
846
|
+
|
847
|
+
if self.args.quit:
|
848
|
+
raise KeyboardInterrupt
|
849
|
+
|
850
|
+
async def _handle_key_press(self: 'Cli', key_press: str) -> None:
|
851
|
+
if key_press in ('h', ' '):
|
852
|
+
await self._print_help()
|
853
|
+
elif key_press == 'v':
|
854
|
+
if logger.getEffectiveLevel() == logging.INFO:
|
855
|
+
logger.info('Enabling debug logs')
|
856
|
+
logger.setLevel(logging.DEBUG)
|
857
|
+
else:
|
858
|
+
logger.info('Disabling debug logs')
|
859
|
+
logger.setLevel(logging.INFO)
|
860
|
+
elif key_press == 'q':
|
861
|
+
raise asyncio.CancelledError
|
862
|
+
elif key_press == '\x1b': # escape
|
863
|
+
await asyncio.sleep(0) # event loop tick for queue.put_nowait be handled
|
864
|
+
if self.key_queue.empty():
|
865
|
+
# single escape press
|
866
|
+
raise asyncio.CancelledError
|
867
|
+
# escape sequence
|
868
|
+
seq = ''
|
869
|
+
while not self.key_queue.empty():
|
870
|
+
seq += await self.key_queue.get()
|
871
|
+
await asyncio.sleep(0) # event loop tick for queue.put_nowait be handled
|
872
|
+
logger.warning('Ignoring escape sequence "%s"', seq)
|
873
|
+
elif key_press == '#':
|
874
|
+
if self.cde:
|
875
|
+
logger.info('CDE config')
|
876
|
+
json_logger.info(json.dumps(self.cde, indent=4, sort_keys=True))
|
877
|
+
if self.cde_type:
|
878
|
+
logger.info('CDE type config')
|
879
|
+
json_logger.info(json.dumps(self.cde_type, indent=4, sort_keys=True))
|
880
|
+
elif key_press == 'n':
|
881
|
+
await self._create_cde()
|
882
|
+
elif key_press in (str(i) for i in range(1, len(self.cdes)+1)):
|
883
|
+
cde_name = list(self.cdes.values())[int(key_press)-1]['name']
|
884
|
+
await self._select_cde(cde_name)
|
885
|
+
elif key_press == 'w':
|
886
|
+
await self._wait_running()
|
887
|
+
elif key_press == 'o':
|
888
|
+
await self._connect_disconnect_cde()
|
889
|
+
elif key_press == 'c':
|
890
|
+
await self._configure_cde()
|
891
|
+
elif key_press == 's':
|
892
|
+
await self._start_stop_cde()
|
893
|
+
elif key_press == 'd':
|
894
|
+
await self._delete_cde()
|
895
|
+
elif key_press == 'p':
|
896
|
+
await self._toggle_port_forwarding()
|
897
|
+
elif key_press == 'f':
|
898
|
+
await self._toggle_sync()
|
899
|
+
elif key_press == 'l':
|
900
|
+
await self._toggle_logs()
|
901
|
+
elif key_press == 't':
|
902
|
+
await self._open_terminal()
|
903
|
+
elif key_press == '\x0a': # return
|
904
|
+
rich.print('')
|
905
|
+
else:
|
906
|
+
logger.warning('Unknown keypress "%s" (%d)', key_press if key_press in string.printable else '?', ord(key_press))
|
907
|
+
|
908
|
+
async def _create_cde(self: 'Cli') -> None:
|
909
|
+
logger.info('Creating new CDE')
|
910
|
+
table = rich.table.Table(title='CDE types')
|
911
|
+
table.add_column('Key', style='cyan bold')
|
912
|
+
table.add_column('Name')
|
913
|
+
table.add_column('Description')
|
914
|
+
for i, cde_type in enumerate(self.cde_types, start=1):
|
915
|
+
table.add_row(str(i), cde_type['name'], cde_type['description'])
|
916
|
+
table.add_row('ESC', 'Cancel')
|
917
|
+
rich.print(table)
|
918
|
+
logger.info('Choose a CDE type (1-%d):', len(self.cde_types))
|
919
|
+
key_press = await self.key_queue.get()
|
920
|
+
if key_press == chr(27):
|
921
|
+
logger.warning('Aborting')
|
922
|
+
return
|
923
|
+
try:
|
924
|
+
cde_type = self.cde_types[int(key_press)-1]
|
925
|
+
except (IndexError, ValueError):
|
926
|
+
logger.error('Invalid choice "%s"', key_press) # noqa: TRY400
|
927
|
+
return
|
928
|
+
cde_name = self._console_input('Choose a name for your CDE: ', prefill=f"{self.user_info['name']}-{cde_type['name']}")
|
929
|
+
await self._create_cde_api_call(cde_name, cde_type['id'])
|
930
|
+
await self._update_cde_list()
|
931
|
+
await self._select_cde(cde_name)
|
932
|
+
|
933
|
+
async def _create_cde_api_call(self: 'Cli', cde_name: str, cde_type_id: str) -> None:
|
934
|
+
maximum_uptime_hours = int(self.config['global'].get('maximum_uptime_hours', '8'))
|
935
|
+
stop_at = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=maximum_uptime_hours)).isoformat()
|
936
|
+
try:
|
937
|
+
response = await self.session.post(
|
938
|
+
url=self.workspace_url / 'api/latest/custom_object',
|
939
|
+
json={
|
940
|
+
'name': cde_name,
|
941
|
+
'object_template_id': self.cde_template['id'],
|
942
|
+
'value': {
|
943
|
+
'cde-type': cde_type_id,
|
944
|
+
'user': self.user_info['identity_id'],
|
945
|
+
'feature-branch-mapping': None,
|
946
|
+
'stop-at': stop_at,
|
947
|
+
},
|
948
|
+
},
|
949
|
+
params={
|
950
|
+
'plain': 'true',
|
951
|
+
},
|
952
|
+
)
|
953
|
+
except (aiohttp.ClientError, aiohttp.ClientResponseError) as ex:
|
954
|
+
logger.error('Failed to create CDE: %s', str(ex)) # noqa: TRY400
|
955
|
+
return
|
956
|
+
if response.status != 200:
|
957
|
+
logger.error('Failed to create CDE: %s (%s):\n%s', response.reason, response.status, await response.text())
|
958
|
+
return
|
959
|
+
|
960
|
+
async def _select_cde(self: 'Cli', cde_name: str, *, quiet: bool = False) -> None:
|
961
|
+
try:
|
962
|
+
self.cde = self.cdes[cde_name]
|
963
|
+
except IndexError:
|
964
|
+
logger.error('Cannot select CDE "%s". No such CDE', cde_name) # noqa: TRY400
|
965
|
+
return
|
966
|
+
if not quiet:
|
967
|
+
logger.info('Selecting "%s" CDE', self.cde_name)
|
968
|
+
self.cde_type = await self._get_cde_type_of_cde(self.cde)
|
969
|
+
self.config['global']['last_cde_name'] = self.cde_name
|
970
|
+
await self._write_config_file()
|
971
|
+
|
972
|
+
async def _wait_running(self: 'Cli') -> None:
|
973
|
+
logger.info('Waiting for CDE "%s" to reach status running...', self.cde_name)
|
974
|
+
while True:
|
975
|
+
await self._update_cde_list()
|
976
|
+
if self.cde['status'] == 'running':
|
977
|
+
break
|
978
|
+
if self.cde['status'].endswith('_failed') or self.cde['status'] in {'not configured', 'deleted', 'connected', 'stopped'}:
|
979
|
+
logger.error('CDE "%s" failed to reach status running and is now in status "%s".', self.cde_name, self.cde['status'])
|
980
|
+
return
|
981
|
+
await asyncio.sleep(10)
|
982
|
+
logger.info('CDE "%s" is now running', self.cde_name)
|
983
|
+
|
984
|
+
async def _connect_disconnect_cde(self: 'Cli') -> None:
|
985
|
+
await self._update_cde_list()
|
986
|
+
if not self.cde:
|
987
|
+
logger.error('No CDE is selected. Cannot connect.')
|
988
|
+
return
|
989
|
+
if self.cde['status'] == 'running':
|
990
|
+
await self._connect_cde()
|
991
|
+
elif self.cde['status'] == 'connected':
|
992
|
+
await self._disconnect_cde()
|
993
|
+
else:
|
994
|
+
logger.error('CDE is not running. Cannot connect.')
|
995
|
+
return
|
996
|
+
|
997
|
+
async def _connect_cde(self: 'Cli') -> None:
|
998
|
+
logger.info('Connecting to CDE')
|
999
|
+
known_hosts = await self._get_known_hosts()
|
1000
|
+
if known_hosts is None:
|
1001
|
+
return
|
1002
|
+
self.known_hosts_file = await _create_temp_file(exit_stack=self.exit_stack, content=known_hosts)
|
1003
|
+
self.ssh_client = paramiko.SSHClient()
|
1004
|
+
self.ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
1005
|
+
try:
|
1006
|
+
self.ssh_client.connect(
|
1007
|
+
hostname=self.hostname,
|
1008
|
+
username=self.cde_type['value']['remote-username'],
|
1009
|
+
timeout=30,
|
1010
|
+
)
|
1011
|
+
except TimeoutError:
|
1012
|
+
logger.error('Timeout while connecting to CDE. Is your CDE running?') # noqa: TRY400
|
1013
|
+
self.ssh_client = None
|
1014
|
+
return
|
1015
|
+
transport = self.ssh_client.get_transport()
|
1016
|
+
transport.set_keepalive(30)
|
1017
|
+
self.sftp_client = paramiko.sftp_client.SFTPClient.from_transport(transport)
|
1018
|
+
logger.info('Connected to CDE')
|
1019
|
+
|
1020
|
+
async def _get_known_hosts(self: 'Cli') -> typing.Optional[str]:
|
1021
|
+
if self.cde['value']['hostkey']:
|
1022
|
+
if self.cde['value']['hostkey'].startswith(self.cde['value']['hostname']):
|
1023
|
+
return self.cde['value']['hostkey']
|
1024
|
+
return f"{self.cde['value']['hostname']} {self.cde['value']['hostkey']}"
|
1025
|
+
if not self.cde:
|
1026
|
+
logger.error('No CDE is selected. Cannot fetch host-key.')
|
1027
|
+
return None
|
1028
|
+
if not self.is_cde_running:
|
1029
|
+
logger.error('CDE is not running. Cannot fetch host-key.')
|
1030
|
+
return None
|
301
1031
|
logger.debug('Scanning hostkeys of "%s"', self.hostname)
|
302
1032
|
try:
|
303
1033
|
stdout, stderr = await run_subprocess(
|
@@ -310,47 +1040,366 @@ class Cli:
|
|
310
1040
|
print_stderr=False,
|
311
1041
|
)
|
312
1042
|
except SubprocessError as ex:
|
313
|
-
logger.error('%s Failed to fetch hostkeys. Is you
|
1043
|
+
logger.error('%s Failed to fetch hostkeys. Is you CDE running?', ex) # noqa: TRY400
|
314
1044
|
sys.exit(1)
|
315
|
-
|
1045
|
+
known_hosts = stdout
|
316
1046
|
with contextlib.suppress(FileNotFoundError):
|
317
|
-
|
318
|
-
|
1047
|
+
known_hosts += pathlib.Path(os.path.expandvars('$HOME/.ssh/known_hosts')).read_bytes()
|
1048
|
+
return known_hosts
|
1049
|
+
|
1050
|
+
async def _disconnect_cde(self: 'Cli') -> None:
|
1051
|
+
logger.info('Disconnecting from CDE')
|
1052
|
+
if self.sftp_client is not None:
|
1053
|
+
self.sftp_client.close()
|
1054
|
+
self.sftp_client = None
|
1055
|
+
if self.ssh_client is not None:
|
1056
|
+
self.ssh_client.close()
|
1057
|
+
self.ssh_client = None
|
1058
|
+
self.known_hosts_file = None
|
1059
|
+
logger.debug('Disconnected from CDE')
|
319
1060
|
|
320
|
-
async def
|
321
|
-
|
1061
|
+
async def _configure_cde(self: 'Cli') -> None:
|
1062
|
+
await self._update_cde_list()
|
1063
|
+
if not self.cde:
|
1064
|
+
logger.error('No CDE is selected. Cannot configure CDE.')
|
322
1065
|
return
|
323
|
-
|
1066
|
+
cde_config_key = f'cde.{self.cde_name}'
|
1067
|
+
if cde_config_key not in self.config:
|
1068
|
+
logger.info('Creating new configuration for CDE "%s".', self.cde_name)
|
1069
|
+
self.config[cde_config_key] = {
|
1070
|
+
'cde_type': self.cde_type['name'],
|
1071
|
+
}
|
1072
|
+
source_directory = self._console_input(
|
1073
|
+
f'Choose a local directory where the sources of the "{self.cde_name}" CDE will be stored: ',
|
1074
|
+
prefill=self.config[cde_config_key].get('source_directory', f'$HOME/{self.cde_type["name"].replace(" ", "-")}'),
|
1075
|
+
)
|
1076
|
+
self.config[cde_config_key]['source_directory'] = source_directory
|
1077
|
+
while True:
|
1078
|
+
output_directory = self._console_input(
|
1079
|
+
f'Choose a local directory where the outputs of the "{self.cde_name}" CDE will be stored: ',
|
1080
|
+
prefill=self.config[cde_config_key].get('output_directory', f'$HOME/{self.cde_type["name"].replace(" ", "-")}-output'),
|
1081
|
+
)
|
1082
|
+
if (
|
1083
|
+
_is_relative_to(source_directory, output_directory)
|
1084
|
+
or _is_relative_to(output_directory, source_directory)
|
1085
|
+
):
|
1086
|
+
logger.error('Source-directory and output-directory must not overlap!')
|
1087
|
+
else:
|
1088
|
+
break
|
1089
|
+
self.config[cde_config_key]['output_directory'] = output_directory
|
1090
|
+
while True:
|
1091
|
+
maximum_uptime_hours = self._console_input(
|
1092
|
+
'How many hours should this CDE remain started until it is automatically stopped: ',
|
1093
|
+
prefill=self.config['global'].get('maximum_uptime_hours', '8'),
|
1094
|
+
)
|
1095
|
+
try:
|
1096
|
+
int(maximum_uptime_hours)
|
1097
|
+
except ValueError:
|
1098
|
+
logger.error('"%s" is not a valid number', maximum_uptime_hours) # noqa: TRY400
|
1099
|
+
else:
|
1100
|
+
break
|
1101
|
+
self.config[cde_config_key]['maximum_uptime_hours'] = maximum_uptime_hours
|
324
1102
|
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
1103
|
+
await self._write_config_file()
|
1104
|
+
logger.info('CDE "%s" configured.', self.cde_name)
|
1105
|
+
|
1106
|
+
async def _start_stop_cde(self: 'Cli') -> None:
|
1107
|
+
await self._update_cde_list()
|
1108
|
+
if not self.cde:
|
1109
|
+
logger.error('No CDE is selected. Cannot start/stop CDE.')
|
1110
|
+
return
|
1111
|
+
if self.cde['status'] in ('stopped', 'deleted'):
|
1112
|
+
await self._start_cde()
|
1113
|
+
elif self.cde['status'] in ('running', 'connected'):
|
1114
|
+
await self._stop_cde()
|
1115
|
+
|
1116
|
+
async def _start_cde(self: 'Cli') -> None:
|
1117
|
+
logger.info('Start CDE')
|
1118
|
+
if not self.cde['exists_remotely']:
|
1119
|
+
await self._create_cde_api_call(self.cde['name'], self.cde_type['id'])
|
1120
|
+
else:
|
1121
|
+
await self._start_cde_api_call()
|
1122
|
+
|
1123
|
+
async def _stop_cde(self: 'Cli') -> None:
|
1124
|
+
logger.info('Stop CDE')
|
1125
|
+
await self._stop_cde_api_call()
|
1126
|
+
# cde was running, is now stopping
|
1127
|
+
if self.sync_task:
|
1128
|
+
self.sync_task.cancel()
|
1129
|
+
self.sync_task = None
|
1130
|
+
if self.port_forwarding_task:
|
1131
|
+
self.port_forwarding_task.cancel()
|
1132
|
+
self.port_forwarding_task = None
|
1133
|
+
if self.logs_task:
|
1134
|
+
self.logs_task.cancel()
|
1135
|
+
self.logs_task = None
|
1136
|
+
if self.ssh_client is not None:
|
1137
|
+
self.ssh_client.close()
|
1138
|
+
self.ssh_client = None
|
1139
|
+
|
1140
|
+
async def _start_cde_api_call(self: 'Cli') -> None:
|
1141
|
+
maximum_uptime_hours = int(self.config['global'].get('maximum_uptime_hours', '8'))
|
1142
|
+
stop_at = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=maximum_uptime_hours)).isoformat()
|
329
1143
|
try:
|
330
|
-
self.
|
331
|
-
|
332
|
-
|
333
|
-
|
1144
|
+
response = await self.session.patch(
|
1145
|
+
url=self.workspace_url / 'api/latest/custom_object' / self.cde['id'],
|
1146
|
+
json={
|
1147
|
+
'value': {
|
1148
|
+
'is-running': True,
|
1149
|
+
'stop-at': stop_at,
|
1150
|
+
},
|
1151
|
+
},
|
334
1152
|
)
|
335
|
-
except
|
336
|
-
logger.
|
337
|
-
|
1153
|
+
except (aiohttp.ClientError, aiohttp.ClientResponseError) as ex:
|
1154
|
+
logger.error('Failed to start CDE: %s', str(ex)) # noqa: TRY400
|
1155
|
+
return
|
1156
|
+
if response.status != 200:
|
1157
|
+
logger.error('Failed to start CDE: %s (%s):\n%s', response.reason, response.status, await response.text())
|
338
1158
|
return
|
339
|
-
transport = self.ssh_client.get_transport()
|
340
|
-
self.sftp_client = paramiko.sftp_client.SFTPClient.from_transport(transport)
|
341
1159
|
|
342
|
-
async def
|
343
|
-
|
344
|
-
self.
|
345
|
-
|
1160
|
+
async def _stop_cde_api_call(self: 'Cli') -> None:
|
1161
|
+
try:
|
1162
|
+
response = await self.session.patch(
|
1163
|
+
url=self.workspace_url / 'api/latest/custom_object' / self.cde['id'],
|
1164
|
+
json={
|
1165
|
+
'value': {
|
1166
|
+
'is-running': False,
|
1167
|
+
},
|
1168
|
+
},
|
1169
|
+
)
|
1170
|
+
except (aiohttp.ClientError, aiohttp.ClientResponseError) as ex:
|
1171
|
+
logger.error('Failed to stop CDE: %s', str(ex)) # noqa: TRY400
|
1172
|
+
return
|
1173
|
+
if response.status != 200:
|
1174
|
+
logger.error('Failed to stop CDE: %s (%s):\n%s', response.reason, response.status, await response.text())
|
1175
|
+
return
|
1176
|
+
|
1177
|
+
async def _delete_cde(self: 'Cli') -> None:
|
1178
|
+
await self._update_cde_list()
|
1179
|
+
if not self.cde:
|
1180
|
+
logger.error('No CDE is selected. Cannot delete CDE.')
|
1181
|
+
return
|
1182
|
+
logger.info('Deleting CDE "%s"', self.cde_name)
|
1183
|
+
try:
|
1184
|
+
response = await self.session.delete(
|
1185
|
+
url=self.workspace_url / 'api/latest/custom_object' / self.cde['id'],
|
1186
|
+
params={
|
1187
|
+
'permanently': 'true',
|
1188
|
+
},
|
1189
|
+
)
|
1190
|
+
except (aiohttp.ClientError, aiohttp.ClientResponseError) as ex:
|
1191
|
+
logger.error('Failed to delete CDE: %s', str(ex)) # noqa: TRY400
|
1192
|
+
return
|
1193
|
+
if response.status != 204:
|
1194
|
+
logger.error('Failed to delete CDE: %s (%s)', response.reason, response.status)
|
1195
|
+
return
|
1196
|
+
if self.sync_task:
|
1197
|
+
self.sync_task.cancel()
|
1198
|
+
self.sync_task = None
|
1199
|
+
if self.port_forwarding_task:
|
1200
|
+
self.port_forwarding_task.cancel()
|
1201
|
+
self.port_forwarding_task = None
|
1202
|
+
if self.logs_task:
|
1203
|
+
self.logs_task.cancel()
|
1204
|
+
self.logs_task = None
|
346
1205
|
if self.ssh_client is not None:
|
347
1206
|
self.ssh_client.close()
|
348
1207
|
self.ssh_client = None
|
349
1208
|
|
1209
|
+
#####
|
1210
|
+
##### PORT FORWARDING
|
1211
|
+
#####
|
1212
|
+
async def _toggle_port_forwarding(self: 'Cli') -> None:
|
1213
|
+
await self._update_cde_list()
|
1214
|
+
if self.port_forwarding_task is None:
|
1215
|
+
await self._start_port_forwarding()
|
1216
|
+
else:
|
1217
|
+
await self._stop_port_forwarding()
|
1218
|
+
|
1219
|
+
async def _start_port_forwarding(self: 'Cli') -> None:
|
1220
|
+
if not self.cde:
|
1221
|
+
logger.error('No CDE is selected. Cannot start port forwarding.')
|
1222
|
+
return
|
1223
|
+
if not self.is_cde_running:
|
1224
|
+
logger.error('CDE is not running. Cannot start port forwarding.')
|
1225
|
+
return
|
1226
|
+
if self.ssh_client is None:
|
1227
|
+
logger.error('Not connected to CDE. Cannot start port forwarding.')
|
1228
|
+
return
|
1229
|
+
self.port_forwarding_task = asyncio.create_task(self._bg_port_forwarding())
|
1230
|
+
|
1231
|
+
async def _stop_port_forwarding(self: 'Cli') -> None:
|
1232
|
+
self.port_forwarding_task.cancel()
|
1233
|
+
self.port_forwarding_task = None
|
1234
|
+
|
1235
|
+
async def _bg_port_forwarding(self: 'Cli') -> None:
|
1236
|
+
service_ports = self.cde_type['value'].get('service-ports')
|
1237
|
+
if service_ports is None:
|
1238
|
+
service_ports = [
|
1239
|
+
'8443:443',
|
1240
|
+
5678,
|
1241
|
+
6678,
|
1242
|
+
7678,
|
1243
|
+
8678,
|
1244
|
+
3000,
|
1245
|
+
2022,
|
1246
|
+
]
|
1247
|
+
service_ports = [
|
1248
|
+
(port, port) if isinstance(port, int) else tuple(map(int, port.split(':', 1)))
|
1249
|
+
for port
|
1250
|
+
in service_ports
|
1251
|
+
]
|
1252
|
+
while True:
|
1253
|
+
logger.info('Starting port forwarding of %s', ', '.join(str(port[0]) for port in service_ports))
|
1254
|
+
try:
|
1255
|
+
await run_subprocess(
|
1256
|
+
'ssh',
|
1257
|
+
[
|
1258
|
+
'-o', 'ConnectTimeout=10',
|
1259
|
+
'-o', f'UserKnownHostsFile={self.known_hosts_file.name}',
|
1260
|
+
'-NT',
|
1261
|
+
f"{self.cde_type['value']['remote-username']}@{self.hostname}",
|
1262
|
+
*itertools.chain.from_iterable([
|
1263
|
+
('-L', f'{port[0]}:localhost:{port[1]}')
|
1264
|
+
for port
|
1265
|
+
in service_ports
|
1266
|
+
]),
|
1267
|
+
|
1268
|
+
],
|
1269
|
+
name='Port forwarding',
|
1270
|
+
capture_stdout=False,
|
1271
|
+
)
|
1272
|
+
except asyncio.CancelledError:
|
1273
|
+
logger.info('Port forwarding interrupted')
|
1274
|
+
raise
|
1275
|
+
except SubprocessError as ex:
|
1276
|
+
logger.error('Port forwarding failed:\n%s: %s', type(ex).__name__, str(ex)) # noqa: TRY400
|
1277
|
+
logger.info('Will retry port forwarding in %s seconds', RETRY_DELAY_SECONDS)
|
1278
|
+
await asyncio.sleep(RETRY_DELAY_SECONDS)
|
1279
|
+
await self._check_background_tasks()
|
1280
|
+
except Exception:
|
1281
|
+
logger.exception('Port forwarding failed')
|
1282
|
+
logger.info('Will retry port forwarding in %s seconds', RETRY_DELAY_SECONDS)
|
1283
|
+
await asyncio.sleep(RETRY_DELAY_SECONDS)
|
1284
|
+
await self._check_background_tasks()
|
1285
|
+
else:
|
1286
|
+
logger.info('Port forwarding done')
|
1287
|
+
break
|
1288
|
+
|
1289
|
+
#####
|
1290
|
+
##### FILE SYNC
|
1291
|
+
#####
|
1292
|
+
async def _toggle_sync(self: 'Cli') -> None:
|
1293
|
+
await self._update_cde_list()
|
1294
|
+
if self.sync_task is None:
|
1295
|
+
await self._start_sync()
|
1296
|
+
else:
|
1297
|
+
await self._stop_sync()
|
1298
|
+
|
1299
|
+
async def _start_sync(self: 'Cli') -> None:
|
1300
|
+
if not self.cde:
|
1301
|
+
logger.error('No CDE is selected. Cannot start file sync.')
|
1302
|
+
return
|
1303
|
+
if not self.is_cde_running:
|
1304
|
+
logger.error('CDE is not running. Cannot start file sync.')
|
1305
|
+
return
|
1306
|
+
if self.sftp_client is None:
|
1307
|
+
logger.error('Not connected to CDE. Cannot start file sync.')
|
1308
|
+
return
|
1309
|
+
self.sync_task = asyncio.create_task(self._bg_sync())
|
1310
|
+
|
1311
|
+
async def _stop_sync(self: 'Cli') -> None:
|
1312
|
+
self.sync_task.cancel()
|
1313
|
+
self.sync_task = None
|
1314
|
+
|
1315
|
+
async def _bg_sync(self: 'Cli') -> None:
|
1316
|
+
while True:
|
1317
|
+
logger.info('Starting file sync')
|
1318
|
+
try:
|
1319
|
+
await self._init_local_cache()
|
1320
|
+
except OSError as ex:
|
1321
|
+
logger.error('Failed to initialize local cache: %s', str(ex)) # noqa: TRY400
|
1322
|
+
return
|
1323
|
+
filesystem_event_queue = asyncio.Queue()
|
1324
|
+
filesystem_watch_task = asyncio.create_task(
|
1325
|
+
self._watch_filesystem(
|
1326
|
+
queue=filesystem_event_queue,
|
1327
|
+
),
|
1328
|
+
)
|
1329
|
+
if self.local_output_directory:
|
1330
|
+
remote_sync_task = asyncio.create_task(
|
1331
|
+
self._remote_sync(),
|
1332
|
+
)
|
1333
|
+
else:
|
1334
|
+
remote_sync_task = None
|
1335
|
+
background_sync_task = None
|
1336
|
+
try:
|
1337
|
+
while True:
|
1338
|
+
filesystem_events = []
|
1339
|
+
if background_sync_task is not None:
|
1340
|
+
background_sync_task.cancel()
|
1341
|
+
with contextlib.suppress(asyncio.CancelledError):
|
1342
|
+
await background_sync_task
|
1343
|
+
background_sync_task = asyncio.create_task(self._background_sync())
|
1344
|
+
filesystem_events.append(await filesystem_event_queue.get())
|
1345
|
+
logger.debug('first event, debouncing...')
|
1346
|
+
# debounce
|
1347
|
+
await asyncio.sleep(EVENT_DEBOUNCE_SECONDS)
|
1348
|
+
logger.debug('collecting changes')
|
1349
|
+
while not filesystem_event_queue.empty():
|
1350
|
+
filesystem_events.append(filesystem_event_queue.get_nowait())
|
1351
|
+
for event in filesystem_events:
|
1352
|
+
logger.debug('non-unique event: %s', event)
|
1353
|
+
# remove duplicates
|
1354
|
+
events = [
|
1355
|
+
event
|
1356
|
+
for i, event
|
1357
|
+
in enumerate(filesystem_events)
|
1358
|
+
if _get_event_significant_path(event) not in (
|
1359
|
+
_get_event_significant_path(later_event)
|
1360
|
+
for later_event
|
1361
|
+
in filesystem_events[i+1:]
|
1362
|
+
)
|
1363
|
+
]
|
1364
|
+
for i, event in enumerate(events, start=1):
|
1365
|
+
logger.debug('unique event [%d/%d]: %s', i, len(events), event)
|
1366
|
+
await self._process_sync_event(event)
|
1367
|
+
except asyncio.CancelledError:
|
1368
|
+
logger.info('File sync interrupted')
|
1369
|
+
raise
|
1370
|
+
except OSError as ex:
|
1371
|
+
logger.error('File sync failed: %s', str(ex)) # noqa: TRY400
|
1372
|
+
logger.info('Will retry file sync in %s seconds', RETRY_DELAY_SECONDS)
|
1373
|
+
await asyncio.sleep(RETRY_DELAY_SECONDS)
|
1374
|
+
await self._check_background_tasks()
|
1375
|
+
except Exception:
|
1376
|
+
logger.exception('File sync failed')
|
1377
|
+
logger.info('Will retry file sync in %s seconds', RETRY_DELAY_SECONDS)
|
1378
|
+
await asyncio.sleep(RETRY_DELAY_SECONDS)
|
1379
|
+
await self._check_background_tasks()
|
1380
|
+
else:
|
1381
|
+
logger.info('File sync stopped')
|
1382
|
+
break
|
1383
|
+
finally:
|
1384
|
+
filesystem_watch_task.cancel()
|
1385
|
+
with contextlib.suppress(asyncio.CancelledError):
|
1386
|
+
await filesystem_watch_task
|
1387
|
+
if remote_sync_task is not None:
|
1388
|
+
remote_sync_task.cancel()
|
1389
|
+
with contextlib.suppress(asyncio.CancelledError):
|
1390
|
+
await remote_sync_task
|
1391
|
+
if background_sync_task is not None:
|
1392
|
+
background_sync_task.cancel()
|
1393
|
+
with contextlib.suppress(asyncio.CancelledError):
|
1394
|
+
await background_sync_task
|
1395
|
+
|
350
1396
|
async def _init_local_cache(self: 'Cli') -> None:
|
351
1397
|
self.local_source_directory.mkdir(parents=True, exist_ok=True)
|
352
1398
|
logger.debug('Listing remote items')
|
353
|
-
|
1399
|
+
try:
|
1400
|
+
listing = self.sftp_client.listdir_attr(self.cde_type['value']['remote-source-directory'])
|
1401
|
+
except FileNotFoundError as ex:
|
1402
|
+
raise InitializationError(f"Remote source directory {self.cde_type['value']['remote-source-directory']} does not exist") from ex
|
354
1403
|
|
355
1404
|
logger.info('Processing %d remote items...', len(listing))
|
356
1405
|
for file_info in rich.progress.track(
|
@@ -363,8 +1412,10 @@ class Cli:
|
|
363
1412
|
logger.info('Processing "%s"', file_info.filename)
|
364
1413
|
try:
|
365
1414
|
result = await self._process_remote_item(file_info)
|
366
|
-
except SubprocessError:
|
367
|
-
logger.
|
1415
|
+
except SubprocessError as ex:
|
1416
|
+
logger.error('Processing of remote item failed:\n%s: %s', type(ex).__name__, str(ex)) # noqa: TRY400
|
1417
|
+
except Exception:
|
1418
|
+
logger.exception('Processing of remote item failed')
|
368
1419
|
else:
|
369
1420
|
logger.info(result)
|
370
1421
|
|
@@ -374,7 +1425,7 @@ class Cli:
|
|
374
1425
|
if file_info.st_mode & stat.S_IFDIR:
|
375
1426
|
# check if .git exists
|
376
1427
|
try:
|
377
|
-
git_stat = self.sftp_client.stat(f'
|
1428
|
+
git_stat = self.sftp_client.stat(f"{self.cde_type['value']['remote-source-directory']}/{filename}/.git")
|
378
1429
|
except FileNotFoundError:
|
379
1430
|
pass
|
380
1431
|
else:
|
@@ -393,7 +1444,7 @@ class Cli:
|
|
393
1444
|
'-e', f'ssh -o ConnectTimeout=10 -o UserKnownHostsFile={self.known_hosts_file.name}',
|
394
1445
|
'--archive',
|
395
1446
|
'--checksum',
|
396
|
-
f'
|
1447
|
+
f"{self.cde_type['value']['remote-username']}@{self.hostname}:{self.cde_type['value']['remote-source-directory']}/{filename}/",
|
397
1448
|
str(self.local_source_directory / filename),
|
398
1449
|
],
|
399
1450
|
name='Copy remote directory',
|
@@ -405,7 +1456,7 @@ class Cli:
|
|
405
1456
|
executor=None,
|
406
1457
|
func=functools.partial(
|
407
1458
|
self.sftp_client.get,
|
408
|
-
remotepath=f'
|
1459
|
+
remotepath=f"{self.cde_type['value']['remote-source-directory']}/{filename}",
|
409
1460
|
localpath=str(self.local_source_directory / filename),
|
410
1461
|
),
|
411
1462
|
)
|
@@ -417,7 +1468,7 @@ class Cli:
|
|
417
1468
|
[
|
418
1469
|
'clone',
|
419
1470
|
'-q',
|
420
|
-
f'
|
1471
|
+
f"{self.cde_type['value']['remote-username']}@{self.hostname}:{self.cde_type['value']['remote-source-directory']}/{filename}",
|
421
1472
|
],
|
422
1473
|
name='Git clone',
|
423
1474
|
cwd=self.local_source_directory,
|
@@ -432,7 +1483,7 @@ class Cli:
|
|
432
1483
|
shlex.join([
|
433
1484
|
'git',
|
434
1485
|
'-C',
|
435
|
-
f'
|
1486
|
+
f"{self.cde_type['value']['remote-source-directory']}/{filename}",
|
436
1487
|
'config',
|
437
1488
|
'--get',
|
438
1489
|
'remote.origin.url',
|
@@ -456,77 +1507,40 @@ class Cli:
|
|
456
1507
|
)
|
457
1508
|
return f'Cloned repository "{filename}"'
|
458
1509
|
|
459
|
-
async def _start_sync(self: 'Cli') -> None:
|
460
|
-
logger.info('Starting file sync')
|
461
|
-
filesystem_event_queue = asyncio.Queue()
|
462
|
-
filesystem_watch_task = asyncio.create_task(
|
463
|
-
self._watch_filesystem(
|
464
|
-
queue=filesystem_event_queue,
|
465
|
-
),
|
466
|
-
)
|
467
|
-
if self.local_output_directory:
|
468
|
-
remote_sync_task = asyncio.create_task(
|
469
|
-
self._remote_sync(),
|
470
|
-
)
|
471
|
-
else:
|
472
|
-
remote_sync_task = None
|
473
|
-
background_sync_task = None
|
474
|
-
try:
|
475
|
-
while True:
|
476
|
-
filesystem_events = []
|
477
|
-
if background_sync_task is not None:
|
478
|
-
background_sync_task.cancel()
|
479
|
-
with contextlib.suppress(asyncio.CancelledError):
|
480
|
-
await background_sync_task
|
481
|
-
background_sync_task = asyncio.create_task(self._background_sync())
|
482
|
-
filesystem_events.append(await filesystem_event_queue.get())
|
483
|
-
logger.debug('first event, debouncing...')
|
484
|
-
# debounce
|
485
|
-
await asyncio.sleep(EVENT_DEBOUNCE_SECONDS)
|
486
|
-
logger.debug('collecting changes')
|
487
|
-
while not filesystem_event_queue.empty():
|
488
|
-
filesystem_events.append(filesystem_event_queue.get_nowait())
|
489
|
-
for event in filesystem_events:
|
490
|
-
logger.debug('non-unique event: %s', event)
|
491
|
-
# remove duplicates
|
492
|
-
events = [
|
493
|
-
event
|
494
|
-
for i, event
|
495
|
-
in enumerate(filesystem_events)
|
496
|
-
if _get_event_significant_path(event) not in (
|
497
|
-
_get_event_significant_path(later_event)
|
498
|
-
for later_event
|
499
|
-
in filesystem_events[i+1:]
|
500
|
-
)
|
501
|
-
]
|
502
|
-
for i, event in enumerate(events, start=1):
|
503
|
-
logger.debug('unique event [%d/%d]: %s', i, len(events), event)
|
504
|
-
await self._process_sync_event(event)
|
505
|
-
except asyncio.CancelledError:
|
506
|
-
logger.info('File sync interrupted')
|
507
|
-
raise
|
508
|
-
except Exception:
|
509
|
-
logger.exception('File sync failed')
|
510
|
-
else:
|
511
|
-
logger.info('File sync stopped')
|
512
|
-
finally:
|
513
|
-
filesystem_watch_task.cancel()
|
514
|
-
with contextlib.suppress(asyncio.CancelledError):
|
515
|
-
await filesystem_watch_task
|
516
|
-
if remote_sync_task is not None:
|
517
|
-
remote_sync_task.cancel()
|
518
|
-
with contextlib.suppress(asyncio.CancelledError):
|
519
|
-
await remote_sync_task
|
520
|
-
if background_sync_task is not None:
|
521
|
-
background_sync_task.cancel()
|
522
|
-
with contextlib.suppress(asyncio.CancelledError):
|
523
|
-
await background_sync_task
|
524
|
-
|
525
1510
|
async def _background_sync(self: 'Cli') -> None:
|
526
1511
|
logger.debug('Starting background sync')
|
527
1512
|
self.local_source_directory.mkdir(parents=True, exist_ok=True)
|
528
1513
|
with contextlib.suppress(OSError):
|
529
|
-
self.sftp_client.mkdir(
|
1514
|
+
self.sftp_client.mkdir(self.cde_type['value']['remote-source-directory'])
|
1515
|
+
file_sync_exclusions = self.cde_type['value'].get('file-sync-exclusions')
|
1516
|
+
if file_sync_exclusions is None:
|
1517
|
+
file_sync_exclusions = [
|
1518
|
+
'build-cache-*', # TODO: make exclusions configurable
|
1519
|
+
'dev-tool/config',
|
1520
|
+
'alembic.ini',
|
1521
|
+
'cypress/screenshots',
|
1522
|
+
'cypress/videos',
|
1523
|
+
'flow_api',
|
1524
|
+
'.git',
|
1525
|
+
'__pycache__',
|
1526
|
+
'.cache',
|
1527
|
+
'node_modules',
|
1528
|
+
'.venv',
|
1529
|
+
'bundle-content', # until https://app.clickup.com/t/86bxn0exx
|
1530
|
+
'cloudomation-fe/build',
|
1531
|
+
'devstack-self-service-portal/vite-cache',
|
1532
|
+
'devstack-self-service-portal/dist',
|
1533
|
+
'documentation/generator/generated',
|
1534
|
+
'version.py',
|
1535
|
+
'instantclient-basic-linux.x64.zip',
|
1536
|
+
'msodbcsql.deb',
|
1537
|
+
'auth/report',
|
1538
|
+
'cloudomation-fe/.env',
|
1539
|
+
'cloudomation/tmp_git_task',
|
1540
|
+
'cloudomation/tmp',
|
1541
|
+
'cloudomation/notifications',
|
1542
|
+
'documentation/versioned_docs',
|
1543
|
+
]
|
530
1544
|
try:
|
531
1545
|
await run_subprocess(
|
532
1546
|
'rsync',
|
@@ -534,43 +1548,27 @@ class Cli:
|
|
534
1548
|
'-e', f'ssh -o ConnectTimeout=10 -o UserKnownHostsFile={self.known_hosts_file.name}',
|
535
1549
|
'--archive',
|
536
1550
|
'--delete',
|
537
|
-
'--
|
538
|
-
|
539
|
-
'--
|
540
|
-
|
541
|
-
|
542
|
-
|
543
|
-
|
544
|
-
|
545
|
-
'--exclude', '.cache',
|
546
|
-
'--exclude', 'node_modules',
|
547
|
-
'--exclude', '.venv',
|
548
|
-
'--exclude', 'bundle-content', # until https://app.clickup.com/t/86bxn0exx
|
549
|
-
'--exclude', 'cloudomation-fe/build',
|
550
|
-
'--exclude', 'devstack-self-service-portal/vite-cache',
|
551
|
-
'--exclude', 'devstack-self-service-portal/dist',
|
552
|
-
'--exclude', 'documentation/generator/generated',
|
553
|
-
'--exclude', 'version.py',
|
554
|
-
'--exclude', 'instantclient-basic-linux.x64.zip',
|
555
|
-
'--exclude', 'msodbcsql.deb',
|
556
|
-
'--exclude', 'auth/report',
|
557
|
-
'--exclude', 'cloudomation-fe/.env',
|
558
|
-
'--exclude', 'cloudomation/tmp_git_task',
|
559
|
-
'--exclude', 'cloudomation/tmp',
|
560
|
-
'--exclude', 'cloudomation/notifications',
|
561
|
-
'--exclude', 'documentation/versioned_docs',
|
1551
|
+
'--checksum', # do not compare timestamps. new CDE template will have all timestamps new,
|
1552
|
+
# but we only want to copy if the content is different
|
1553
|
+
'--ignore-times', # we also use this to avoid syncing timestamps on all directories
|
1554
|
+
*itertools.chain.from_iterable([
|
1555
|
+
('--exclude', exclusion)
|
1556
|
+
for exclusion
|
1557
|
+
in file_sync_exclusions
|
1558
|
+
]),
|
562
1559
|
'--human-readable',
|
563
|
-
'--
|
1560
|
+
'--verbose',
|
564
1561
|
f'{self.local_source_directory}/',
|
565
|
-
f'
|
1562
|
+
f"{self.cde_type['value']['remote-username']}@{self.hostname}:{self.cde_type['value']['remote-source-directory']}",
|
566
1563
|
],
|
567
1564
|
name='Background sync',
|
1565
|
+
print_to_debug_log=True,
|
568
1566
|
)
|
569
1567
|
except asyncio.CancelledError:
|
570
1568
|
logger.debug('Background sync interrupted')
|
571
1569
|
raise
|
572
1570
|
except SubprocessError as ex:
|
573
|
-
logger.error('Background sync failed: %s', ex) # noqa: TRY400
|
1571
|
+
logger.error('Background sync failed:\n%s: %s', type(ex).__name__, str(ex)) # noqa: TRY400
|
574
1572
|
except Exception:
|
575
1573
|
logger.exception('Background sync failed')
|
576
1574
|
else:
|
@@ -579,7 +1577,7 @@ class Cli:
|
|
579
1577
|
async def _reverse_background_sync(self: 'Cli') -> None:
|
580
1578
|
logger.debug('Starting reverse background sync')
|
581
1579
|
with contextlib.suppress(OSError):
|
582
|
-
self.sftp_client.mkdir(
|
1580
|
+
self.sftp_client.mkdir(self.cde_type['value']['remote-output-directory'])
|
583
1581
|
self.local_output_directory.mkdir(parents=True, exist_ok=True)
|
584
1582
|
try:
|
585
1583
|
stdout, stderr = await run_subprocess(
|
@@ -589,8 +1587,7 @@ class Cli:
|
|
589
1587
|
'--archive',
|
590
1588
|
'--exclude', '__pycache__',
|
591
1589
|
'--human-readable',
|
592
|
-
'
|
593
|
-
f'{REMOTE_USERNAME}@{self.hostname}:{REMOTE_OUTPUT_DIRECTORY}/',
|
1590
|
+
f"{self.cde_type['value']['remote-username']}@{self.hostname}:{self.cde_type['value']['remote-output-directory']}/",
|
594
1591
|
str(self.local_output_directory),
|
595
1592
|
],
|
596
1593
|
name='Reverse background sync',
|
@@ -598,7 +1595,9 @@ class Cli:
|
|
598
1595
|
except asyncio.CancelledError:
|
599
1596
|
logger.debug('Reverse background sync interrupted')
|
600
1597
|
raise
|
601
|
-
except SubprocessError:
|
1598
|
+
except SubprocessError as ex:
|
1599
|
+
logger.error('Reverse background sync failed:\n%s: %s', type(ex).__name__, str(ex)) # noqa: TRY400
|
1600
|
+
except Exception:
|
602
1601
|
logger.exception('Reverse background sync failed')
|
603
1602
|
else:
|
604
1603
|
logger.debug('Reverse background sync done')
|
@@ -633,7 +1632,7 @@ class Cli:
|
|
633
1632
|
async def _process_sync_event(self: 'Cli', event: watchdog.events.FileSystemEvent) -> None:
|
634
1633
|
local_path = pathlib.Path(event.src_path)
|
635
1634
|
relative_path = local_path.relative_to(self.local_source_directory)
|
636
|
-
remote_path = f'
|
1635
|
+
remote_path = f"{self.cde_type['value']['remote-source-directory']}/{relative_path}"
|
637
1636
|
if isinstance(event, watchdog.events.DirCreatedEvent):
|
638
1637
|
await self._remote_directory_create(remote_path)
|
639
1638
|
elif isinstance(event, watchdog.events.DirDeletedEvent):
|
@@ -649,7 +1648,7 @@ class Cli:
|
|
649
1648
|
elif isinstance(event, watchdog.events.FileMovedEvent):
|
650
1649
|
dest_local_path = pathlib.Path(event.dest_path)
|
651
1650
|
dest_relative_path = dest_local_path.relative_to(self.local_source_directory)
|
652
|
-
dest_remote_path = f'
|
1651
|
+
dest_remote_path = f"{self.cde_type['value']['remote-source-directory']}/{dest_relative_path}"
|
653
1652
|
stat = dest_local_path.stat()
|
654
1653
|
times = (stat.st_atime, stat.st_mtime)
|
655
1654
|
await self._remote_file_move(remote_path, dest_remote_path, times)
|
@@ -697,147 +1696,126 @@ class Cli:
|
|
697
1696
|
self.sftp_client.rename(remote_path, dest_remote_path)
|
698
1697
|
self.sftp_client.utime(dest_remote_path, times)
|
699
1698
|
|
700
|
-
|
701
|
-
|
702
|
-
|
703
|
-
|
704
|
-
|
705
|
-
|
706
|
-
|
707
|
-
'-o', f'UserKnownHostsFile={self.known_hosts_file.name}',
|
708
|
-
'-NT',
|
709
|
-
f'{REMOTE_USERNAME}@{self.hostname}',
|
710
|
-
'-L', '8443:localhost:443', # TODO: make ports configurable
|
711
|
-
'-L', '5678:localhost:5678',
|
712
|
-
'-L', '6678:localhost:6678',
|
713
|
-
'-L', '7678:localhost:7678',
|
714
|
-
'-L', '8678:localhost:8678',
|
715
|
-
'-L', '3000:localhost:3000',
|
716
|
-
'-L', '2022:localhost:2022',
|
717
|
-
],
|
718
|
-
name='Port forwarding',
|
719
|
-
capture_stdout=False,
|
720
|
-
)
|
721
|
-
except asyncio.CancelledError:
|
722
|
-
logger.info('Port forwarding interrupted')
|
723
|
-
raise
|
724
|
-
except SubprocessError:
|
725
|
-
logger.exception('Port forwarding failed')
|
1699
|
+
#####
|
1700
|
+
##### LOGS
|
1701
|
+
#####
|
1702
|
+
async def _toggle_logs(self: 'Cli') -> None:
|
1703
|
+
await self._update_cde_list()
|
1704
|
+
if self.logs_task is None:
|
1705
|
+
await self._start_logs()
|
726
1706
|
else:
|
727
|
-
|
728
|
-
|
1707
|
+
await self._stop_logs()
|
729
1708
|
|
730
1709
|
async def _start_logs(self: 'Cli') -> None:
|
731
|
-
|
732
|
-
|
733
|
-
|
734
|
-
|
735
|
-
|
736
|
-
|
737
|
-
|
738
|
-
|
739
|
-
|
740
|
-
|
741
|
-
)
|
742
|
-
try:
|
743
|
-
stdout_get = asyncio.create_task(stdout_queue.get())
|
744
|
-
stderr_get = asyncio.create_task(stderr_queue.get())
|
745
|
-
while True:
|
746
|
-
done, pending = await asyncio.wait(
|
747
|
-
{stdout_get, stderr_get},
|
748
|
-
return_when=asyncio.FIRST_COMPLETED,
|
749
|
-
)
|
750
|
-
if stdout_get in done:
|
751
|
-
stdout = await stdout_get
|
752
|
-
if stdout is not None:
|
753
|
-
self.console.print(rich.markup.escape(stdout.strip()), style='default on grey23', justify='left')
|
754
|
-
stdout_get = asyncio.create_task(stdout_queue.get())
|
755
|
-
if stderr_get in done:
|
756
|
-
stderr = await stderr_get
|
757
|
-
if stderr is not None:
|
758
|
-
self.console.print(rich.markup.escape(stderr.strip()), style='default on red', justify='left')
|
759
|
-
stderr_get = asyncio.create_task(stderr_queue.get())
|
760
|
-
except asyncio.CancelledError:
|
761
|
-
logger.info('Following logs interrupted')
|
762
|
-
raise
|
763
|
-
except Exception:
|
764
|
-
logger.exception('Following logs failed')
|
765
|
-
else:
|
766
|
-
logger.info('Stopped following logs')
|
767
|
-
finally:
|
768
|
-
stream_task.cancel()
|
769
|
-
with contextlib.suppress(asyncio.CancelledError):
|
770
|
-
await stream_task
|
1710
|
+
if not self.cde:
|
1711
|
+
logger.error('No CDE is selected. Cannot follow logs.')
|
1712
|
+
return
|
1713
|
+
if not self.is_cde_running:
|
1714
|
+
logger.error('CDE is not running. Cannot follow logs.')
|
1715
|
+
return
|
1716
|
+
if self.ssh_client is None:
|
1717
|
+
logger.error('Not connected to CDE. Cannot follow logs.')
|
1718
|
+
return
|
1719
|
+
self.logs_task = asyncio.create_task(self._bg_logs())
|
771
1720
|
|
1721
|
+
async def _stop_logs(self: 'Cli') -> None:
|
1722
|
+
self.logs_task.cancel()
|
1723
|
+
self.logs_task = None
|
772
1724
|
|
773
|
-
def
|
774
|
-
self: 'Cli',
|
775
|
-
stdout_queue: asyncio.Queue,
|
776
|
-
stderr_queue: asyncio.Queue,
|
777
|
-
) -> None:
|
778
|
-
ssh_stdin, ssh_stdout, ssh_stderr = self.ssh_client.exec_command(
|
779
|
-
'cd /home/devstack-user/starflows/research/dev-tool && . dev.sh logs',
|
780
|
-
get_pty=False,
|
781
|
-
timeout=0,
|
782
|
-
)
|
783
|
-
ssh_stdin.close()
|
784
|
-
have_stdout = False
|
785
|
-
have_stderr = False
|
1725
|
+
async def _bg_logs(self: 'Cli') -> None:
|
786
1726
|
while True:
|
1727
|
+
logger.info('Following logs')
|
787
1728
|
try:
|
788
|
-
|
789
|
-
|
790
|
-
|
1729
|
+
async with asyncssh.connect(
|
1730
|
+
self.hostname,
|
1731
|
+
connect_timeout=10,
|
1732
|
+
known_hosts=self.known_hosts_file.name,
|
1733
|
+
username=self.cde_type['value']['remote-username'],
|
1734
|
+
term_type=os.environ.get('TERM'),
|
1735
|
+
) as conn:
|
1736
|
+
await conn.run(input='dev.sh logs\n', stdout=sys.stdout, stderr=sys.stderr, recv_eof=False)
|
1737
|
+
except asyncio.CancelledError:
|
1738
|
+
logger.info('Following logs interrupted')
|
1739
|
+
raise
|
1740
|
+
except Exception:
|
1741
|
+
logger.exception('Following logs failed')
|
1742
|
+
logger.info('Will retry following logs in %s seconds', RETRY_DELAY_SECONDS)
|
1743
|
+
await asyncio.sleep(RETRY_DELAY_SECONDS)
|
1744
|
+
await self._check_background_tasks()
|
791
1745
|
else:
|
792
|
-
|
1746
|
+
logger.info('Stopped following logs')
|
1747
|
+
break
|
1748
|
+
|
1749
|
+
#####
|
1750
|
+
##### TERMINAL
|
1751
|
+
#####
|
1752
|
+
async def _open_terminal(self: 'Cli') -> None:
|
1753
|
+
await self._update_cde_list()
|
1754
|
+
if not self.cde:
|
1755
|
+
logger.error('No CDE is selected. Cannot open terminal.')
|
1756
|
+
return
|
1757
|
+
if not self.is_cde_running:
|
1758
|
+
logger.error('CDE is not running. Cannot open terminal.')
|
1759
|
+
return
|
1760
|
+
if self.ssh_client is None:
|
1761
|
+
logger.error('Not connected to CDE. Cannot open terminal.')
|
1762
|
+
return
|
1763
|
+
while True:
|
1764
|
+
logger.info('Opening interactive terminal (press CTRL+D or enter "exit" to close)')
|
1765
|
+
await self._reset_keyboard()
|
1766
|
+
_fd = sys.stdin.fileno()
|
1767
|
+
_tcattr = termios.tcgetattr(_fd)
|
1768
|
+
tty.setcbreak(_fd)
|
793
1769
|
try:
|
794
|
-
|
795
|
-
|
796
|
-
|
1770
|
+
terminal_size = shutil.get_terminal_size()
|
1771
|
+
async with asyncssh.connect(
|
1772
|
+
self.hostname,
|
1773
|
+
connect_timeout=10,
|
1774
|
+
known_hosts=self.known_hosts_file.name,
|
1775
|
+
username=self.cde_type['value']['remote-username'],
|
1776
|
+
term_type=os.environ.get('TERM'),
|
1777
|
+
term_size=(terminal_size.columns, terminal_size.lines),
|
1778
|
+
) as conn:
|
1779
|
+
try:
|
1780
|
+
async with conn.create_process(
|
1781
|
+
stdin=os.dup(sys.stdin.fileno()),
|
1782
|
+
stdout=os.dup(sys.stdout.fileno()),
|
1783
|
+
stderr=os.dup(sys.stderr.fileno()),
|
1784
|
+
) as self.terminal_process:
|
1785
|
+
await self.terminal_process.wait()
|
1786
|
+
finally:
|
1787
|
+
self.terminal_process = None
|
1788
|
+
except asyncio.CancelledError:
|
1789
|
+
logger.info('Interactive terminal interrupted')
|
1790
|
+
raise
|
1791
|
+
except Exception:
|
1792
|
+
logger.exception('Interactive terminal failed')
|
1793
|
+
logger.info('Will retry interactive terminal in %s seconds', RETRY_DELAY_SECONDS)
|
1794
|
+
await asyncio.sleep(RETRY_DELAY_SECONDS)
|
1795
|
+
await self._check_background_tasks()
|
797
1796
|
else:
|
798
|
-
|
799
|
-
if have_stdout and stdout:
|
800
|
-
self.loop.call_soon_threadsafe(stdout_queue.put_nowait, stdout)
|
801
|
-
if have_stderr and stderr:
|
802
|
-
self.loop.call_soon_threadsafe(stderr_queue.put_nowait, stderr)
|
803
|
-
if have_stdout and not stdout and have_stderr and not stderr:
|
1797
|
+
logger.info('Interactive terminal closed')
|
804
1798
|
break
|
805
|
-
|
806
|
-
|
807
|
-
|
808
|
-
self.loop.call_soon_threadsafe(stderr_queue.put_nowait, None)
|
1799
|
+
finally:
|
1800
|
+
termios.tcsetattr(_fd, termios.TCSADRAIN, _tcattr)
|
1801
|
+
await self._setup_keyboard()
|
809
1802
|
|
1803
|
+
async def _setup_keyboard(self: 'Cli') -> None:
|
1804
|
+
self._fd = sys.stdin.fileno()
|
1805
|
+
self._tcattr = termios.tcgetattr(self._fd)
|
1806
|
+
tty.setcbreak(self._fd)
|
1807
|
+
def on_stdin() -> None:
|
1808
|
+
self.loop.call_soon_threadsafe(self.key_queue.put_nowait, sys.stdin.read(1))
|
1809
|
+
self.loop.add_reader(sys.stdin, on_stdin)
|
810
1810
|
|
811
|
-
def
|
812
|
-
|
813
|
-
|
814
|
-
'-H', '--hostname',
|
815
|
-
required=True,
|
816
|
-
help='the IP or hostname of the RDE',
|
817
|
-
)
|
818
|
-
parser.add_argument(
|
819
|
-
'-s', '--source-directory',
|
820
|
-
required=True,
|
821
|
-
help='a local directory where the sources from the RDE are cached',
|
822
|
-
)
|
823
|
-
parser.add_argument(
|
824
|
-
'-o', '--output-directory',
|
825
|
-
help='a local directory where artifacts created on the RDE are stored',
|
826
|
-
)
|
827
|
-
parser.add_argument(
|
828
|
-
'-v', '--verbose',
|
829
|
-
action='store_true',
|
830
|
-
help='enable debug logging',
|
831
|
-
)
|
832
|
-
parser.add_argument(
|
833
|
-
'-V', '--version',
|
834
|
-
action='version',
|
835
|
-
version=f'Cloudomation devstack-cli {version.MAJOR}+{version.BRANCH_NAME}.{version.BUILD_DATE}.{version.SHORT_SHA}',
|
836
|
-
)
|
837
|
-
args = parser.parse_args()
|
1811
|
+
async def _reset_keyboard(self: 'Cli') -> None:
|
1812
|
+
self.loop.remove_reader(sys.stdin)
|
1813
|
+
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._tcattr)
|
838
1814
|
|
839
|
-
|
840
|
-
|
1815
|
+
def main() -> None:
|
1816
|
+
cli = Cli()
|
1817
|
+
signal.signal(signal.SIGINT, functools.partial(sigint_handler, cli=cli))
|
1818
|
+
with contextlib.suppress(asyncio.CancelledError):
|
841
1819
|
asyncio.run(cli.run())
|
842
1820
|
logger.info('Bye!')
|
843
1821
|
|