devstack-cli 10.0.180__py3-none-any.whl → 11.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 +69 -38
- {devstack_cli-10.0.180.dist-info → devstack_cli-11.0.0.dist-info}/METADATA +1 -1
- devstack_cli-11.0.0.dist-info/RECORD +9 -0
- {devstack_cli-10.0.180.dist-info → devstack_cli-11.0.0.dist-info}/WHEEL +1 -1
- version.py +5 -5
- devstack_cli-10.0.180.dist-info/RECORD +0 -9
- {devstack_cli-10.0.180.dist-info → devstack_cli-11.0.0.dist-info}/LICENSE +0 -0
- {devstack_cli-10.0.180.dist-info → devstack_cli-11.0.0.dist-info}/entry_points.txt +0 -0
- {devstack_cli-10.0.180.dist-info → devstack_cli-11.0.0.dist-info}/top_level.txt +0 -0
cli.py
CHANGED
@@ -10,6 +10,7 @@ import json
|
|
10
10
|
import logging
|
11
11
|
import os
|
12
12
|
import pathlib
|
13
|
+
import re
|
13
14
|
import readline
|
14
15
|
import shlex
|
15
16
|
import shutil
|
@@ -56,6 +57,30 @@ logger.addHandler(rich.logging.RichHandler())
|
|
56
57
|
json_logger = logging.getLogger('cli-json')
|
57
58
|
json_logger.addHandler(rich.logging.RichHandler(highlighter=rich.highlighter.JSONHighlighter()))
|
58
59
|
|
60
|
+
def is_valid_username(username: str) -> bool:
|
61
|
+
return re.match(r'^[a-zA-Z0-9]+[a-zA-Z0-9_.-]*$', username) is not None
|
62
|
+
|
63
|
+
def is_valid_hostname(hostname: str) -> bool:
|
64
|
+
return re.match(r'^([a-zA-Z0-9][a-zA-Z0-9-]*\.)*([a-zA-Z0-9][a-zA-Z0-9-]*)$', hostname) is not None
|
65
|
+
|
66
|
+
def is_valid_path(path: str) -> bool:
|
67
|
+
return re.match(r'^[a-zA-Z0-9/_.,\-+ ]+$', path) is not None
|
68
|
+
|
69
|
+
def ensure_valid_username(username: str) -> str:
|
70
|
+
if not is_valid_username(username):
|
71
|
+
raise ValueError(f'Invalid username: "{username}". Username must start with an alphanumeric character, and contain only alphanumeric characters, dots, underscores and hyphens.')
|
72
|
+
return username
|
73
|
+
|
74
|
+
def ensure_valid_hostname(hostname: str) -> str:
|
75
|
+
if not is_valid_hostname(hostname):
|
76
|
+
raise ValueError(f'Invalid hostname: "{hostname}". Hostname must start with an alphanumeric character, and contain only alphanumeric characters, dots, and hyphens.')
|
77
|
+
return hostname
|
78
|
+
|
79
|
+
def ensure_valid_path(path: str) -> str:
|
80
|
+
if not is_valid_path(path):
|
81
|
+
raise ValueError(f'Invalid path: "{path}". Path must start with an alphanumeric character, and contain only alphanumeric characters, slashes, dots, underscores, commas, pluses, hyphens, and spaces.')
|
82
|
+
return path
|
83
|
+
|
59
84
|
class SubprocessError(Exception):
|
60
85
|
"""A subprocess call returned with non-zero."""
|
61
86
|
|
@@ -83,6 +108,7 @@ class FileSystemEventHandlerToQueue(watchdog.events.FileSystemEventHandler):
|
|
83
108
|
if event.event_type in (
|
84
109
|
watchdog.events.EVENT_TYPE_OPENED,
|
85
110
|
watchdog.events.EVENT_TYPE_CLOSED,
|
111
|
+
watchdog.events.EVENT_TYPE_CLOSED_NO_WRITE,
|
86
112
|
):
|
87
113
|
return
|
88
114
|
if event.event_type == watchdog.events.EVENT_TYPE_MODIFIED and event.is_directory:
|
@@ -96,7 +122,7 @@ class FileSystemEventHandlerToQueue(watchdog.events.FileSystemEventHandler):
|
|
96
122
|
|
97
123
|
async def run_subprocess(
|
98
124
|
program: str,
|
99
|
-
args: str,
|
125
|
+
args: typing.List[str],
|
100
126
|
*,
|
101
127
|
name: str,
|
102
128
|
cwd: typing.Optional[pathlib.Path] = None,
|
@@ -975,13 +1001,13 @@ class Cli:
|
|
975
1001
|
async def _wait_running(self: 'Cli') -> None:
|
976
1002
|
logger.info('Waiting for CDE "%s" to reach status running...', self.cde_name)
|
977
1003
|
while True:
|
1004
|
+
await asyncio.sleep(10)
|
978
1005
|
await self._update_cde_list()
|
979
1006
|
if self.cde['status'] == 'running':
|
980
1007
|
break
|
981
1008
|
if self.cde['status'].endswith('_failed') or self.cde['status'] in {'not configured', 'deleted', 'connected', 'stopped'}:
|
982
1009
|
logger.error('CDE "%s" failed to reach status running and is now in status "%s".', self.cde_name, self.cde['status'])
|
983
1010
|
return
|
984
|
-
await asyncio.sleep(10)
|
985
1011
|
logger.info('CDE "%s" is now running', self.cde_name)
|
986
1012
|
|
987
1013
|
async def _connect_disconnect_cde(self: 'Cli') -> None:
|
@@ -1001,10 +1027,11 @@ class Cli:
|
|
1001
1027
|
logger.info('Connecting to CDE')
|
1002
1028
|
known_hosts = await self._get_known_hosts()
|
1003
1029
|
if known_hosts is None:
|
1030
|
+
logger.error('Cannot connect to CDE. Host-key not found.')
|
1004
1031
|
return
|
1005
1032
|
self.known_hosts_file = await _create_temp_file(exit_stack=self.exit_stack, content=known_hosts)
|
1006
1033
|
self.ssh_client = paramiko.SSHClient()
|
1007
|
-
self.ssh_client.
|
1034
|
+
self.ssh_client.load_host_keys(self.known_hosts_file.name)
|
1008
1035
|
try:
|
1009
1036
|
self.ssh_client.connect(
|
1010
1037
|
hostname=self.hostname,
|
@@ -1021,34 +1048,18 @@ class Cli:
|
|
1021
1048
|
logger.info('Connected to CDE')
|
1022
1049
|
|
1023
1050
|
async def _get_known_hosts(self: 'Cli') -> typing.Optional[str]:
|
1024
|
-
if self.cde['value']['hostkey']:
|
1025
|
-
if self.cde['value']['hostkey'].startswith(self.cde['value']['hostname']):
|
1026
|
-
return self.cde['value']['hostkey']
|
1027
|
-
return f"{self.cde['value']['hostname']} {self.cde['value']['hostkey']}"
|
1028
1051
|
if not self.cde:
|
1029
1052
|
logger.error('No CDE is selected. Cannot fetch host-key.')
|
1030
1053
|
return None
|
1031
1054
|
if not self.is_cde_running:
|
1032
1055
|
logger.error('CDE is not running. Cannot fetch host-key.')
|
1033
1056
|
return None
|
1034
|
-
|
1035
|
-
|
1036
|
-
|
1037
|
-
|
1038
|
-
|
1039
|
-
|
1040
|
-
],
|
1041
|
-
name='ssh-keyscan',
|
1042
|
-
print_stdout=False,
|
1043
|
-
print_stderr=False,
|
1044
|
-
)
|
1045
|
-
except SubprocessError as ex:
|
1046
|
-
logger.error('%s Failed to fetch hostkeys. Is you CDE running?', ex) # noqa: TRY400
|
1047
|
-
sys.exit(1)
|
1048
|
-
known_hosts = stdout
|
1049
|
-
with contextlib.suppress(FileNotFoundError):
|
1050
|
-
known_hosts += pathlib.Path(os.path.expandvars('$HOME/.ssh/known_hosts')).read_bytes()
|
1051
|
-
return known_hosts
|
1057
|
+
if not self.cde['value']['hostkey']:
|
1058
|
+
logger.error('CDE record does not contain a hostkey.')
|
1059
|
+
return None
|
1060
|
+
if self.cde['value']['hostkey'].startswith(self.cde['value']['hostname']):
|
1061
|
+
return self.cde['value']['hostkey']
|
1062
|
+
return f"{self.cde['value']['hostname']} {self.cde['value']['hostkey']}"
|
1052
1063
|
|
1053
1064
|
async def _disconnect_cde(self: 'Cli') -> None:
|
1054
1065
|
logger.info('Disconnecting from CDE')
|
@@ -1236,6 +1247,8 @@ class Cli:
|
|
1236
1247
|
self.port_forwarding_task = None
|
1237
1248
|
|
1238
1249
|
async def _bg_port_forwarding(self: 'Cli') -> None:
|
1250
|
+
remote_username = ensure_valid_username(self.cde_type['value']['remote-username'])
|
1251
|
+
hostname = ensure_valid_hostname(self.hostname)
|
1239
1252
|
service_ports = self.cde_type['value'].get('service-ports')
|
1240
1253
|
if service_ports is None:
|
1241
1254
|
service_ports = [
|
@@ -1252,6 +1265,11 @@ class Cli:
|
|
1252
1265
|
for port
|
1253
1266
|
in service_ports
|
1254
1267
|
]
|
1268
|
+
for port in service_ports:
|
1269
|
+
if port[0] < 1 or port[0] > 65535:
|
1270
|
+
raise ValueError(f'Invalid port: "{port[0]}". Only numbers between 1 and 65535 are allowed.')
|
1271
|
+
if port[1] < 1 or port[1] > 65535:
|
1272
|
+
raise ValueError(f'Invalid port: "{port[1]}". Only numbers between 1 and 65535 are allowed.')
|
1255
1273
|
while True:
|
1256
1274
|
logger.info('Starting port forwarding of %s', ', '.join(str(port[0]) for port in service_ports))
|
1257
1275
|
try:
|
@@ -1261,7 +1279,7 @@ class Cli:
|
|
1261
1279
|
'-o', 'ConnectTimeout=10',
|
1262
1280
|
'-o', f'UserKnownHostsFile={self.known_hosts_file.name}',
|
1263
1281
|
'-NT',
|
1264
|
-
f
|
1282
|
+
f'{remote_username}@{hostname}',
|
1265
1283
|
*itertools.chain.from_iterable([
|
1266
1284
|
('-L', f'{port[0]}:localhost:{port[1]}')
|
1267
1285
|
for port
|
@@ -1441,13 +1459,16 @@ class Cli:
|
|
1441
1459
|
return await self._process_remote_item_copy_file(file_info.filename)
|
1442
1460
|
|
1443
1461
|
async def _process_remote_item_copy_dir(self: 'Cli', filename: str) -> str:
|
1462
|
+
remote_username = ensure_valid_username(self.cde_type['value']['remote-username'])
|
1463
|
+
hostname = ensure_valid_hostname(self.hostname)
|
1464
|
+
remote_source_directory = ensure_valid_path(self.cde_type['value']['remote-source-directory'])
|
1444
1465
|
await run_subprocess(
|
1445
1466
|
'rsync',
|
1446
1467
|
[
|
1447
1468
|
'-e', f'ssh -o ConnectTimeout=10 -o UserKnownHostsFile={self.known_hosts_file.name}',
|
1448
1469
|
'--archive',
|
1449
1470
|
'--checksum',
|
1450
|
-
f
|
1471
|
+
f'{remote_username}@{hostname}:{remote_source_directory}/{filename}/',
|
1451
1472
|
str(self.local_source_directory / filename),
|
1452
1473
|
],
|
1453
1474
|
name='Copy remote directory',
|
@@ -1455,23 +1476,27 @@ class Cli:
|
|
1455
1476
|
return f'Copied directory "{filename}"'
|
1456
1477
|
|
1457
1478
|
async def _process_remote_item_copy_file(self: 'Cli', filename: str) -> str:
|
1479
|
+
remote_source_directory = ensure_valid_path(self.cde_type['value']['remote-source-directory'])
|
1458
1480
|
await self.loop.run_in_executor(
|
1459
1481
|
executor=None,
|
1460
1482
|
func=functools.partial(
|
1461
1483
|
self.sftp_client.get,
|
1462
|
-
remotepath=f
|
1484
|
+
remotepath=f'{remote_source_directory}/{filename}',
|
1463
1485
|
localpath=str(self.local_source_directory / filename),
|
1464
1486
|
),
|
1465
1487
|
)
|
1466
1488
|
return f'Copied file "{filename}"'
|
1467
1489
|
|
1468
1490
|
async def _process_remote_item_clone(self: 'Cli', filename: str) -> str:
|
1491
|
+
remote_username = ensure_valid_username(self.cde_type['value']['remote-username'])
|
1492
|
+
hostname = ensure_valid_hostname(self.hostname)
|
1493
|
+
remote_source_directory = ensure_valid_path(self.cde_type['value']['remote-source-directory'])
|
1469
1494
|
await run_subprocess(
|
1470
1495
|
'git',
|
1471
1496
|
[
|
1472
1497
|
'clone',
|
1473
1498
|
'-q',
|
1474
|
-
f
|
1499
|
+
f'{remote_username}@{hostname}:{remote_source_directory}/{filename}',
|
1475
1500
|
],
|
1476
1501
|
name='Git clone',
|
1477
1502
|
cwd=self.local_source_directory,
|
@@ -1486,7 +1511,7 @@ class Cli:
|
|
1486
1511
|
shlex.join([
|
1487
1512
|
'git',
|
1488
1513
|
'-C',
|
1489
|
-
f
|
1514
|
+
f'{remote_source_directory}/{filename}',
|
1490
1515
|
'config',
|
1491
1516
|
'--get',
|
1492
1517
|
'remote.origin.url',
|
@@ -1511,10 +1536,13 @@ class Cli:
|
|
1511
1536
|
return f'Cloned repository "{filename}"'
|
1512
1537
|
|
1513
1538
|
async def _background_sync(self: 'Cli') -> None:
|
1539
|
+
remote_username = ensure_valid_username(self.cde_type['value']['remote-username'])
|
1540
|
+
hostname = ensure_valid_hostname(self.hostname)
|
1541
|
+
remote_source_directory = ensure_valid_path(self.cde_type['value']['remote-source-directory'])
|
1514
1542
|
logger.debug('Starting background sync')
|
1515
1543
|
self.local_source_directory.mkdir(parents=True, exist_ok=True)
|
1516
1544
|
with contextlib.suppress(OSError):
|
1517
|
-
self.sftp_client.mkdir(
|
1545
|
+
self.sftp_client.mkdir(remote_source_directory)
|
1518
1546
|
file_sync_exclusions = self.cde_type['value'].get('file-sync-exclusions')
|
1519
1547
|
if file_sync_exclusions is None:
|
1520
1548
|
file_sync_exclusions = [
|
@@ -1562,7 +1590,7 @@ class Cli:
|
|
1562
1590
|
'--human-readable',
|
1563
1591
|
'--verbose',
|
1564
1592
|
f'{self.local_source_directory}/',
|
1565
|
-
f
|
1593
|
+
f'{remote_username}@{hostname}:{remote_source_directory}',
|
1566
1594
|
],
|
1567
1595
|
name='Background sync',
|
1568
1596
|
print_to_debug_log=True,
|
@@ -1578,19 +1606,22 @@ class Cli:
|
|
1578
1606
|
logger.info('Background sync done')
|
1579
1607
|
|
1580
1608
|
async def _reverse_background_sync(self: 'Cli') -> None:
|
1609
|
+
remote_username = ensure_valid_username(self.cde_type['value']['remote-username'])
|
1610
|
+
hostname = ensure_valid_hostname(self.hostname)
|
1611
|
+
remote_output_directory = ensure_valid_path(self.cde_type['value']['remote-output-directory'])
|
1581
1612
|
logger.debug('Starting reverse background sync')
|
1582
1613
|
with contextlib.suppress(OSError):
|
1583
|
-
self.sftp_client.mkdir(
|
1614
|
+
self.sftp_client.mkdir(remote_output_directory)
|
1584
1615
|
self.local_output_directory.mkdir(parents=True, exist_ok=True)
|
1585
1616
|
try:
|
1586
|
-
|
1617
|
+
await run_subprocess(
|
1587
1618
|
'rsync',
|
1588
1619
|
[
|
1589
1620
|
'-e', f'ssh -o ConnectTimeout=10 -o UserKnownHostsFile={self.known_hosts_file.name}',
|
1590
1621
|
'--archive',
|
1591
1622
|
'--exclude', '__pycache__',
|
1592
1623
|
'--human-readable',
|
1593
|
-
f
|
1624
|
+
f'{remote_username}@{hostname}:{remote_output_directory}/',
|
1594
1625
|
str(self.local_output_directory),
|
1595
1626
|
],
|
1596
1627
|
name='Reverse background sync',
|
@@ -1780,12 +1811,12 @@ class Cli:
|
|
1780
1811
|
term_size=(terminal_size.columns, terminal_size.lines),
|
1781
1812
|
) as conn:
|
1782
1813
|
try:
|
1783
|
-
|
1814
|
+
self.terminal_process = await conn.create_process(
|
1784
1815
|
stdin=os.dup(sys.stdin.fileno()),
|
1785
1816
|
stdout=os.dup(sys.stdout.fileno()),
|
1786
1817
|
stderr=os.dup(sys.stderr.fileno()),
|
1787
|
-
)
|
1788
|
-
|
1818
|
+
)
|
1819
|
+
await self.terminal_process.wait()
|
1789
1820
|
finally:
|
1790
1821
|
self.terminal_process = None
|
1791
1822
|
except asyncio.CancelledError:
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.2
|
2
2
|
Name: devstack-cli
|
3
|
-
Version:
|
3
|
+
Version: 11.0.0
|
4
4
|
Summary: Command-line access to Cloud Development Environments (CDEs) created by Cloudomation DevStack
|
5
5
|
Author-email: Stefan Mückstein <stefan@cloudomation.com>
|
6
6
|
Project-URL: Homepage, https://cloudomation.com/
|
@@ -0,0 +1,9 @@
|
|
1
|
+
__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
2
|
+
cli.py,sha256=-KSbyW0gGMp18u3EkQ2J6H4tiVyyW__0zUDSiC1J75o,81433
|
3
|
+
version.py,sha256=bTUKZf6wIt0itSaSFfZfA760kf0BqtQIPJUQR_tqFdQ,180
|
4
|
+
devstack_cli-11.0.0.dist-info/LICENSE,sha256=OBXZbEUMtIHIzyISkJ9fJlf_imds3rcKqeQu9yiyUJI,1055
|
5
|
+
devstack_cli-11.0.0.dist-info/METADATA,sha256=pkEAXWlnz1lmJ-y0QKt1ZfwRx5AAiJW5sejWgQTu4VQ,4286
|
6
|
+
devstack_cli-11.0.0.dist-info/WHEEL,sha256=nn6H5-ilmfVryoAQl3ZQ2l8SH5imPWFpm1A5FgEuFV4,91
|
7
|
+
devstack_cli-11.0.0.dist-info/entry_points.txt,sha256=f0xb4DIk0a7E5kyZ7YpoLhtjoagQj5VQpeBbW9a8A9Y,42
|
8
|
+
devstack_cli-11.0.0.dist-info/top_level.txt,sha256=lP8zvU46Am_G0MPcNmCI6f0sMfwpDUWpTROaPs-IEPk,21
|
9
|
+
devstack_cli-11.0.0.dist-info/RECORD,,
|
version.py
CHANGED
@@ -2,8 +2,8 @@
|
|
2
2
|
constants, set by build
|
3
3
|
"""
|
4
4
|
|
5
|
-
MAJOR = '
|
6
|
-
BRANCH_NAME = 'release-
|
7
|
-
BUILD_DATE = '2025-
|
8
|
-
SHORT_SHA = '
|
9
|
-
VERSION = '
|
5
|
+
MAJOR = '11'
|
6
|
+
BRANCH_NAME = 'release-11'
|
7
|
+
BUILD_DATE = '2025-02-26-033416'
|
8
|
+
SHORT_SHA = '0dab1e2'
|
9
|
+
VERSION = '11+release-11.2025-02-26-033416.0dab1e2'
|
@@ -1,9 +0,0 @@
|
|
1
|
-
__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
2
|
-
cli.py,sha256=gLKHGA-pqxD4KXJqAUV56kxiYw1CL6PBADU-1m5szKs,79464
|
3
|
-
version.py,sha256=kNBP1kyN4fy5lpTIZ3kDiN3gSFwmEYpT0KQntnNIFHE,180
|
4
|
-
devstack_cli-10.0.180.dist-info/LICENSE,sha256=OBXZbEUMtIHIzyISkJ9fJlf_imds3rcKqeQu9yiyUJI,1055
|
5
|
-
devstack_cli-10.0.180.dist-info/METADATA,sha256=MAwGhX3ffEp-Q5kVYPNyvPeuzcfV9-pudCqRl03aUNQ,4288
|
6
|
-
devstack_cli-10.0.180.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
7
|
-
devstack_cli-10.0.180.dist-info/entry_points.txt,sha256=f0xb4DIk0a7E5kyZ7YpoLhtjoagQj5VQpeBbW9a8A9Y,42
|
8
|
-
devstack_cli-10.0.180.dist-info/top_level.txt,sha256=lP8zvU46Am_G0MPcNmCI6f0sMfwpDUWpTROaPs-IEPk,21
|
9
|
-
devstack_cli-10.0.180.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|