devstack-cli 9.0.0__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2024 Starflows OG
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.1
2
+ Name: devstack-cli
3
+ Version: 9.0.0
4
+ Summary: Command-line access to Remote Development Environments (RDEs) created by Cloudomation DevStack
5
+ Author-email: Stefan Mückstein <stefan@cloudomation.com>
6
+ Project-URL: Homepage, https://cloudomation.com/
7
+ Project-URL: Documentation, https://docs.cloudomation.com/devstack/
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: paramiko
15
+ Requires-Dist: watchdog
16
+ Requires-Dist: rich
17
+
18
+ # DevStack CLI
19
+
20
+ The `devstack-cli` provides command-line access to Remote Development Environments (RDEs) created by Cloudomation DevStack. Learn more about [Cloudomation DevStack](https://docs.cloudomation.com/devstack/docs/overview-and-concept).
21
+
22
+ ## Installation
23
+
24
+ The following binaries must be installed to be able to use `devstack-cli`:
25
+
26
+ * `ssh`
27
+ * `ssh-keyscan`
28
+ * `rsync`
29
+ * `git`
30
+
31
+ On Debian/Ubuntu the packages can be installed with
32
+
33
+ ```
34
+ apt install openssh-client rsync git
35
+ ```
36
+
37
+ Then you can install `devstack-cli` by running
38
+
39
+ ```
40
+ python -m pip install devstack-cli
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```
46
+ usage: devstack-cli [-h] -H HOSTNAME -s SOURCE_DIRECTORY [-o OUTPUT_DIRECTORY] [-v]
47
+ ```
48
+
49
+ You need to specify the hostname/IP of the RDE created by Cloudomation DevStack as well as the path to a directory where the sources will be cached locally. Optionally you can specify an output directory where artifacts created on the RDE will be stored locally.
50
+ The `-v` switch enables debug logging.
51
+
52
+ ## Support
53
+
54
+ `devstack-cli` is part of Cloudomation DevStack and support is provided to you with an active subscription.
55
+
56
+ ## Authors and acknowledgment
57
+
58
+ Cloudomation actively maintains the `devstack-cli` command line tool as part of Cloudomation DevStack
59
+
60
+ ## License
61
+
62
+ Copyright (c) 2024 Starflows OG
63
+
64
+ Permission is hereby granted, free of charge, to any person obtaining a copy
65
+ of this software and associated documentation files (the "Software"), to deal
66
+ in the Software without restriction, including without limitation the rights
67
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
68
+ copies of the Software, and to permit persons to whom the Software is
69
+ furnished to do so, subject to the following conditions:
70
+
71
+ The above copyright notice and this permission notice shall be included in all
72
+ copies or substantial portions of the Software.
73
+
74
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
75
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
76
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
77
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
78
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
79
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
80
+ SOFTWARE.
@@ -0,0 +1,63 @@
1
+ # DevStack CLI
2
+
3
+ The `devstack-cli` provides command-line access to Remote Development Environments (RDEs) created by Cloudomation DevStack. Learn more about [Cloudomation DevStack](https://docs.cloudomation.com/devstack/docs/overview-and-concept).
4
+
5
+ ## Installation
6
+
7
+ The following binaries must be installed to be able to use `devstack-cli`:
8
+
9
+ * `ssh`
10
+ * `ssh-keyscan`
11
+ * `rsync`
12
+ * `git`
13
+
14
+ On Debian/Ubuntu the packages can be installed with
15
+
16
+ ```
17
+ apt install openssh-client rsync git
18
+ ```
19
+
20
+ Then you can install `devstack-cli` by running
21
+
22
+ ```
23
+ python -m pip install devstack-cli
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```
29
+ usage: devstack-cli [-h] -H HOSTNAME -s SOURCE_DIRECTORY [-o OUTPUT_DIRECTORY] [-v]
30
+ ```
31
+
32
+ You need to specify the hostname/IP of the RDE created by Cloudomation DevStack as well as the path to a directory where the sources will be cached locally. Optionally you can specify an output directory where artifacts created on the RDE will be stored locally.
33
+ The `-v` switch enables debug logging.
34
+
35
+ ## Support
36
+
37
+ `devstack-cli` is part of Cloudomation DevStack and support is provided to you with an active subscription.
38
+
39
+ ## Authors and acknowledgment
40
+
41
+ Cloudomation actively maintains the `devstack-cli` command line tool as part of Cloudomation DevStack
42
+
43
+ ## License
44
+
45
+ Copyright (c) 2024 Starflows OG
46
+
47
+ Permission is hereby granted, free of charge, to any person obtaining a copy
48
+ of this software and associated documentation files (the "Software"), to deal
49
+ in the Software without restriction, including without limitation the rights
50
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
51
+ copies of the Software, and to permit persons to whom the Software is
52
+ furnished to do so, subject to the following conditions:
53
+
54
+ The above copyright notice and this permission notice shall be included in all
55
+ copies or substantial portions of the Software.
56
+
57
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
58
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
59
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
60
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
61
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
62
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
63
+ SOFTWARE.
@@ -0,0 +1,89 @@
1
+ [project]
2
+ name = "devstack-cli"
3
+ version = "9.0.0"
4
+ authors = [
5
+ { name="Stefan Mückstein", email="stefan@cloudomation.com" },
6
+ ]
7
+ description = "Command-line access to Remote Development Environments (RDEs) created by Cloudomation DevStack"
8
+ readme = "README.md"
9
+ requires-python = ">=3.8"
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Operating System :: OS Independent",
14
+ ]
15
+ dependencies = [
16
+ "paramiko",
17
+ "watchdog",
18
+ "rich",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://cloudomation.com/"
23
+ Documentation = "https://docs.cloudomation.com/devstack/"
24
+
25
+ [build-system]
26
+ requires = ["setuptools"]
27
+ build-backend = "setuptools.build_meta"
28
+
29
+ [tool.setuptools]
30
+ package-dir = {"" = "src"}
31
+
32
+ [project.scripts]
33
+ devstack-cli = "cli:main"
34
+
35
+ [tool.ruff]
36
+ select = ["ALL"]
37
+ ignore = ["E501", "ERA001", "TRY003", "EM102", "EM101", "D100", "ARG002", "D300", "D205", "D212", "D", "TRY301", "PLC1901", "BLE001", "PERF203", "FA100", "ANN401", "PLR0913", "ANN002", "ANN003", "RET504", "PLR2004"]
38
+
39
+ # Allow autofix for all enabled rules (when `--fix`) is provided.
40
+ fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"]
41
+ unfixable = ["D", "Q001", "Q002", "F841"]
42
+
43
+ # Exclude a variety of commonly ignored directories.
44
+ exclude = [
45
+ ".bzr",
46
+ ".direnv",
47
+ ".eggs",
48
+ ".git",
49
+ ".hg",
50
+ ".mypy_cache",
51
+ ".nox",
52
+ ".pants.d",
53
+ ".pytype",
54
+ ".ruff_cache",
55
+ ".svn",
56
+ ".tox",
57
+ ".venv",
58
+ "__pypackages__",
59
+ "_build",
60
+ "buck-out",
61
+ "build",
62
+ "dist",
63
+ "node_modules",
64
+ "venv",
65
+ "tests/",
66
+ "tests_sandbox/",
67
+ "alembic/",
68
+ "map_async_iterator.py",
69
+ "flow_api/",
70
+ ]
71
+ per-file-ignores = {}
72
+
73
+ # Same as Black.
74
+ line-length = 88
75
+
76
+ # Allow unused variables when underscore-prefixed.
77
+ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
78
+
79
+ # Assume Python 3.8.
80
+ target-version = "py38"
81
+
82
+ [tool.ruff.mccabe]
83
+ # Unlike Flake8, default to a complexity level of 10.
84
+ max-complexity = 10
85
+
86
+ [tool.ruff.flake8-quotes]
87
+ inline-quotes = "single"
88
+ multiline-quotes = "single"
89
+ docstring-quotes = "double"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,846 @@
1
+ import argparse
2
+ import asyncio
3
+ import contextlib
4
+ import functools
5
+ import io
6
+ import logging
7
+ import pathlib
8
+ import shlex
9
+ import stat
10
+ import string
11
+ import sys
12
+ import tempfile
13
+ import termios
14
+ import time
15
+ import tty
16
+ import typing
17
+
18
+ import paramiko
19
+ import paramiko.sftp_client
20
+ import rich.console
21
+ import rich.logging
22
+ import rich.markup
23
+ import rich.progress
24
+ import version
25
+ import watchdog.events
26
+ import watchdog.observers
27
+
28
+ REMOTE_USERNAME = 'devstack-user'
29
+ REMOTE_SOURCE_DIRECTORY = '/home/devstack-user/starflows'
30
+ REMOTE_OUTPUT_DIRECTORY = '/home/devstack-user/starflows-output'
31
+ EVENT_DEBOUNCE_SECONDS = .5
32
+
33
+ logging.basicConfig(level=logging.INFO, handlers=[rich.logging.RichHandler()], format='%(message)s')
34
+ logger = logging.getLogger('cli')
35
+
36
+ class SubprocessError(Exception):
37
+ """A subprocess call returned with non-zero."""
38
+
39
+
40
+ class FileSystemEventHandlerToQueue(watchdog.events.FileSystemEventHandler):
41
+ def __init__(
42
+ self: 'FileSystemEventHandlerToQueue',
43
+ queue: asyncio.Queue,
44
+ loop: asyncio.BaseEventLoop,
45
+ *args,
46
+ **kwargs,
47
+ ) -> None:
48
+ self._loop = loop
49
+ self._queue = queue
50
+ super(*args, **kwargs)
51
+
52
+ def on_any_event(
53
+ self: 'FileSystemEventHandlerToQueue',
54
+ event: watchdog.events.FileSystemEvent,
55
+ ) -> None:
56
+ if event.event_type in (
57
+ watchdog.events.EVENT_TYPE_OPENED,
58
+ watchdog.events.EVENT_TYPE_CLOSED,
59
+ ):
60
+ return
61
+ if event.event_type == watchdog.events.EVENT_TYPE_MODIFIED and event.is_directory:
62
+ return
63
+ if '/.git' in event.src_path:
64
+ return
65
+ if hasattr(event, 'dest_path') and '/.git' in event.dest_path:
66
+ return
67
+ self._loop.call_soon_threadsafe(self._queue.put_nowait, event)
68
+
69
+
70
+ async def run_subprocess(
71
+ program: str,
72
+ args: str,
73
+ *,
74
+ name: str,
75
+ cwd: typing.Optional[pathlib.Path] = None,
76
+ env: typing.Optional[dict] = None,
77
+ capture_stdout: bool = True,
78
+ print_stdout: bool = True,
79
+ capture_stderr: bool = True,
80
+ print_stderr: bool = True,
81
+ ) -> None:
82
+ args_str = ' '.join(args)
83
+ process = await asyncio.create_subprocess_exec(
84
+ program,
85
+ *args,
86
+ cwd=cwd,
87
+ stdin=asyncio.subprocess.DEVNULL,
88
+ stdout=asyncio.subprocess.PIPE if capture_stdout else asyncio.subprocess.DEVNULL,
89
+ stderr=asyncio.subprocess.PIPE if capture_stderr else asyncio.subprocess.DEVNULL,
90
+ env=env,
91
+ )
92
+ stdout = b''
93
+ stderr = b''
94
+ try:
95
+ if not capture_stdout and not capture_stderr:
96
+ await process.wait()
97
+ else:
98
+ tasks = set()
99
+ if capture_stdout:
100
+ stdout_readline = asyncio.create_task(process.stdout.readline())
101
+ tasks.add(stdout_readline)
102
+ if capture_stderr:
103
+ stderr_readline = asyncio.create_task(process.stderr.readline())
104
+ tasks.add(stderr_readline)
105
+ while process.returncode is None:
106
+ done, pending = await asyncio.wait(
107
+ tasks,
108
+ return_when=asyncio.FIRST_COMPLETED,
109
+ )
110
+ if capture_stdout and stdout_readline in done:
111
+ stdout_line = await stdout_readline
112
+ if print_stdout and stdout_line.decode().strip():
113
+ logger.info('%s: %s', name, stdout_line.decode().strip())
114
+ stdout += stdout_line + b'\n'
115
+ stdout_readline = asyncio.create_task(process.stdout.readline())
116
+ pending.add(stdout_readline)
117
+ if capture_stderr and stderr_readline in done:
118
+ stderr_line = await stderr_readline
119
+ if print_stderr and stderr_line.decode().strip():
120
+ logger.warning('%s: %s', name, stderr_line.decode().strip())
121
+ stderr += stderr_line + b'\n'
122
+ stderr_readline = asyncio.create_task(process.stderr.readline())
123
+ pending.add(stderr_readline)
124
+ tasks = pending
125
+ finally:
126
+ if process.returncode is None:
127
+ logger.debug('Terminating "%s %s"', program, args_str)
128
+ process.terminate()
129
+ try:
130
+ await asyncio.wait_for(process.wait(), timeout=3)
131
+ except asyncio.TimeoutError:
132
+ logger.info('Killing "%s %s"', program, args_str)
133
+ process.kill()
134
+ await asyncio.wait_for(process.wait(), timeout=3)
135
+ if process.returncode:
136
+ if cwd is None:
137
+ msg = f'Command "{program} {args_str}" failed with returncode {process.returncode}.'
138
+ else:
139
+ msg = f'Command "{program} {args_str}" in "{cwd}" failed with returncode {process.returncode}.'
140
+ raise SubprocessError(msg)
141
+ logger.debug(
142
+ 'Command "%s %s" succeeded.',
143
+ program,
144
+ args_str,
145
+ )
146
+ return stdout, stderr
147
+
148
+
149
+ def _get_event_significant_path(event: watchdog.events.FileSystemEvent) -> str:
150
+ if hasattr(event, 'dest_path'):
151
+ return event.dest_path
152
+ return event.src_path
153
+
154
+
155
+ def is_relative_to(self: pathlib.Path, other: pathlib.Path) -> bool:
156
+ return other == self or other in self.parents
157
+
158
+
159
+ class Cli:
160
+ def __init__(self: 'Cli', args: argparse.Namespace) -> None:
161
+ rich.print(f'Cloudomation devstack-cli {version.MAJOR}+{version.BRANCH_NAME}.{version.BUILD_DATE}.{version.SHORT_SHA}')
162
+ self.hostname = args.hostname
163
+ self.local_source_directory = pathlib.Path(args.source_directory)
164
+
165
+ self.local_output_directory = pathlib.Path(args.output_directory) if args.output_directory else None
166
+ if (
167
+ self.local_output_directory is not None
168
+ and (
169
+ is_relative_to(self.local_source_directory, self.local_output_directory)
170
+ or is_relative_to(self.local_output_directory, self.local_source_directory)
171
+ )
172
+ ):
173
+ logger.error('Source-directory and output-directory must not overlap!')
174
+ sys.exit(1)
175
+ self.ssh_client = None
176
+ self.sftp_client = None
177
+ self.filesystem_watch_task = None
178
+ self.known_hosts_file = None
179
+ self.console = rich.console.Console()
180
+ if args.verbose:
181
+ logger.setLevel(logging.DEBUG)
182
+
183
+ 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
+ try:
188
+ await self._connect_to_rde()
189
+ await self._init_local_cache()
190
+ sync_task = asyncio.create_task(self._start_sync())
191
+ port_forwarding_task = asyncio.create_task(self._start_port_forwarding())
192
+ logs_task = None
193
+ await self._setup_keyboard(key_queue)
194
+ try:
195
+ logger.info('Ready!')
196
+ key_queue.put_nowait('h')
197
+ while True:
198
+ key_press = await key_queue.get()
199
+ # check status
200
+ if sync_task is not None and sync_task.done():
201
+ sync_task = None
202
+ if port_forwarding_task is not None and port_forwarding_task.done():
203
+ port_forwarding_task = None
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()
285
+
286
+ async def _setup_keyboard(self: 'Cli', queue: asyncio.Queue) -> None:
287
+ self._fd = sys.stdin.fileno()
288
+ self._tcattr = termios.tcgetattr(self._fd)
289
+ tty.setcbreak(self._fd)
290
+ def on_stdin() -> None:
291
+ self.loop.call_soon_threadsafe(queue.put_nowait, sys.stdin.read(1))
292
+ self.loop.add_reader(sys.stdin, on_stdin)
293
+
294
+ async def _reset_keyboard(self: 'Cli') -> None:
295
+ termios.tcsetattr(self._fd, termios.TCSADRAIN, self._tcattr)
296
+ self.loop.remove_reader(sys.stdin)
297
+
298
+ async def _prepare_known_hosts(self: 'Cli') -> None:
299
+ self.known_hosts_file = tempfile.NamedTemporaryFile(delete=False)
300
+ logger.info('Writing temporary known_hosts file "%s"', self.known_hosts_file.name)
301
+ logger.debug('Scanning hostkeys of "%s"', self.hostname)
302
+ try:
303
+ stdout, stderr = await run_subprocess(
304
+ 'ssh-keyscan',
305
+ [
306
+ self.hostname,
307
+ ],
308
+ name='ssh-keyscan',
309
+ print_stdout=False,
310
+ print_stderr=False,
311
+ )
312
+ except SubprocessError as ex:
313
+ logger.error('%s Failed to fetch hostkeys. Is you RDE running?', ex) # noqa: TRY400
314
+ sys.exit(1)
315
+ self.known_hosts_file.write(stdout)
316
+ with contextlib.suppress(FileNotFoundError):
317
+ self.known_hosts_file.write(pathlib.Path('~/.ssh/known_hosts').expanduser().read_bytes())
318
+ self.known_hosts_file.close()
319
+
320
+ async def _cleanup_known_hosts_file(self: 'Cli') -> None:
321
+ if self.known_hosts_file is None:
322
+ return
323
+ pathlib.Path(self.known_hosts_file.name).unlink()
324
+
325
+ async def _connect_to_rde(self: 'Cli') -> None:
326
+ self.ssh_client = paramiko.SSHClient()
327
+ self.ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
328
+ logger.info('Connecting to RDE')
329
+ try:
330
+ self.ssh_client.connect(
331
+ hostname=self.hostname,
332
+ username=REMOTE_USERNAME,
333
+ timeout=30,
334
+ )
335
+ except TimeoutError:
336
+ logger.exception('Timeout while connecting to RDE. Is your RDE running?')
337
+ sys.exit(1)
338
+ return
339
+ transport = self.ssh_client.get_transport()
340
+ self.sftp_client = paramiko.sftp_client.SFTPClient.from_transport(transport)
341
+
342
+ async def _disconnect_from_rde(self: 'Cli') -> None:
343
+ if self.sftp_client is not None:
344
+ self.sftp_client.close()
345
+ self.sftp_client = None
346
+ if self.ssh_client is not None:
347
+ self.ssh_client.close()
348
+ self.ssh_client = None
349
+
350
+ async def _init_local_cache(self: 'Cli') -> None:
351
+ self.local_source_directory.mkdir(parents=True, exist_ok=True)
352
+ logger.debug('Listing remote items')
353
+ listing = self.sftp_client.listdir_attr(REMOTE_SOURCE_DIRECTORY)
354
+
355
+ logger.info('Processing %d remote items...', len(listing))
356
+ for file_info in rich.progress.track(
357
+ sequence=sorted(
358
+ listing,
359
+ key=lambda file_info: file_info.filename.casefold(),
360
+ ),
361
+ description='Processing remote items',
362
+ ):
363
+ logger.info('Processing "%s"', file_info.filename)
364
+ try:
365
+ result = await self._process_remote_item(file_info)
366
+ except SubprocessError:
367
+ logger.exception('Failed')
368
+ else:
369
+ logger.info(result)
370
+
371
+ async def _process_remote_item(self: 'Cli', file_info: paramiko.sftp_attr.SFTPAttributes) -> str:
372
+ filename = file_info.filename
373
+
374
+ if file_info.st_mode & stat.S_IFDIR:
375
+ # check if .git exists
376
+ try:
377
+ git_stat = self.sftp_client.stat(f'{REMOTE_SOURCE_DIRECTORY}/{filename}/.git')
378
+ except FileNotFoundError:
379
+ pass
380
+ else:
381
+ if git_stat.st_mode & stat.S_IFDIR:
382
+ repo_dir = self.local_source_directory / filename
383
+ if not repo_dir.exists():
384
+ return await self._process_remote_item_clone(file_info.filename)
385
+ return f'Repository "{filename}" already exists'
386
+ return await self._process_remote_item_copy_dir(file_info.filename)
387
+ return await self._process_remote_item_copy_file(file_info.filename)
388
+
389
+ async def _process_remote_item_copy_dir(self: 'Cli', filename: str) -> str:
390
+ await run_subprocess(
391
+ 'rsync',
392
+ [
393
+ '-e', f'ssh -o ConnectTimeout=10 -o UserKnownHostsFile={self.known_hosts_file.name}',
394
+ '--archive',
395
+ '--checksum',
396
+ f'{REMOTE_USERNAME}@{self.hostname}:{REMOTE_SOURCE_DIRECTORY}/{filename}/',
397
+ str(self.local_source_directory / filename),
398
+ ],
399
+ name='Copy remote directory',
400
+ )
401
+ return f'Copied directory "{filename}"'
402
+
403
+ async def _process_remote_item_copy_file(self: 'Cli', filename: str) -> str:
404
+ await self.loop.run_in_executor(
405
+ executor=None,
406
+ func=functools.partial(
407
+ self.sftp_client.get,
408
+ remotepath=f'{REMOTE_SOURCE_DIRECTORY}/{filename}',
409
+ localpath=str(self.local_source_directory / filename),
410
+ ),
411
+ )
412
+ return f'Copied file "{filename}"'
413
+
414
+ async def _process_remote_item_clone(self: 'Cli', filename: str) -> str:
415
+ await run_subprocess(
416
+ 'git',
417
+ [
418
+ 'clone',
419
+ '-q',
420
+ f'{REMOTE_USERNAME}@{self.hostname}:{REMOTE_SOURCE_DIRECTORY}/{filename}',
421
+ ],
422
+ name='Git clone',
423
+ cwd=self.local_source_directory,
424
+ env={
425
+ 'GIT_SSH_COMMAND': f'ssh -o ConnectTimeout=10 -o UserKnownHostsFile={self.known_hosts_file.name}',
426
+ },
427
+ )
428
+ ssh_stdin, ssh_stdout, ssh_stderr = await self.loop.run_in_executor(
429
+ executor=None,
430
+ func=functools.partial(
431
+ self.ssh_client.exec_command,
432
+ shlex.join([
433
+ 'git',
434
+ '-C',
435
+ f'{REMOTE_SOURCE_DIRECTORY}/{filename}',
436
+ 'config',
437
+ '--get',
438
+ 'remote.origin.url',
439
+ ]),
440
+ ),
441
+ )
442
+ upstream = ssh_stdout.readline().strip()
443
+ await run_subprocess(
444
+ 'git',
445
+ [
446
+ 'remote',
447
+ 'set-url',
448
+ 'origin',
449
+ upstream,
450
+ ],
451
+ name='Git remote set-url',
452
+ cwd=self.local_source_directory / filename,
453
+ env={
454
+ 'GIT_SSH_COMMAND': f'ssh -o ConnectTimeout=10 -o UserKnownHostsFile={self.known_hosts_file.name}',
455
+ },
456
+ )
457
+ return f'Cloned repository "{filename}"'
458
+
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
+ async def _background_sync(self: 'Cli') -> None:
526
+ logger.debug('Starting background sync')
527
+ self.local_source_directory.mkdir(parents=True, exist_ok=True)
528
+ with contextlib.suppress(OSError):
529
+ self.sftp_client.mkdir(REMOTE_SOURCE_DIRECTORY)
530
+ try:
531
+ await run_subprocess(
532
+ 'rsync',
533
+ [
534
+ '-e', f'ssh -o ConnectTimeout=10 -o UserKnownHostsFile={self.known_hosts_file.name}',
535
+ '--archive',
536
+ '--delete',
537
+ '--exclude', 'build-cache-*', # TODO: make exclusions configurable
538
+ '--exclude', 'dev-tool/config',
539
+ '--exclude', 'alembic.ini',
540
+ '--exclude', 'cypress/screenshots',
541
+ '--exclude', 'cypress/videos',
542
+ '--exclude', 'flow_api',
543
+ '--exclude', '.git',
544
+ '--exclude', '__pycache__',
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',
562
+ '--human-readable',
563
+ '--info=name1',
564
+ f'{self.local_source_directory}/',
565
+ f'{REMOTE_USERNAME}@{self.hostname}:{REMOTE_SOURCE_DIRECTORY}',
566
+ ],
567
+ name='Background sync',
568
+ )
569
+ except asyncio.CancelledError:
570
+ logger.debug('Background sync interrupted')
571
+ raise
572
+ except SubprocessError as ex:
573
+ logger.error('Background sync failed: %s', ex) # noqa: TRY400
574
+ except Exception:
575
+ logger.exception('Background sync failed')
576
+ else:
577
+ logger.info('Background sync done')
578
+
579
+ async def _reverse_background_sync(self: 'Cli') -> None:
580
+ logger.debug('Starting reverse background sync')
581
+ with contextlib.suppress(OSError):
582
+ self.sftp_client.mkdir(REMOTE_OUTPUT_DIRECTORY)
583
+ self.local_output_directory.mkdir(parents=True, exist_ok=True)
584
+ try:
585
+ stdout, stderr = await run_subprocess(
586
+ 'rsync',
587
+ [
588
+ '-e', f'ssh -o ConnectTimeout=10 -o UserKnownHostsFile={self.known_hosts_file.name}',
589
+ '--archive',
590
+ '--exclude', '__pycache__',
591
+ '--human-readable',
592
+ '--info=name1',
593
+ f'{REMOTE_USERNAME}@{self.hostname}:{REMOTE_OUTPUT_DIRECTORY}/',
594
+ str(self.local_output_directory),
595
+ ],
596
+ name='Reverse background sync',
597
+ )
598
+ except asyncio.CancelledError:
599
+ logger.debug('Reverse background sync interrupted')
600
+ raise
601
+ except SubprocessError:
602
+ logger.exception('Reverse background sync failed')
603
+ else:
604
+ logger.debug('Reverse background sync done')
605
+
606
+ async def _watch_filesystem(
607
+ self: 'Cli',
608
+ queue: asyncio.Queue,
609
+ ) -> None:
610
+ handler = FileSystemEventHandlerToQueue(queue, self.loop)
611
+ filesystem_observer = watchdog.observers.Observer()
612
+ filesystem_observer.schedule(
613
+ event_handler=handler,
614
+ path=str(self.local_source_directory),
615
+ recursive=True,
616
+ )
617
+ filesystem_observer.start()
618
+ logger.info('Filesystem watches established')
619
+ try:
620
+ await self.loop.run_in_executor(
621
+ executor=None,
622
+ func=filesystem_observer.join,
623
+ )
624
+ finally:
625
+ filesystem_observer.stop()
626
+ filesystem_observer.join(3)
627
+
628
+ async def _remote_sync(self: 'Cli') -> None:
629
+ while True:
630
+ await self._reverse_background_sync()
631
+ await asyncio.sleep(10)
632
+
633
+ async def _process_sync_event(self: 'Cli', event: watchdog.events.FileSystemEvent) -> None:
634
+ local_path = pathlib.Path(event.src_path)
635
+ relative_path = local_path.relative_to(self.local_source_directory)
636
+ remote_path = f'{REMOTE_SOURCE_DIRECTORY}/{relative_path}'
637
+ if isinstance(event, watchdog.events.DirCreatedEvent):
638
+ await self._remote_directory_create(remote_path)
639
+ elif isinstance(event, watchdog.events.DirDeletedEvent):
640
+ await self._remote_directory_delete(remote_path)
641
+ elif isinstance(event, watchdog.events.FileCreatedEvent):
642
+ await self._remote_file_create(remote_path)
643
+ elif isinstance(event, watchdog.events.FileModifiedEvent):
644
+ stat = local_path.stat()
645
+ times = (stat.st_atime, stat.st_mtime)
646
+ await self._remote_file_copy(event.src_path, remote_path, times)
647
+ elif isinstance(event, watchdog.events.FileDeletedEvent):
648
+ await self._remote_file_delete(remote_path)
649
+ elif isinstance(event, watchdog.events.FileMovedEvent):
650
+ dest_local_path = pathlib.Path(event.dest_path)
651
+ dest_relative_path = dest_local_path.relative_to(self.local_source_directory)
652
+ dest_remote_path = f'{REMOTE_SOURCE_DIRECTORY}/{dest_relative_path}'
653
+ stat = dest_local_path.stat()
654
+ times = (stat.st_atime, stat.st_mtime)
655
+ await self._remote_file_move(remote_path, dest_remote_path, times)
656
+
657
+ async def _remote_directory_create(self: 'Cli', remote_path: str) -> None:
658
+ logger.info('Create directory: "%s" (remote)', remote_path)
659
+ try:
660
+ self.sftp_client.mkdir(remote_path)
661
+ except OSError:
662
+ logger.exception('-> failed')
663
+ try:
664
+ stat = self.sftp_client.stat(remote_path)
665
+ except FileNotFoundError:
666
+ logger.info('-> remote directory does not exist')
667
+ else:
668
+ logger.info('-> remote directory already exists:\n%s', stat)
669
+
670
+ async def _remote_directory_delete(self: 'Cli', remote_path: str) -> None:
671
+ logger.info('Delete directory: "%s" (remote)', remote_path)
672
+ try:
673
+ self.sftp_client.rmdir(remote_path)
674
+ except FileNotFoundError:
675
+ logger.exception('-> remote directory does not exist')
676
+ except OSError:
677
+ logger.exception('-> failed')
678
+
679
+ async def _remote_file_create(self: 'Cli', remote_path: str) -> None:
680
+ logger.info('Create file: "%s" (remote)', remote_path)
681
+ self.sftp_client.putfo(io.BytesIO(), remote_path)
682
+
683
+ async def _remote_file_copy(self: 'Cli', local_path: str, remote_path: str, times: typing.Tuple[int, int]) -> None:
684
+ logger.info('Copy file: "%s" (local) -> "%s" (remote)', local_path, remote_path)
685
+ self.sftp_client.put(local_path, remote_path)
686
+ self.sftp_client.utime(remote_path, times)
687
+
688
+ async def _remote_file_delete(self: 'Cli', remote_path: str) -> None:
689
+ logger.info('Delete file: "%s" (remote)', remote_path)
690
+ try:
691
+ self.sftp_client.remove(remote_path)
692
+ except FileNotFoundError:
693
+ logger.info('-> remote file does not exist')
694
+
695
+ async def _remote_file_move(self: 'Cli', remote_path: str, dest_remote_path: str, times: typing.Tuple[int, int]) -> None:
696
+ logger.info('Move file: "%s" (remote) -> "%s" (remote)', remote_path, dest_remote_path)
697
+ self.sftp_client.rename(remote_path, dest_remote_path)
698
+ self.sftp_client.utime(dest_remote_path, times)
699
+
700
+ async def _start_port_forwarding(self: 'Cli') -> None:
701
+ logger.info('Starting port forwarding of ports 8443, 5678, 6678, 7678, 8678, 3000, 2022')
702
+ try:
703
+ await run_subprocess(
704
+ 'ssh',
705
+ [
706
+ '-o', 'ConnectTimeout=10',
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')
726
+ else:
727
+ logger.info('Port forwarding done')
728
+
729
+
730
+ async def _start_logs(self: 'Cli') -> None:
731
+ logger.info('Following logs')
732
+ stdout_queue = asyncio.Queue()
733
+ stderr_queue = asyncio.Queue()
734
+ stream_task = self.loop.run_in_executor(
735
+ executor=None,
736
+ func=functools.partial(
737
+ self._stream_logs,
738
+ stdout_queue=stdout_queue,
739
+ stderr_queue=stderr_queue,
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
771
+
772
+
773
+ def _stream_logs(
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
786
+ while True:
787
+ try:
788
+ stdout = ssh_stdout.readline(1024)
789
+ except TimeoutError:
790
+ have_stdout = False
791
+ else:
792
+ have_stdout = True
793
+ try:
794
+ stderr = ssh_stderr.readline(1024)
795
+ except TimeoutError:
796
+ have_stderr = False
797
+ else:
798
+ have_stderr = True
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:
804
+ break
805
+ if not have_stdout and not have_stderr:
806
+ time.sleep(.5)
807
+ self.loop.call_soon_threadsafe(stdout_queue.put_nowait, None)
808
+ self.loop.call_soon_threadsafe(stderr_queue.put_nowait, None)
809
+
810
+
811
+ def main() -> None:
812
+ parser = argparse.ArgumentParser()
813
+ parser.add_argument(
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()
838
+
839
+ cli = Cli(args)
840
+ with contextlib.suppress(KeyboardInterrupt):
841
+ asyncio.run(cli.run())
842
+ logger.info('Bye!')
843
+
844
+
845
+ if __name__ == '__main__':
846
+ main()
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.1
2
+ Name: devstack-cli
3
+ Version: 9.0.0
4
+ Summary: Command-line access to Remote Development Environments (RDEs) created by Cloudomation DevStack
5
+ Author-email: Stefan Mückstein <stefan@cloudomation.com>
6
+ Project-URL: Homepage, https://cloudomation.com/
7
+ Project-URL: Documentation, https://docs.cloudomation.com/devstack/
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: paramiko
15
+ Requires-Dist: watchdog
16
+ Requires-Dist: rich
17
+
18
+ # DevStack CLI
19
+
20
+ The `devstack-cli` provides command-line access to Remote Development Environments (RDEs) created by Cloudomation DevStack. Learn more about [Cloudomation DevStack](https://docs.cloudomation.com/devstack/docs/overview-and-concept).
21
+
22
+ ## Installation
23
+
24
+ The following binaries must be installed to be able to use `devstack-cli`:
25
+
26
+ * `ssh`
27
+ * `ssh-keyscan`
28
+ * `rsync`
29
+ * `git`
30
+
31
+ On Debian/Ubuntu the packages can be installed with
32
+
33
+ ```
34
+ apt install openssh-client rsync git
35
+ ```
36
+
37
+ Then you can install `devstack-cli` by running
38
+
39
+ ```
40
+ python -m pip install devstack-cli
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```
46
+ usage: devstack-cli [-h] -H HOSTNAME -s SOURCE_DIRECTORY [-o OUTPUT_DIRECTORY] [-v]
47
+ ```
48
+
49
+ You need to specify the hostname/IP of the RDE created by Cloudomation DevStack as well as the path to a directory where the sources will be cached locally. Optionally you can specify an output directory where artifacts created on the RDE will be stored locally.
50
+ The `-v` switch enables debug logging.
51
+
52
+ ## Support
53
+
54
+ `devstack-cli` is part of Cloudomation DevStack and support is provided to you with an active subscription.
55
+
56
+ ## Authors and acknowledgment
57
+
58
+ Cloudomation actively maintains the `devstack-cli` command line tool as part of Cloudomation DevStack
59
+
60
+ ## License
61
+
62
+ Copyright (c) 2024 Starflows OG
63
+
64
+ Permission is hereby granted, free of charge, to any person obtaining a copy
65
+ of this software and associated documentation files (the "Software"), to deal
66
+ in the Software without restriction, including without limitation the rights
67
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
68
+ copies of the Software, and to permit persons to whom the Software is
69
+ furnished to do so, subject to the following conditions:
70
+
71
+ The above copyright notice and this permission notice shall be included in all
72
+ copies or substantial portions of the Software.
73
+
74
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
75
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
76
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
77
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
78
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
79
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
80
+ SOFTWARE.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/__init__.py
5
+ src/cli.py
6
+ src/version.py
7
+ src/devstack_cli.egg-info/PKG-INFO
8
+ src/devstack_cli.egg-info/SOURCES.txt
9
+ src/devstack_cli.egg-info/dependency_links.txt
10
+ src/devstack_cli.egg-info/entry_points.txt
11
+ src/devstack_cli.egg-info/requires.txt
12
+ src/devstack_cli.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ devstack-cli = cli:main
@@ -0,0 +1,3 @@
1
+ paramiko
2
+ watchdog
3
+ rich
@@ -0,0 +1,3 @@
1
+ __init__
2
+ cli
3
+ version
@@ -0,0 +1,9 @@
1
+ """
2
+ constants, set by build
3
+ """
4
+
5
+ MAJOR = '9'
6
+ BRANCH_NAME = 'release-9'
7
+ BUILD_DATE = '2024-04-08-194853'
8
+ SHORT_SHA = 'c394f11'
9
+ VERSION = '9+release-9.2024-04-08-194853.c394f11'