devstack-cli 11.0.254__tar.gz → 12.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.4
2
2
  Name: devstack-cli
3
- Version: 11.0.254
3
+ Version: 12.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 = "11.0.254"
3
+ version = "12.0.0"
4
4
  authors = [
5
5
  { name="Stefan Mückstein", email="stefan@cloudomation.com" },
6
6
  ]
@@ -34,6 +34,7 @@ import rich.logging
34
34
  import rich.markup
35
35
  import rich.pretty
36
36
  import rich.progress
37
+ import rich.table
37
38
  import watchdog.events
38
39
  import watchdog.observers
39
40
  import yarl
@@ -237,11 +238,12 @@ class Cli:
237
238
  self.password: typing.Optional[str] = None
238
239
  self.session: typing.Optional[aiohttp.ClientSession] = None
239
240
  self.workspace_url: typing.Optional[yarl.URL] = None
241
+ self.user_info: typing.Optional[dict] = None
240
242
  self.sync_task: typing.Optional[asyncio.Task] = None
241
243
  self.port_forwarding_task: typing.Optional[asyncio.Task] = None
242
244
  self.logs_task: typing.Optional[asyncio.Task] = None
243
245
  self.exit_stack: typing.Optional[contextlib.AsyncExitStack] = None
244
- self.cdes: typing.List[dict] = []
246
+ self.cdes: typing.Dict[str, dict] = {}
245
247
  self.cde: typing.Optional[dict] = None
246
248
  self.cde_type: typing.Optional[dict] = None
247
249
  self.ssh_client: typing.Optional[paramiko.SSHClient] = None
@@ -358,7 +360,7 @@ class Cli:
358
360
  parser.add_argument(
359
361
  '--maximum-uptime-hours',
360
362
  type=int,
361
- help='the number of hours before an CDE is automatically stopped',
363
+ help='deprecated. accepted to not break backwards compatibility, please set `devstack_default_maximum_uptime_hours` metadata value on your user.',
362
364
  )
363
365
  parser.add_argument(
364
366
  '-n', '--cde-name',
@@ -484,6 +486,59 @@ class Cli:
484
486
  self.loop.remove_reader(sys.stdin)
485
487
  termios.tcsetattr(self._fd, termios.TCSADRAIN, self._tcattr)
486
488
 
489
+ async def _migrate_maximum_uptime_hours(self: 'Cli') -> None:
490
+ assert self.session is not None
491
+ assert self.workspace_url is not None
492
+ assert self.user_info is not None
493
+ assert self.config is not None
494
+ response = await self.session.get(
495
+ url=self.workspace_url / 'api/latest/record_metadata',
496
+ params={
497
+ 'filter': json.dumps({
498
+ 'and': [
499
+ {
500
+ 'field': 'record_id',
501
+ 'op': 'eq',
502
+ 'value': self.user_info['identity_id'],
503
+ },
504
+ {
505
+ 'field': 'key',
506
+ 'op': 'eq',
507
+ 'value': 'devstack_default_maximum_uptime_hours',
508
+ },
509
+ ],
510
+ }),
511
+ 'fields': 'record_id,key,data,id',
512
+ 'plain': 'True',
513
+ },
514
+ )
515
+ if not response.ok:
516
+ raise InitializationError('Failed to get devstack_default_maximum_uptime_hours metadata')
517
+ maximum_uptime_hours_value = await response.json()
518
+ if len(maximum_uptime_hours_value) > 0:
519
+ if not isinstance(maximum_uptime_hours_value[0]['data'], (str, int)):
520
+ response = await self.session.delete(url=self.workspace_url / 'api/latest/record_metadata' / maximum_uptime_hours_value[0]['id'])
521
+ if not response.ok:
522
+ raise InitializationError(f'Failed to delete invalid metadata: {response.reason} ({response.status}):\n{await response.text()}')
523
+ logger.warning('Deleted invalid metadata: %s', maximum_uptime_hours_value[0]['id'])
524
+ else:
525
+ if int(self.config['global']['maximum_uptime_hours']) != int(maximum_uptime_hours_value[0]['data']):
526
+ logger.warning('"maximum_uptime_hours" is deprecated and set, while the metadata key `devstack_default_maximum_uptime_hours` is also set, which will be used.')
527
+ return
528
+ logger.info('Migrating maximum_uptime_hours to Cloudomation metadata')
529
+ response = await self.session.post(
530
+ url=self.workspace_url / 'api/latest/record_metadata',
531
+ json={
532
+ 'record_id': self.user_info['identity_id'],
533
+ 'key': 'devstack_default_maximum_uptime_hours',
534
+ 'data': int(self.config['global']['maximum_uptime_hours']),
535
+ },
536
+ )
537
+ if not response.ok:
538
+ raise InitializationError(f'Failed to migrate maximum_uptime_hours: {response.reason} ({response.status}):\n{await response.text()}')
539
+ del self.config['global']['maximum_uptime_hours']
540
+
541
+
487
542
  async def _load_global_config(self: 'Cli') -> None:
488
543
  self.config_file = pathlib.Path(os.path.expandvars(self.args.config_file))
489
544
  self.config_file.parent.mkdir(parents=True, exist_ok=True) # make sure the config directory exists
@@ -496,6 +551,8 @@ class Cli:
496
551
  config_str = await f.read()
497
552
  self.config.read_string(config_str, source=self.config_file)
498
553
  self.config.setdefault('global', {})
554
+ self.config['global'].setdefault('source_directory_template', '$HOME/{folder}')
555
+ self.config['global'].setdefault('output_directory_template', '$HOME/{folder}-output')
499
556
 
500
557
  workspace_url = self.args.workspace_url or self.config['global'].get('workspace_url')
501
558
  if not workspace_url:
@@ -513,18 +570,6 @@ class Cli:
513
570
  self.password = self._console_input(f'Enter your password to authenticate "{user_name}" to {workspace_url}: ', password=True)
514
571
  self.twofa_code = self._console_input('Enter a current two-factor authentication code (if enabled): ', password=True)
515
572
 
516
- maximum_uptime_hours = self.args.maximum_uptime_hours or self.config['global'].get('maximum_uptime_hours')
517
- if not maximum_uptime_hours:
518
- while True:
519
- maximum_uptime_hours = self._console_input('How many hours should an CDE remain started until it is automatically stopped: ', prefill='8')
520
- try:
521
- int(maximum_uptime_hours)
522
- except ValueError:
523
- logger.error('"%s" is not a valid number', maximum_uptime_hours) # noqa: TRY400
524
- else:
525
- break
526
- self.config['global']['maximum_uptime_hours'] = maximum_uptime_hours
527
-
528
573
  await self._write_config_file()
529
574
 
530
575
  async def _write_config_file(self: 'Cli') -> None:
@@ -578,6 +623,9 @@ class Cli:
578
623
  logger.info('Logged in to Cloudomation workspace')
579
624
  json_logger.debug(json.dumps(self.user_info, indent=4, sort_keys=True))
580
625
 
626
+ if 'maximum_uptime_hours' in self.config['global']:
627
+ await self._migrate_maximum_uptime_hours()
628
+
581
629
  response = await self.session.get(
582
630
  url=self.workspace_url / 'api/latest/object_template/cde-type',
583
631
  params={
@@ -590,6 +638,18 @@ class Cli:
590
638
  logger.debug('The "cde-type" object template')
591
639
  json_logger.debug(json.dumps(self.cde_type_template, indent=4, sort_keys=True))
592
640
 
641
+ response = await self.session.get(
642
+ url=self.workspace_url / 'api/latest/object_template/cde-snapshot',
643
+ params={
644
+ 'by': 'name',
645
+ },
646
+ )
647
+ if response.status != 200:
648
+ raise InitializationError(f'Failed to fetch "cde-snapshot" object template: {response.reason} ({response.status}):\n{await response.text()}\nIs the "DevStack" bundle installed?')
649
+ self.cde_snapshot_template = (await response.json())['object_template']
650
+ logger.debug('The "cde-snapshot" object template')
651
+ json_logger.debug(json.dumps(self.cde_snapshot_template, indent=4, sort_keys=True))
652
+
593
653
  response = await self.session.get(
594
654
  url=self.workspace_url / 'api/latest/object_template/cde',
595
655
  params={
@@ -619,6 +679,28 @@ class Cli:
619
679
  logger.debug('The "cde-type" custom objects')
620
680
  json_logger.debug(json.dumps(self.cde_types, indent=4, sort_keys=True))
621
681
 
682
+ response = await self.session.get(
683
+ url=self.workspace_url / 'api/latest/custom_object',
684
+ params={
685
+ 'filter': json.dumps({
686
+ 'and': [
687
+ {
688
+ 'field': 'object_template_id',
689
+ 'op': 'eq',
690
+ 'value': self.cde_snapshot_template['id'],
691
+ },
692
+ ],
693
+ }),
694
+ 'plain': 'true',
695
+ },
696
+ )
697
+ if response.status != 200:
698
+ raise InitializationError(f'Failed to fetch "cde-snapshot" custom objects: {response.reason} ({response.status}):\n{await response.text()}')
699
+ self.cde_snapshots = await response.json()
700
+ self.cde_snapshots.sort(key=lambda cde_snapshot: cde_snapshot['name'])
701
+ logger.debug('The "cde-snapshot" custom objects')
702
+ json_logger.debug(json.dumps(self.cde_snapshots, indent=4, sort_keys=True))
703
+
622
704
  # logger.info('Using configuration of CDE "%s"', self.cde_name)
623
705
  # json_logger.debug(json.dumps(self.cde_config, indent=4, sort_keys=True))
624
706
 
@@ -642,7 +724,10 @@ class Cli:
642
724
  table.add_section()
643
725
  table.add_row('', '== CDE selection ==')
644
726
  for i, cde in enumerate(self.cdes.values(), start=1):
645
- cde_type = await self._get_cde_type_of_cde(cde)
727
+ cde_snapshot = await self._get_cde_snapshot_of_cde(cde)
728
+ if not cde_snapshot:
729
+ continue
730
+ cde_type = await self._get_cde_type_of_cde_snapshot(cde_snapshot)
646
731
  if not cde_type:
647
732
  continue
648
733
  cde_type_name = cde_type['name']
@@ -822,24 +907,31 @@ class Cli:
822
907
  self.sftp_client.close()
823
908
  self.sftp_client = None
824
909
 
910
+ async def _get_cde_type_of_cde_snapshot(self: 'Cli', cde_snapshot: dict) -> typing.Optional[dict]:
911
+ try:
912
+ cde_type = next(cde_type for cde_type in self.cde_types if cde_type['id'] == cde_snapshot['value']['cde-type'])
913
+ except StopIteration:
914
+ logger.error('CDE type ID "%s" not found', cde_snapshot['value']['cde-type']) # noqa: TRY400
915
+ return None
916
+ return cde_type
825
917
 
826
- async def _get_cde_type_of_cde(self: 'Cli', cde: dict) -> typing.Optional[dict]:
918
+ async def _get_cde_snapshot_of_cde(self: 'Cli', cde: dict) -> typing.Optional[dict]:
827
919
  if cde['exists_remotely']:
828
920
  try:
829
- cde_type = next(cde_type for cde_type in self.cde_types if cde_type['id'] == cde['value']['cde-type'])
921
+ cde_snapshot = next(cde_snapshot for cde_snapshot in self.cde_snapshots if cde_snapshot['id'] == cde['value']['cde-snapshot'])
830
922
  except StopIteration:
831
- logger.error('CDE type ID "%s" not found', cde['value']['cde-type']) # noqa: TRY400
923
+ logger.error('CDE snapshot ID "%s" not found', cde['value']['cde-snapshot']) # noqa: TRY400
832
924
  return None
833
925
  elif cde['exists_locally']:
834
926
  try:
835
- cde_type = next(cde_type for cde_type in self.cde_types if cde_type['name'] == cde['cde_type'])
927
+ cde_snapshot = next(cde_snapshot for cde_snapshot in self.cde_snapshots if cde_snapshot['name'] == cde.get('cde_snapshot'))
836
928
  except StopIteration:
837
- logger.error('CDE type "%s" not found', cde['cde_type']) # noqa: TRY400
929
+ logger.error('CDE snapshot "%s" not found', cde.get('cde_snapshot')) # noqa: TRY400
838
930
  return None
839
931
  else:
840
932
  logger.error('CDE does not exist')
841
933
  return None
842
- return cde_type
934
+ return cde_snapshot
843
935
 
844
936
  async def _process_args(self: 'Cli') -> None:
845
937
  if self.args.cde_name:
@@ -936,32 +1028,60 @@ class Cli:
936
1028
 
937
1029
  async def _create_cde(self: 'Cli') -> None:
938
1030
  logger.info('Creating new CDE')
939
- table = rich.table.Table(title='CDE types')
1031
+
1032
+ response = await self.session.get(
1033
+ url=self.workspace_url / 'api/latest/custom_object',
1034
+ params={
1035
+ 'filter': json.dumps({
1036
+ 'and': [
1037
+ {
1038
+ 'field': 'object_template_id',
1039
+ 'op': 'eq',
1040
+ 'value': self.cde_snapshot_template['id'],
1041
+ },
1042
+ {
1043
+ 'field': 'provisioning_state',
1044
+ 'op': 'eq',
1045
+ 'value': 'READY',
1046
+ },
1047
+ ],
1048
+ }),
1049
+ 'plain': 'true',
1050
+ },
1051
+ )
1052
+ if response.status != 200:
1053
+ raise InitializationError(f'Failed to fetch "cde-snapshot" custom objects: {response.reason} ({response.status}):\n{await response.text()}')
1054
+ self.cde_snapshots = await response.json()
1055
+ self.cde_snapshots.sort(key=lambda cde_snapshot: cde_snapshot['name'])
1056
+ logger.debug('The "cde-snapshot" custom objects')
1057
+ json_logger.debug(json.dumps(self.cde_snapshots, indent=4, sort_keys=True))
1058
+
1059
+ table = rich.table.Table(title='CDE snapshots')
940
1060
  table.add_column('Key', style='cyan bold')
941
1061
  table.add_column('Name')
942
1062
  table.add_column('Description')
943
- for i, cde_type in enumerate(self.cde_types, start=1):
944
- table.add_row(str(i), cde_type['name'], cde_type['description'])
945
- table.add_row('ESC', 'Cancel')
1063
+ table.add_column('Status')
1064
+ for i, cde_snapshot in enumerate(self.cde_snapshots, start=1):
1065
+ table.add_row(str(i), cde_snapshot['name'], cde_snapshot['description'], cde_snapshot['provisioning_state'])
1066
+ #table.add_row('ESC', 'Cancel')
946
1067
  rich.print(table)
947
- logger.info('Choose a CDE type (1-%d):', len(self.cde_types))
948
- key_press = await self.key_queue.get()
949
- if key_press == chr(27):
950
- logger.warning('Aborting')
951
- return
1068
+ cde_snapshot_index = self._console_input(f'Choose a CDE snapshot (1-{len(self.cde_snapshots)}): ')
952
1069
  try:
953
- cde_type = self.cde_types[int(key_press)-1]
1070
+ cde_snapshot = self.cde_snapshots[int(cde_snapshot_index)-1]
954
1071
  except (IndexError, ValueError):
955
- logger.error('Invalid choice "%s"', key_press) # noqa: TRY400
1072
+ logger.error('Invalid choice "%s"', cde_snapshot_index) # noqa: TRY400
1073
+ return
1074
+ if cde_snapshot['provisioning_state'] != 'READY':
1075
+ logger.error('CDE snapshot "%s" is not ready', cde_snapshot['name'])
956
1076
  return
957
- cde_name = self._console_input('Choose a name for your CDE: ', prefill=f"{self.user_info['name']}-{cde_type['name']}")
958
- await self._create_cde_api_call(cde_name, cde_type['id'])
1077
+ cde_name = self._console_input('Choose a name for your CDE: ', prefill=f"{self.user_info['name']}-{cde_snapshot['name']}")
1078
+ await self._create_cde_api_call(cde_name, cde_snapshot['id'])
959
1079
  await self._update_cde_list()
960
1080
  await self._select_cde(cde_name)
1081
+ if self.cde['status'] == 'not configured':
1082
+ await self._configure_cde(silent=True)
961
1083
 
962
- async def _create_cde_api_call(self: 'Cli', cde_name: str, cde_type_id: str) -> None:
963
- maximum_uptime_hours = int(self.config['global'].get('maximum_uptime_hours', '8'))
964
- stop_at = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=maximum_uptime_hours)).isoformat()
1084
+ async def _create_cde_api_call(self: 'Cli', cde_name: str, cde_snapshot_id: str) -> None:
965
1085
  try:
966
1086
  response = await self.session.post(
967
1087
  url=self.workspace_url / 'api/latest/custom_object',
@@ -969,10 +1089,9 @@ class Cli:
969
1089
  'name': cde_name,
970
1090
  'object_template_id': self.cde_template['id'],
971
1091
  'value': {
972
- 'cde-type': cde_type_id,
1092
+ 'cde-snapshot': cde_snapshot_id,
973
1093
  'user': self.user_info['identity_id'],
974
1094
  'feature-branch-mapping': None,
975
- 'stop-at': stop_at,
976
1095
  },
977
1096
  },
978
1097
  params={
@@ -985,18 +1104,28 @@ class Cli:
985
1104
  if response.status != 200:
986
1105
  logger.error('Failed to create CDE: %s (%s):\n%s', response.reason, response.status, await response.text())
987
1106
  return
1107
+ response_json = await response.json()
1108
+ if response_json['id'] in { c['id'] for c in self.cdes.values() if 'id' in c}:
1109
+ logger.warning('Did not create a new CDE since a CDE with the name you chose already exists')
988
1110
 
989
1111
  async def _select_cde(self: 'Cli', cde_name: str, *, quiet: bool = False) -> None:
990
1112
  if self.cde is not None and self.cde['name'] != cde_name and self.ssh_client is not None:
991
1113
  await self._disconnect_cde()
992
1114
  try:
993
1115
  self.cde = self.cdes[cde_name]
994
- except IndexError:
1116
+ except KeyError:
995
1117
  logger.error('Cannot select CDE "%s". No such CDE', cde_name) # noqa: TRY400
996
1118
  return
997
1119
  if not quiet:
998
1120
  logger.info('Selecting "%s" CDE', self.cde_name)
999
- self.cde_type = await self._get_cde_type_of_cde(self.cde)
1121
+ self.cde_snapshot = await self._get_cde_snapshot_of_cde(self.cde)
1122
+ if not self.cde_snapshot:
1123
+ logger.error('Cannot select CDE "%s". No such CDE snapshot', cde_name) # noqa: TRY400
1124
+ return
1125
+ self.cde_type = await self._get_cde_type_of_cde_snapshot(self.cde_snapshot)
1126
+ if not self.cde_type:
1127
+ logger.error('Cannot select CDE "%s". No such CDE type', cde_name) # noqa: TRY400
1128
+ return
1000
1129
  self.config['global']['last_cde_name'] = self.cde_name
1001
1130
  await self._write_config_file()
1002
1131
 
@@ -1022,8 +1151,10 @@ class Cli:
1022
1151
  elif self.cde['status'] == 'connected':
1023
1152
  await self._disconnect_cde()
1024
1153
  else:
1025
- logger.error('CDE is not running. Cannot connect.')
1026
- return
1154
+ await self._start_cde()
1155
+ await self._wait_running()
1156
+ # logger.error('CDE is not running. Cannot connect.')
1157
+ # return
1027
1158
 
1028
1159
  async def _connect_cde(self: 'Cli') -> None:
1029
1160
  logger.info('Connecting to CDE')
@@ -1077,7 +1208,7 @@ class Cli:
1077
1208
  self.known_hosts_file = None
1078
1209
  logger.debug('Disconnected from CDE')
1079
1210
 
1080
- async def _configure_cde(self: 'Cli') -> None:
1211
+ async def _configure_cde(self: 'Cli', *, silent: bool = False) -> None:
1081
1212
  await self._update_cde_list()
1082
1213
  if not self.cde:
1083
1214
  logger.error('No CDE is selected. Cannot configure CDE.')
@@ -1087,17 +1218,26 @@ class Cli:
1087
1218
  logger.info('Creating new configuration for CDE "%s".', self.cde_name)
1088
1219
  self.config[cde_config_key] = {
1089
1220
  'cde_type': self.cde_type['name'],
1221
+ 'cde_snapshot': self.cde_snapshot['name'],
1090
1222
  }
1091
- source_directory = self._console_input(
1092
- f'Choose a local directory where the sources of the "{self.cde_name}" CDE will be stored: ',
1093
- prefill=self.config[cde_config_key].get('source_directory', f'$HOME/{self.cde_type["name"].replace(" ", "-")}'),
1094
- )
1223
+ default_source_directory = self.config['global']['source_directory_template'].format(folder=self.cde_type['name'].replace(' ', '-'))
1224
+ if silent:
1225
+ source_directory = default_source_directory
1226
+ else:
1227
+ source_directory = self._console_input(
1228
+ f'Choose a local directory where the sources of the "{self.cde_name}" CDE will be stored: ',
1229
+ prefill=self.config[cde_config_key].get('source_directory', default_source_directory),
1230
+ )
1095
1231
  self.config[cde_config_key]['source_directory'] = source_directory
1096
1232
  while True:
1097
- output_directory = self._console_input(
1098
- f'Choose a local directory where the outputs of the "{self.cde_name}" CDE will be stored: ',
1099
- prefill=self.config[cde_config_key].get('output_directory', f'$HOME/{self.cde_type["name"].replace(" ", "-")}-output'),
1100
- )
1233
+ default_output_directory = self.config['global']['output_directory_template'].format(folder=self.cde_type['name'].replace(' ', '-'))
1234
+ if silent:
1235
+ output_directory = default_output_directory
1236
+ else:
1237
+ output_directory = self._console_input(
1238
+ f'Choose a local directory where the outputs of the "{self.cde_name}" CDE will be stored: ',
1239
+ prefill=self.config[cde_config_key].get('output_directory', default_output_directory),
1240
+ )
1101
1241
  if (
1102
1242
  _is_relative_to(source_directory, output_directory)
1103
1243
  or _is_relative_to(output_directory, source_directory)
@@ -1106,18 +1246,6 @@ class Cli:
1106
1246
  else:
1107
1247
  break
1108
1248
  self.config[cde_config_key]['output_directory'] = output_directory
1109
- while True:
1110
- maximum_uptime_hours = self._console_input(
1111
- 'How many hours should this CDE remain started until it is automatically stopped: ',
1112
- prefill=self.config['global'].get('maximum_uptime_hours', '8'),
1113
- )
1114
- try:
1115
- int(maximum_uptime_hours)
1116
- except ValueError:
1117
- logger.error('"%s" is not a valid number', maximum_uptime_hours) # noqa: TRY400
1118
- else:
1119
- break
1120
- self.config[cde_config_key]['maximum_uptime_hours'] = maximum_uptime_hours
1121
1249
 
1122
1250
  await self._write_config_file()
1123
1251
  logger.info('CDE "%s" configured.', self.cde_name)
@@ -1157,15 +1285,12 @@ class Cli:
1157
1285
  self.ssh_client = None
1158
1286
 
1159
1287
  async def _start_cde_api_call(self: 'Cli') -> None:
1160
- maximum_uptime_hours = int(self.config['global'].get('maximum_uptime_hours', '8'))
1161
- stop_at = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=maximum_uptime_hours)).isoformat()
1162
1288
  try:
1163
1289
  response = await self.session.patch(
1164
1290
  url=self.workspace_url / 'api/latest/custom_object' / self.cde['id'],
1165
1291
  json={
1166
1292
  'value': {
1167
1293
  'is-running': True,
1168
- 'stop-at': stop_at,
1169
1294
  },
1170
1295
  },
1171
1296
  )
@@ -1199,19 +1324,21 @@ class Cli:
1199
1324
  logger.error('No CDE is selected. Cannot delete CDE.')
1200
1325
  return
1201
1326
  logger.info('Deleting CDE "%s"', self.cde_name)
1202
- try:
1203
- response = await self.session.delete(
1204
- url=self.workspace_url / 'api/latest/custom_object' / self.cde['id'],
1205
- params={
1206
- 'permanently': 'true',
1207
- },
1208
- )
1209
- except (aiohttp.ClientError, aiohttp.ClientResponseError) as ex:
1210
- logger.error('Failed to delete CDE: %s', str(ex)) # noqa: TRY400
1211
- return
1212
- if response.status != 204:
1213
- logger.error('Failed to delete CDE: %s (%s)', response.reason, response.status)
1214
- return
1327
+ if self.cde['exists_remotely']:
1328
+ try:
1329
+ response = await self.session.delete(
1330
+ url=self.workspace_url / 'api/latest/custom_object' / self.cde['id'],
1331
+ params={
1332
+ 'permanently': 'true',
1333
+ 'wait': 'false',
1334
+ },
1335
+ )
1336
+ except (aiohttp.ClientError, aiohttp.ClientResponseError) as ex:
1337
+ logger.error('Failed to delete CDE: %s', str(ex)) # noqa: TRY400
1338
+ return
1339
+ if response.status != 204:
1340
+ logger.error('Failed to delete CDE: %s (%s)', response.reason, response.status)
1341
+ return
1215
1342
  if self.sync_task:
1216
1343
  self.sync_task.cancel()
1217
1344
  self.sync_task = None
@@ -1224,7 +1351,9 @@ class Cli:
1224
1351
  if self.ssh_client is not None:
1225
1352
  self.ssh_client.close()
1226
1353
  self.ssh_client = None
1227
-
1354
+ self.config.remove_section(f'cde.{self.cde_name}')
1355
+ self.config['global'].pop('last_cde_name', None)
1356
+ await self._write_config_file()
1228
1357
  #####
1229
1358
  ##### PORT FORWARDING
1230
1359
  #####
@@ -1240,11 +1369,11 @@ class Cli:
1240
1369
  logger.error('No CDE is selected. Cannot start port forwarding.')
1241
1370
  return
1242
1371
  if not self.is_cde_running:
1243
- logger.error('CDE is not running. Cannot start port forwarding.')
1244
- return
1372
+ await self._start_cde()
1373
+ await self._wait_running()
1374
+ await self._connect_cde()
1245
1375
  if self.ssh_client is None:
1246
- logger.error('Not connected to CDE. Cannot start port forwarding.')
1247
- return
1376
+ await self._connect_cde()
1248
1377
  self.port_forwarding_task = asyncio.create_task(self._bg_port_forwarding())
1249
1378
 
1250
1379
  async def _stop_port_forwarding(self: 'Cli') -> None:
@@ -1347,7 +1476,7 @@ class Cli:
1347
1476
  logger.info('Starting file sync')
1348
1477
  try:
1349
1478
  await self._init_local_cache()
1350
- except OSError as ex:
1479
+ except Exception as ex:
1351
1480
  logger.error('Failed to initialize local cache: %s', str(ex)) # noqa: TRY400
1352
1481
  return
1353
1482
  filesystem_event_queue = asyncio.Queue()
@@ -1757,8 +1886,7 @@ class Cli:
1757
1886
  logger.error('CDE is not running. Cannot follow logs.')
1758
1887
  return
1759
1888
  if self.ssh_client is None:
1760
- logger.error('Not connected to CDE. Cannot follow logs.')
1761
- return
1889
+ await self._connect_cde()
1762
1890
  self.logs_task = asyncio.create_task(self._bg_logs())
1763
1891
 
1764
1892
  async def _stop_logs(self: 'Cli') -> None:
@@ -1803,8 +1931,7 @@ class Cli:
1803
1931
  logger.error('CDE is not running. Cannot open terminal.')
1804
1932
  return
1805
1933
  if self.ssh_client is None:
1806
- logger.error('Not connected to CDE. Cannot open terminal.')
1807
- return
1934
+ await self._connect_cde()
1808
1935
  while True:
1809
1936
  logger.info('Opening interactive terminal (press CTRL+D or enter "exit" to close)')
1810
1937
  await self._reset_keyboard()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devstack-cli
3
- Version: 11.0.254
3
+ Version: 12.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 = '12'
6
+ BRANCH_NAME = 'release-12'
7
+ BUILD_DATE = '2026-08-19-223353'
8
+ SHORT_SHA = '7b791d7'
9
+ VERSION = '12+release-12.2026-08-19-223353.7b791d7'
@@ -1,9 +0,0 @@
1
- """
2
- constants, set by build
3
- """
4
-
5
- MAJOR = '11'
6
- BRANCH_NAME = 'release-11'
7
- BUILD_DATE = '2026-06-22-023906'
8
- SHORT_SHA = '000400e'
9
- VERSION = '11+release-11.2026-06-22-023906.000400e'
File without changes
File without changes
File without changes