devstack-cli 10.0.180__tar.gz → 11.0.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: devstack-cli
3
- Version: 10.0.180
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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devstack-cli"
3
- version = "10.0.180"
3
+ version = "11.0.0"
4
4
  authors = [
5
5
  { name="Stefan Mückstein", email="stefan@cloudomation.com" },
6
6
  ]
@@ -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.set_missing_host_key_policy(paramiko.AutoAddPolicy())
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
- logger.debug('Scanning hostkeys of "%s"', self.hostname)
1035
- try:
1036
- stdout, stderr = await run_subprocess(
1037
- 'ssh-keyscan',
1038
- [
1039
- self.hostname,
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"{self.cde_type['value']['remote-username']}@{self.hostname}",
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"{self.cde_type['value']['remote-username']}@{self.hostname}:{self.cde_type['value']['remote-source-directory']}/{filename}/",
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"{self.cde_type['value']['remote-source-directory']}/{filename}",
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"{self.cde_type['value']['remote-username']}@{self.hostname}:{self.cde_type['value']['remote-source-directory']}/{filename}",
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"{self.cde_type['value']['remote-source-directory']}/{filename}",
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(self.cde_type['value']['remote-source-directory'])
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"{self.cde_type['value']['remote-username']}@{self.hostname}:{self.cde_type['value']['remote-source-directory']}",
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(self.cde_type['value']['remote-output-directory'])
1614
+ self.sftp_client.mkdir(remote_output_directory)
1584
1615
  self.local_output_directory.mkdir(parents=True, exist_ok=True)
1585
1616
  try:
1586
- stdout, stderr = await run_subprocess(
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"{self.cde_type['value']['remote-username']}@{self.hostname}:{self.cde_type['value']['remote-output-directory']}/",
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
- async with conn.create_process(
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
- ) as self.terminal_process:
1788
- await self.terminal_process.wait()
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: 10.0.180
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
+ """
2
+ constants, set by build
3
+ """
4
+
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
- """
2
- constants, set by build
3
- """
4
-
5
- MAJOR = '10'
6
- BRANCH_NAME = 'release-10'
7
- BUILD_DATE = '2025-01-29-073316'
8
- SHORT_SHA = '8202cdc'
9
- VERSION = '10+release-10.2025-01-29-073316.8202cdc'
File without changes
File without changes
File without changes