anyscale 0.26.28__py3-none-any.whl → 0.26.30__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.
Files changed (54) hide show
  1. anyscale/__init__.py +10 -0
  2. anyscale/_private/anyscale_client/anyscale_client.py +69 -0
  3. anyscale/_private/anyscale_client/common.py +38 -0
  4. anyscale/_private/anyscale_client/fake_anyscale_client.py +11 -0
  5. anyscale/_private/docgen/__main__.py +4 -18
  6. anyscale/_private/docgen/api.md +0 -125
  7. anyscale/_private/docgen/models.md +0 -111
  8. anyscale/client/README.md +0 -6
  9. anyscale/client/openapi_client/__init__.py +0 -4
  10. anyscale/client/openapi_client/api/default_api.py +0 -228
  11. anyscale/client/openapi_client/models/__init__.py +0 -4
  12. anyscale/client/openapi_client/models/workload_info.py +59 -3
  13. anyscale/commands/command_examples.py +10 -0
  14. anyscale/commands/job_queue_commands.py +295 -104
  15. anyscale/commands/list_util.py +14 -1
  16. anyscale/commands/machine_pool_commands.py +25 -11
  17. anyscale/commands/service_commands.py +10 -14
  18. anyscale/commands/workspace_commands_v2.py +462 -25
  19. anyscale/controllers/job_controller.py +5 -210
  20. anyscale/job_queue/__init__.py +89 -0
  21. anyscale/job_queue/_private/job_queue_sdk.py +158 -0
  22. anyscale/job_queue/commands.py +130 -0
  23. anyscale/job_queue/models.py +284 -0
  24. anyscale/scripts.py +1 -1
  25. anyscale/sdk/anyscale_client/__init__.py +0 -11
  26. anyscale/sdk/anyscale_client/api/default_api.py +140 -1433
  27. anyscale/sdk/anyscale_client/models/__init__.py +0 -11
  28. anyscale/service/__init__.py +4 -1
  29. anyscale/service/_private/service_sdk.py +5 -0
  30. anyscale/service/commands.py +4 -2
  31. anyscale/utils/ssh_websocket_proxy.py +178 -0
  32. anyscale/version.py +1 -1
  33. {anyscale-0.26.28.dist-info → anyscale-0.26.30.dist-info}/METADATA +3 -1
  34. {anyscale-0.26.28.dist-info → anyscale-0.26.30.dist-info}/RECORD +39 -49
  35. anyscale/client/openapi_client/models/serve_deployment_fast_api_docs_status.py +0 -123
  36. anyscale/client/openapi_client/models/servedeploymentfastapidocsstatus_response.py +0 -121
  37. anyscale/client/openapi_client/models/web_terminal.py +0 -121
  38. anyscale/client/openapi_client/models/webterminal_response.py +0 -121
  39. anyscale/sdk/anyscale_client/models/cluster_environment_build_log_response.py +0 -123
  40. anyscale/sdk/anyscale_client/models/clusterenvironmentbuildlogresponse_response.py +0 -121
  41. anyscale/sdk/anyscale_client/models/create_cloud.py +0 -518
  42. anyscale/sdk/anyscale_client/models/object_storage_config.py +0 -122
  43. anyscale/sdk/anyscale_client/models/object_storage_config_s3.py +0 -256
  44. anyscale/sdk/anyscale_client/models/objectstorageconfig_response.py +0 -121
  45. anyscale/sdk/anyscale_client/models/session_operation.py +0 -266
  46. anyscale/sdk/anyscale_client/models/session_operation_type.py +0 -101
  47. anyscale/sdk/anyscale_client/models/sessionoperation_response.py +0 -121
  48. anyscale/sdk/anyscale_client/models/update_cloud.py +0 -150
  49. anyscale/sdk/anyscale_client/models/update_project.py +0 -150
  50. {anyscale-0.26.28.dist-info → anyscale-0.26.30.dist-info}/LICENSE +0 -0
  51. {anyscale-0.26.28.dist-info → anyscale-0.26.30.dist-info}/NOTICE +0 -0
  52. {anyscale-0.26.28.dist-info → anyscale-0.26.30.dist-info}/WHEEL +0 -0
  53. {anyscale-0.26.28.dist-info → anyscale-0.26.30.dist-info}/entry_points.txt +0 -0
  54. {anyscale-0.26.28.dist-info → anyscale-0.26.30.dist-info}/top_level.txt +0 -0
@@ -1,9 +1,15 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ import importlib.resources
1
4
  from io import StringIO
2
5
  from json import dumps as json_dumps
3
6
  import pathlib
7
+ import shlex
4
8
  import subprocess
9
+ import sys
5
10
  import tempfile
6
- from typing import Optional, Tuple
11
+ from typing import List, Optional, Tuple
12
+ from urllib.parse import urlparse
7
13
 
8
14
  import click
9
15
  import yaml
@@ -26,6 +32,12 @@ from anyscale.workspace.models import (
26
32
 
27
33
  log = BlockLogger() # CLI Logger
28
34
 
35
+ # Constants for SSH configuration
36
+ HTTPS_PORT = "443"
37
+ SSH_TIMEOUT_SECONDS = 45
38
+ WSS_PATH = "/sshws"
39
+ PREFERRED_AUTH_METHOD = "PreferredAuthentications=publickey"
40
+
29
41
 
30
42
  def _validate_workspace_name_and_id(
31
43
  name: Optional[str], id: Optional[str] # noqa: A002
@@ -37,6 +49,339 @@ def _validate_workspace_name_and_id(
37
49
  raise click.ClickException("Only one of '--name' and '--id' can be provided.")
38
50
 
39
51
 
52
+ def _check_workspace_is_running(
53
+ name: Optional[str],
54
+ id: Optional[str], # noqa: A002
55
+ cloud: Optional[str],
56
+ project: Optional[str],
57
+ ) -> None:
58
+ """Verify that the workspace is in RUNNING state."""
59
+ try:
60
+ workspace_status = anyscale.workspace.status(
61
+ name=name, id=id, cloud=cloud, project=project
62
+ )
63
+ if workspace_status != WorkspaceState.RUNNING:
64
+ raise click.ClickException(
65
+ f"Workspace must be running to SSH into it. Current status: {workspace_status}"
66
+ )
67
+ except ValueError as e:
68
+ # Handle workspace not found or other value errors
69
+ error_msg = str(e)
70
+ if "not found" in error_msg.lower():
71
+ workspace_identifier = name if name else id
72
+ raise click.ClickException(
73
+ f"Workspace '{workspace_identifier}' not found. Please check the workspace name/id and try again."
74
+ )
75
+ else:
76
+ raise click.ClickException(f"Error checking workspace status: {error_msg}")
77
+ except (AttributeError, KeyError, TypeError):
78
+ # Handle any other errors from status check
79
+ raise click.ClickException(
80
+ "Failed to check workspace status. Please ensure the workspace exists and you have access to it."
81
+ )
82
+
83
+
84
+ def _get_workspace_directory_name(
85
+ name: Optional[str],
86
+ id: Optional[str], # noqa: A002
87
+ cloud: Optional[str],
88
+ project: Optional[str],
89
+ ) -> str:
90
+ """Get the default directory name for the workspace."""
91
+ try:
92
+ workspace_private_sdk = _LAZY_SDK_SINGLETONS[_WORKSPACE_SDK_SINGLETON_KEY]
93
+ return workspace_private_sdk.get_default_dir_name(
94
+ name=name, id=id, cloud=cloud, project=project
95
+ )
96
+ except (AttributeError, KeyError, ValueError, TypeError):
97
+ # Handle errors getting default directory name
98
+ raise click.ClickException(
99
+ "Failed to retrieve workspace configuration. Please try again later."
100
+ )
101
+
102
+
103
+ def _create_directory_setup_command(dir_name: str) -> str:
104
+ """Create shell command to set up the workspace directory."""
105
+ # Even though dir_name comes from our API, we should still shell escape it.
106
+ dir_name_escaped = shlex.quote(dir_name)
107
+ return (
108
+ f'if [ -d "$HOME/{dir_name_escaped}" ]; then '
109
+ f'cd "$HOME/{dir_name_escaped}"; '
110
+ f"else "
111
+ f'mkdir -p "$HOME/{dir_name_escaped}" 2>/dev/null && '
112
+ f'cd "$HOME/{dir_name_escaped}" && '
113
+ f'echo "Created directory $HOME/{dir_name_escaped} (it did not exist)." >&2 || '
114
+ f'echo "Warning: Could not access or create directory $HOME/{dir_name_escaped}. Staying in home directory." >&2; '
115
+ f"fi"
116
+ )
117
+
118
+
119
+ class ConnectionType(Enum):
120
+ HTTPS = "https"
121
+ LEGACY = "legacy"
122
+
123
+
124
+ @dataclass
125
+ class SSHConfig:
126
+ """Configuration for SSH connection to workspace."""
127
+
128
+ target_host: str
129
+ config_file: str
130
+ connection_type: ConnectionType
131
+ proxy_command: Optional[str] = None
132
+ port: Optional[str] = None
133
+
134
+ @property
135
+ def ssh_options(self) -> List[str]:
136
+ """Get SSH options based on connection type."""
137
+ if self.connection_type == ConnectionType.HTTPS:
138
+ options = [
139
+ "-p",
140
+ self.port or HTTPS_PORT,
141
+ "-o",
142
+ PREFERRED_AUTH_METHOD,
143
+ ]
144
+ if self.proxy_command:
145
+ options.extend(["-o", f"ProxyCommand={self.proxy_command}"])
146
+ return options
147
+ return []
148
+
149
+
150
+ def _setup_https_connection(
151
+ workspace_obj: Workspace, workspace_private_sdk, host_name: str, config_file: str
152
+ ) -> SSHConfig:
153
+ """Set up HTTPS connection and return SSHConfig object."""
154
+ cluster = workspace_private_sdk.client.get_workspace_cluster(workspace_obj.id)
155
+
156
+ if not cluster:
157
+ raise click.ClickException(
158
+ "Could not retrieve cluster details for the workspace."
159
+ )
160
+
161
+ # Get hostname with multiple fallback methods
162
+ public_hostname = _get_public_hostname(cluster)
163
+
164
+ # Get cluster access token
165
+ cluster_access_token = _get_cluster_access_token(cluster, workspace_private_sdk)
166
+
167
+ # Set up proxy command
168
+ proxy_cmd = _create_proxy_command(public_hostname, cluster_access_token)
169
+
170
+ return SSHConfig(
171
+ target_host=f"ray@{host_name}",
172
+ config_file=config_file,
173
+ connection_type=ConnectionType.HTTPS,
174
+ proxy_command=proxy_cmd,
175
+ port=HTTPS_PORT,
176
+ )
177
+
178
+
179
+ def _get_public_hostname(cluster) -> str:
180
+ """Extract public hostname from cluster with fallback methods."""
181
+ # Attempt 1: Use cluster.hostname
182
+ if hasattr(cluster, "hostname") and cluster.hostname:
183
+ return cluster.hostname
184
+
185
+ # Attempt 2: Fallback to parsing webterminal_auth_url
186
+ if hasattr(cluster, "webterminal_auth_url") and cluster.webterminal_auth_url:
187
+ parsed_url = urlparse(cluster.webterminal_auth_url)
188
+ if parsed_url.netloc:
189
+ return parsed_url.netloc
190
+
191
+ # webterminal_auth_url was present but parsing failed
192
+ raise click.ClickException(
193
+ "Could not extract hostname from cluster configuration. "
194
+ "The URL appears to be malformed."
195
+ )
196
+
197
+ # Both methods failed
198
+ raise click.ClickException(
199
+ "Could not retrieve hostname for HTTPS connection. "
200
+ "Required cluster configuration is not available."
201
+ )
202
+
203
+
204
+ def _get_cluster_access_token(cluster, workspace_private_sdk) -> str:
205
+ """Get cluster access token for HTTPS connection."""
206
+ if not hasattr(cluster, "id") or not cluster.id:
207
+ raise click.ClickException(
208
+ "Cluster configuration is incomplete, cannot retrieve access token."
209
+ )
210
+
211
+ from anyscale.client.openapi_client.exceptions import (
212
+ ApiException,
213
+ ApiTypeError,
214
+ ApiValueError,
215
+ )
216
+
217
+ try:
218
+ # We need to use the internal API here as there's no public API available
219
+ # This might be updated in future SDK versions
220
+ cluster_access_token = workspace_private_sdk.client._internal_api_client.get_cluster_access_token_api_v2_authentication_cluster_id_cluster_access_token_get( # noqa: SLF001
221
+ cluster_id=cluster.id
222
+ )
223
+
224
+ if not cluster_access_token:
225
+ raise click.ClickException(
226
+ "Failed to retrieve authentication token. Please try again."
227
+ )
228
+
229
+ return cluster_access_token
230
+
231
+ except (ApiException, ApiTypeError, ApiValueError):
232
+ # Don't expose API details in error messages
233
+ raise click.ClickException(
234
+ "Failed to authenticate. Please check your permissions and try again."
235
+ ) from None
236
+ except click.ClickException:
237
+ raise # Re-raise click exceptions as-is
238
+ except (AttributeError, KeyError, ValueError, TypeError, RuntimeError) as e:
239
+ # Generic error without exposing internal details
240
+ raise click.ClickException(
241
+ "An error occurred during authentication. Please try again."
242
+ ) from e
243
+
244
+
245
+ def _create_proxy_command(public_hostname: str, cluster_access_token: str) -> str:
246
+ """Create the proxy command for HTTPS connection."""
247
+ wss_url = f"wss://{public_hostname}{WSS_PATH}"
248
+
249
+ try:
250
+ with importlib.resources.path(
251
+ "anyscale.utils", "ssh_websocket_proxy.py"
252
+ ) as proxy_path:
253
+ proxy_script_path = str(proxy_path)
254
+ except (ModuleNotFoundError, ImportError) as e:
255
+ raise click.ClickException(f"Could not locate SSH proxy script: {e}") from e
256
+
257
+ # Properly escape the proxy command arguments
258
+ return " ".join(
259
+ [
260
+ shlex.quote(sys.executable),
261
+ shlex.quote(proxy_script_path),
262
+ shlex.quote(wss_url),
263
+ shlex.quote(cluster_access_token),
264
+ ]
265
+ )
266
+
267
+
268
+ def _build_ssh_command(
269
+ ssh_config: SSHConfig, user_args: List[str], shell_command: str,
270
+ ) -> List[str]:
271
+ """Build the final SSH command with all options."""
272
+ # Build SSH command with basic options
273
+ base_cmd = (
274
+ ["ssh"]
275
+ + ANYSCALE_WORKSPACES_SSH_OPTIONS
276
+ + ssh_config.ssh_options
277
+ + [ssh_config.target_host, "-F", ssh_config.config_file]
278
+ )
279
+
280
+ # Process user-supplied arguments
281
+ if not user_args:
282
+ # No user args, use default interactive shell
283
+ return base_cmd + ["-tt", f"bash -c '{shell_command} && exec bash -i'"]
284
+
285
+ # Parse user arguments into options and commands
286
+ user_options, user_command = _parse_user_args(user_args)
287
+
288
+ ssh_cmd = base_cmd + user_options
289
+
290
+ if user_command:
291
+ # User supplied their own command, use it directly
292
+ ssh_cmd.extend(user_command)
293
+ else:
294
+ # Only options provided, add interactive shell
295
+ # Use -tt for interactive shell unless -T or -N was specified
296
+ if not any(opt in {"-T", "-N"} for opt in user_options):
297
+ ssh_cmd.append("-tt")
298
+ ssh_cmd.append(f"bash -c '{shell_command} && exec bash -i'")
299
+
300
+ return ssh_cmd
301
+
302
+
303
+ def _parse_user_args(user_args: List[str]) -> Tuple[List[str], List[str]]:
304
+ """Parse user arguments into options and commands."""
305
+ # Find where command section starts (first non-option argument)
306
+ command_start_idx = None
307
+ for i, arg in enumerate(user_args):
308
+ if arg and not arg.startswith("-"):
309
+ command_start_idx = i
310
+ break
311
+
312
+ if command_start_idx is not None:
313
+ user_options = [arg for arg in user_args[:command_start_idx] if arg]
314
+ user_command = [arg for arg in user_args[command_start_idx:] if arg]
315
+ else:
316
+ user_options = [arg for arg in user_args if arg]
317
+ user_command = []
318
+
319
+ return user_options, user_command
320
+
321
+
322
+ def _try_https_connection(
323
+ workspace_obj: Workspace,
324
+ workspace_private_sdk,
325
+ host_name: str,
326
+ config_file: str,
327
+ ctx_args: List[str],
328
+ shell_command: str,
329
+ ) -> bool:
330
+ """Attempt HTTPS SSH connection. Returns True if successful."""
331
+ try:
332
+ cluster = workspace_private_sdk.client.get_workspace_cluster(workspace_obj.id)
333
+ if not cluster:
334
+ return False
335
+
336
+ ssh_config = _setup_https_connection(
337
+ workspace_obj, workspace_private_sdk, host_name, config_file
338
+ )
339
+
340
+ ssh_cmd = _build_ssh_command(ssh_config, ctx_args, shell_command)
341
+
342
+ # Try HTTPS connection with a timeout
343
+ result = subprocess.run(
344
+ ssh_cmd,
345
+ check=False,
346
+ timeout=SSH_TIMEOUT_SECONDS,
347
+ stderr=subprocess.DEVNULL,
348
+ )
349
+
350
+ return result.returncode == 0
351
+
352
+ except subprocess.TimeoutExpired:
353
+ print(
354
+ "HTTPS connection timed out or failed (SSH proxy may not be available), "
355
+ "falling back to Legacy SSH connection..."
356
+ )
357
+ return False
358
+ except OSError:
359
+ print(
360
+ "HTTPS connection timed out or failed (SSH proxy may not be available), "
361
+ "falling back to Legacy SSH connection..."
362
+ )
363
+ return False
364
+ except (click.ClickException, ValueError, AttributeError, KeyError, TypeError):
365
+ print("HTTPS setup failed, falling back to Legacy SSH connection...")
366
+ return False
367
+
368
+
369
+ def _execute_legacy_ssh(
370
+ ssh_target_host: str, config_file: str, ctx_args: List[str], shell_command: str,
371
+ ) -> None:
372
+ """Execute legacy SSH connection."""
373
+ legacy_ssh_config = SSHConfig(
374
+ target_host=ssh_target_host,
375
+ config_file=config_file,
376
+ connection_type=ConnectionType.LEGACY,
377
+ proxy_command=None,
378
+ port=None,
379
+ )
380
+
381
+ ssh_cmd = _build_ssh_command(legacy_ssh_config, ctx_args, shell_command)
382
+ subprocess.run(ssh_cmd, check=False)
383
+
384
+
40
385
  @click.group("workspace_v2", help="Anyscale workspace commands V2.")
41
386
  def workspace_cli() -> None:
42
387
  pass
@@ -416,6 +761,12 @@ id should be used, specifying both will result in an error.
416
761
  type=str,
417
762
  help="Named project to use for the workpsace. If not provided, the default project for the cloud will be used.",
418
763
  )
764
+ @click.option(
765
+ "--legacy",
766
+ is_flag=True,
767
+ default=False,
768
+ help="Use legacy SSH connection method, bypassing HTTPS SSH.",
769
+ )
419
770
  @click.pass_context
420
771
  def ssh(
421
772
  ctx,
@@ -423,6 +774,7 @@ def ssh(
423
774
  name: Optional[str],
424
775
  cloud: Optional[str],
425
776
  project: Optional[str],
777
+ legacy: bool,
426
778
  ) -> None:
427
779
  """SSH into a workspace.
428
780
 
@@ -431,34 +783,119 @@ id should be used, specifying both will result in an error.
431
783
 
432
784
  You may pass extra args for the ssh command, for example to setup port forwarding:
433
785
  anyscale workspace_v2 ssh -n workspace-name -- -L 9000:localhost:9000
786
+
787
+ Use the --legacy flag to bypass HTTPS SSH and use the legacy connection method directly.
434
788
  """
789
+ try:
790
+ _validate_workspace_name_and_id(name=name, id=id)
435
791
 
436
- _validate_workspace_name_and_id(name=name, id=id)
437
- assert (
438
- anyscale.workspace.status(name=name, id=id, cloud=cloud, project=project)
439
- == WorkspaceState.RUNNING
440
- ), "Workspace must be running to SSH into it."
441
- workspace_private_sdk = _LAZY_SDK_SINGLETONS[_WORKSPACE_SDK_SINGLETON_KEY]
442
- dir_name = workspace_private_sdk.get_default_dir_name(
443
- name=name, id=id, cloud=cloud, project=project
444
- )
445
- command = f"cd {dir_name} && /bin/bash"
446
- with tempfile.TemporaryDirectory() as tmpdirname:
447
- host_name, config_file = anyscale.workspace.generate_ssh_config_file(
448
- name=name, id=id, cloud=cloud, project=project, ssh_config_path=tmpdirname
449
- )
450
- args = ctx.args
451
- ssh_command = (
452
- ["ssh"]
453
- + ANYSCALE_WORKSPACES_SSH_OPTIONS
454
- + [host_name]
455
- + ["-F", config_file]
456
- + ["-tt"]
457
- + (args if args and len(args) > 0 else [""])
458
- + [f"bash -i -c {command}"]
792
+ # Verify workspace is running
793
+ _check_workspace_is_running(name, id, cloud, project)
794
+
795
+ # Inform user that connection might take time (earliest point after verifying workspace is running)
796
+ connection_mode = " (Legacy SSH)" if legacy else ""
797
+ print(
798
+ f"Connecting to workspace{connection_mode}... This might take a while. Press Ctrl+C to cancel."
459
799
  )
460
800
 
461
- subprocess.run(ssh_command, check=False)
801
+ # Get workspace directory name
802
+ dir_name = _get_workspace_directory_name(name, id, cloud, project)
803
+
804
+ # Create the shell command that will:
805
+ # 1. Try to cd to the directory
806
+ # 2. If that fails, create it and cd to it
807
+ # 3. If that fails too, just stay in home directory with a warning
808
+ shell_command = _create_directory_setup_command(dir_name)
809
+
810
+ with tempfile.TemporaryDirectory() as tmpdirname:
811
+ try:
812
+ host_name, config_file = anyscale.workspace.generate_ssh_config_file(
813
+ name=name,
814
+ id=id,
815
+ cloud=cloud,
816
+ project=project,
817
+ ssh_config_path=tmpdirname,
818
+ )
819
+ except ValueError as e:
820
+ error_msg = str(e)
821
+ if "not found" in error_msg.lower():
822
+ workspace_identifier = name if name else id
823
+ raise click.ClickException(
824
+ f"Workspace '{workspace_identifier}' not found or not accessible."
825
+ )
826
+ else:
827
+ raise click.ClickException("Failed to generate SSH configuration.")
828
+ except (
829
+ OSError,
830
+ IOError,
831
+ RuntimeError,
832
+ AttributeError,
833
+ KeyError,
834
+ TypeError,
835
+ ):
836
+ # Handle any other errors from SSH config generation
837
+ raise click.ClickException(
838
+ "Failed to generate SSH configuration. Please check your network connection and try again."
839
+ )
840
+
841
+ # Get workspace and cluster information to determine connection method
842
+ ssh_target_host = host_name
843
+
844
+ # Skip HTTPS if --legacy flag is used
845
+ https_connection_successful = False
846
+ if not legacy:
847
+ # Try HTTPS first (unless legacy flag is set)
848
+ try:
849
+ workspace_obj = anyscale.workspace.get(
850
+ name=name, id=id, cloud=cloud, project=project
851
+ )
852
+ workspace_private_sdk = _LAZY_SDK_SINGLETONS[
853
+ _WORKSPACE_SDK_SINGLETON_KEY
854
+ ]
855
+ cluster = workspace_private_sdk.client.get_workspace_cluster(
856
+ workspace_obj.id
857
+ )
858
+
859
+ if cluster:
860
+ https_connection_successful = _try_https_connection(
861
+ workspace_obj,
862
+ workspace_private_sdk,
863
+ host_name,
864
+ config_file,
865
+ ctx.args,
866
+ shell_command,
867
+ )
868
+
869
+ except (ValueError, AttributeError, KeyError, TypeError):
870
+ # If we can't get workspace/cluster info, proceed with legacy SSH
871
+ pass
872
+
873
+ # Run legacy SSH command if HTTPS wasn't successful or --legacy was specified
874
+ if not https_connection_successful:
875
+ _execute_legacy_ssh(
876
+ ssh_target_host, config_file, ctx.args, shell_command
877
+ )
878
+
879
+ except click.ClickException:
880
+ # Re-raise click exceptions as they already have user-friendly messages
881
+ raise
882
+ except KeyboardInterrupt:
883
+ # Handle Ctrl+C gracefully
884
+ raise click.ClickException("SSH connection cancelled by user.")
885
+ except (
886
+ OSError,
887
+ IOError,
888
+ RuntimeError,
889
+ ValueError,
890
+ AttributeError,
891
+ KeyError,
892
+ TypeError,
893
+ ):
894
+ # Catch any unexpected exceptions and provide a generic user-friendly message
895
+ raise click.ClickException(
896
+ "An unexpected error occurred while establishing SSH connection. "
897
+ "Please try again or contact support if the issue persists."
898
+ )
462
899
 
463
900
 
464
901
  @workspace_cli.command(