1import logging
2import re
3import yaml
4import boto3
5from sagemaker.hyperpod.common.utils import create_boto3_client
6from typing import List, Optional, ClassVar, Dict, Set, Any, Union
7from pydantic import BaseModel, Field, ConfigDict, model_validator
8from kubernetes import client, config
9from kubernetes.client.rest import ApiException
10from kr8s.objects import Pod
11
12from sagemaker.hyperpod.common.config.metadata import Metadata
13from hyperpod_space_template.v1_0.model import SpaceConfig as SpaceConfigV1_0
14from hyperpod_space_template.v1_1.model import SpaceConfig as SpaceConfigV1_1, ResourceRequirements
15
16SpaceConfig = Union[SpaceConfigV1_0, SpaceConfigV1_1]
17from sagemaker.hyperpod.common.utils import (
18 handle_exception,
19 get_default_namespace,
20 setup_logging,
21 verify_kubernetes_version_compatibility,
22)
23from sagemaker.hyperpod.space.utils import (
24 map_kubernetes_response_to_model,
25 validate_space_mig_resources,
26 validate_mig_profile_in_cluster,
27 warn_if_addon_version_incompatible,
28)
29from sagemaker.hyperpod.common.telemetry.telemetry_logging import (
30 _hyperpod_telemetry_emitter,
31)
32from sagemaker.hyperpod.common.telemetry.constants import Feature
33from sagemaker.hyperpod.cli.constants.space_constants import (
34 SPACE_GROUP,
35 SPACE_VERSION,
36 SPACE_PLURAL,
37 DEFAULT_SPACE_PORT,
38)
39from sagemaker.hyperpod.cli.constants.space_access_constants import (
40 SPACE_ACCESS_GROUP,
41 SPACE_ACCESS_VERSION,
42 SPACE_ACCESS_PLURAL,
43)
44
45
[docs]
46class HPSpace(BaseModel):
47 """HyperPod Space on Amazon SageMaker HyperPod clusters.
48
49 This class provides methods to create, manage, and monitor spaces
50 on SageMaker HyperPod clusters orchestrated by Amazon EKS. Spaces are
51 interactive workspaces that provide development environments with
52 configurable resources, storage, and access controls.
53
54 **Attributes:**
55
56 .. list-table::
57 :header-rows: 1
58 :widths: 20 20 60
59
60 * - Attribute
61 - Type
62 - Description
63 * - config
64 - SpaceConfig
65 - The space configuration using the space parameter model
66 * - raw_resource
67 - Dict[str, Any], optional
68 - The complete Kubernetes resource data including apiVersion, kind, metadata, and status
69
70 .. dropdown:: Usage Examples
71 :open:
72
73 .. code-block:: python
74
75 >>> # Create a new space
76 >>> from hyperpod_space_template.v1_0.model import SpaceConfig
77 >>> config = SpaceConfig(name="my-space", display_name="My Space")
78 >>> space = HPSpace(config=config)
79 >>> space.create()
80
81 >>> # List all spaces
82 >>> spaces = HPSpace.list()
83 >>> for space in spaces:
84 ... print(f"Space: {space.config.name}")
85 """
86
87 is_kubeconfig_loaded: ClassVar[bool] = False
88 model_config = ConfigDict(extra="forbid")
89
90 config: SpaceConfig = Field(
91 description="The space configuration using the space parameter model"
92 )
93
94 raw_resource: Optional[Dict[str, Any]] = Field(
95 default=None,
96 description="The complete Kubernetes resource data including apiVersion, kind, metadata, and status"
97 )
98
99 @classmethod
100 def get_logger(cls):
101 """Get logger for the HPSpace class.
102
103 **Returns:**
104
105 logging.Logger: Logger instance configured for the HPSpace class
106
107 .. dropdown:: Usage Examples
108 :open:
109
110 .. code-block:: python
111
112 >>> logger = HPSpace.get_logger()
113 >>> logger.info("Space operation completed")
114 """
115 return logging.getLogger(__name__)
116
117 @property
118 def api_version(self) -> Optional[str]:
119 """Get the apiVersion from the Kubernetes resource.
120
121 **Returns:**
122
123 str or None: The API version of the Kubernetes resource, or None if raw_resource is not available
124
125 .. dropdown:: Usage Examples
126 :open:
127
128 .. code-block:: python
129
130 >>> space = HPSpace.get("my-space")
131 >>> print(f"API Version: {space.api_version}")
132 """
133 return self.raw_resource.get("apiVersion") if self.raw_resource else None
134
135 @property
136 def kind(self) -> Optional[str]:
137 """Get the kind from the Kubernetes resource.
138
139 **Returns:**
140
141 str or None: The kind of the Kubernetes resource, or None if raw_resource is not available
142
143 .. dropdown:: Usage Examples
144 :open:
145
146 .. code-block:: python
147
148 >>> space = HPSpace.get("my-space")
149 >>> print(f"Resource Kind: {space.kind}")
150 """
151 return self.raw_resource.get("kind") if self.raw_resource else None
152
153 @property
154 def metadata(self) -> Optional[Dict[str, Any]]:
155 """Get the metadata from the Kubernetes resource.
156
157 **Returns:**
158
159 Dict[str, Any] or None: The metadata section of the Kubernetes resource, or None if raw_resource is not available
160
161 .. dropdown:: Usage Examples
162 :open:
163
164 .. code-block:: python
165
166 >>> space = HPSpace.get("my-space")
167 >>> print(f"Creation Time: {space.metadata['creationTimestamp']}")
168 """
169 return self.raw_resource.get("metadata") if self.raw_resource else None
170
171 @property
172 def status(self) -> Optional[Dict[str, Any]]:
173 """Get the status from the Kubernetes resource.
174
175 **Returns:**
176
177 Dict[str, Any] or None: The status section of the Kubernetes resource, or None if raw_resource is not available
178
179 .. dropdown:: Usage Examples
180 :open:
181
182 .. code-block:: python
183
184 >>> space = HPSpace.get("my-space")
185 >>> conditions = space.status.get('conditions', [])
186 >>> for condition in conditions:
187 ... print(f"{condition['type']}: {condition['status']}")
188 """
189 return self.raw_resource.get("status") if self.raw_resource else None
190
191 @classmethod
192 def verify_kube_config(cls):
193 """Verify and load Kubernetes configuration.
194
195 Loads the Kubernetes configuration from the default kubeconfig location
196 and verifies compatibility with the cluster. This method is called
197 automatically by other methods that interact with the Kubernetes API.
198
199 **Raises:**
200
201 RuntimeError: If the kubeconfig cannot be loaded or is invalid
202
203 .. dropdown:: Usage Examples
204 :open:
205
206 .. code-block:: python
207
208 >>> # Verify kubeconfig before operations
209 >>> HPSpace.verify_kube_config()
210 """
211 if not cls.is_kubeconfig_loaded:
212 try:
213 config.load_kube_config()
214 cls.is_kubeconfig_loaded = True
215 verify_kubernetes_version_compatibility(cls.get_logger())
216 except Exception as e:
217 raise RuntimeError(f"Failed to load kubeconfig: {e}")
218
219 @staticmethod
220 def _extract_mig_profiles(resources: Optional[ResourceRequirements]) -> Set[str]:
221 """Extract MIG profile resource keys from resources without validation.
222
223 **Parameters:**
224
225 .. list-table::
226 :header-rows: 1
227 :widths: 20 20 60
228
229 * - Parameter
230 - Type
231 - Description
232 * - resources
233 - ResourceRequirements or None
234 - The resource requirements to extract MIG profiles from
235
236 **Returns:**
237
238 set: Set of MIG profile resource keys found in the resources
239 """
240 if not resources:
241 return set()
242
243 mig_profiles = set()
244
245 if resources.requests:
246 mig_profiles.update([
247 key for key in resources.requests.keys()
248 if key.startswith("nvidia.com/mig-")
249 ])
250
251 if resources.limits:
252 mig_profiles.update([
253 key for key in resources.limits.keys()
254 if key.startswith("nvidia.com/mig-")
255 ])
256
257 return mig_profiles
258
259 def _validate_and_extract_mig_profiles(self, resources: Optional[ResourceRequirements]) -> Set[str]:
260 """Validate MIG resources and extract MIG profiles.
261
262 **Parameters:**
263
264 .. list-table::
265 :header-rows: 1
266 :widths: 20 20 60
267
268 * - Parameter
269 - Type
270 - Description
271 * - resources
272 - ResourceRequirements or None
273 - The resource requirements to validate
274
275 **Returns:**
276
277 set: Set of MIG profile resource keys found in the resources
278
279 **Raises:**
280
281 RuntimeError: If MIG validation fails or profiles are invalid
282 """
283 if not resources:
284 return set()
285
286 # Validate requests
287 if resources.requests:
288 valid, err = validate_space_mig_resources(resources.requests)
289 if not valid:
290 raise RuntimeError(err)
291
292 # Validate limits
293 if resources.limits:
294 valid, err = validate_space_mig_resources(resources.limits)
295 if not valid:
296 raise RuntimeError(err)
297
298 # Extract MIG profiles
299 mig_profiles = self._extract_mig_profiles(resources)
300
301 # Validate that requests and limits use the same MIG profile
302 if len(mig_profiles) > 1:
303 raise RuntimeError(
304 "MIG profile mismatch: requests and limits must use the same MIG profile. "
305 f"Found: {', '.join(mig_profiles)}"
306 )
307
308 # Validate MIG profile exists in cluster
309 if mig_profiles:
310 mig_profile = list(mig_profiles)[0]
311 valid, err = validate_mig_profile_in_cluster(mig_profile)
312 if not valid:
313 raise RuntimeError(err)
314
315 return mig_profiles
316
[docs]
317 @_hyperpod_telemetry_emitter(Feature.HYPERPOD, "create_space")
318 @warn_if_addon_version_incompatible
319 def create(self, debug: bool = False):
320 """Create and submit the HyperPod Space to the Kubernetes cluster.
321
322 Creates a new space resource in the Kubernetes cluster based on the
323 configuration provided in the space config. Validates MIG profiles
324 if enabled and converts the configuration to the appropriate domain model.
325
326 **Parameters:**
327
328 .. list-table::
329 :header-rows: 1
330 :widths: 20 20 60
331
332 * - Parameter
333 - Type
334 - Description
335 * - debug
336 - bool, optional
337 - Enable debug logging (default: False)
338
339 **Raises:**
340
341 RuntimeError: If MIG profile validation fails or unsupported profiles are used
342 Exception: If the space creation fails or Kubernetes API call fails
343
344 .. dropdown:: Usage Examples
345 :open:
346
347 .. code-block:: python
348
349 >>> # Create a space with debug logging
350 >>> space = HPSpace(config=space_config)
351 >>> space.create(debug=True)
352
353 >>> # Create a space with default settings
354 >>> space.create()
355 """
356 self.verify_kube_config()
357
358 logger = self.get_logger()
359 logger = setup_logging(logger, debug)
360
361 # Validate and extract MIG profiles
362 self._validate_and_extract_mig_profiles(self.config.resources)
363
364 # Convert config to domain model
365 domain_config = self.config.to_domain()
366 config_body = domain_config["space_spec"]
367
368 logger.debug(
369 "Creating HyperPod Space with config:\n%s",
370 yaml.dump(config_body),
371 )
372
373 custom_api = client.CustomObjectsApi()
374
375 try:
376 custom_api.create_namespaced_custom_object(
377 group=SPACE_GROUP,
378 version=SPACE_VERSION,
379 namespace=self.config.namespace,
380 plural=SPACE_PLURAL,
381 body=config_body,
382 )
383 logger.debug(f"Successfully created HyperPod Space '{self.config.name}'!")
384 except Exception as e:
385 logger.error(f"Failed to create HyperPod Space {self.config.name}!")
386 handle_exception(e, self.config.name, self.config.namespace, debug=debug)
387
[docs]
388 @classmethod
389 @_hyperpod_telemetry_emitter(Feature.HYPERPOD, "list_spaces")
390 def list(cls, namespace: Optional[str] = None) -> List["HPSpace"]:
391 """List all HyperPod Spaces in the specified namespace created by the caller.
392
393 Retrieves all spaces that were either created by the current caller (based on
394 AWS STS identity) or are marked as 'Public' ownership type. Uses pagination
395 to handle large numbers of spaces efficiently.
396
397 **Parameters:**
398
399 .. list-table::
400 :header-rows: 1
401 :widths: 20 20 60
402
403 * - Parameter
404 - Type
405 - Description
406 * - namespace
407 - str, optional
408 - The Kubernetes namespace to list spaces from. If None, uses the default namespace from current context
409
410 **Returns:**
411
412 List[HPSpace]: List of HPSpace instances created by the caller or marked as public
413
414 **Raises:**
415
416 Exception: If the Kubernetes API call fails or spaces cannot be retrieved
417
418 .. dropdown:: Usage Examples
419 :open:
420
421 .. code-block:: python
422
423 >>> # List spaces in default namespace
424 >>> spaces = HPSpace.list()
425 >>> print(f"Found {len(spaces)} spaces")
426
427 >>> # List spaces in specific namespace
428 >>> spaces = HPSpace.list(namespace="my-namespace")
429 >>> for space in spaces:
430 ... print(f"Space: {space.config.name}")
431 """
432 cls.verify_kube_config()
433
434 if not namespace:
435 namespace = get_default_namespace()
436
437 # Get caller identity
438 sts_client = create_boto3_client('sts')
439 caller_identity = sts_client.get_caller_identity()
440 caller_arn = caller_identity['Arn']
441
442 custom_api = client.CustomObjectsApi()
443 spaces = []
444 continue_token = None
445
446 try:
447 while True:
448 response = custom_api.list_namespaced_custom_object(
449 group=SPACE_GROUP,
450 version=SPACE_VERSION,
451 namespace=namespace,
452 plural=SPACE_PLURAL,
453 _continue=continue_token
454 )
455
456 for item in response.get("items", []):
457 # Check if space was created by the caller or it's set as 'Public'
458 created_by = item.get('metadata', {}).get('annotations', {}).get('workspace.jupyter.org/created-by')
459 ownership_type = item.get('spec', {}).get('ownershipType', '')
460 if created_by == caller_arn or ownership_type == "Public":
461 config_data = map_kubernetes_response_to_model(item, SpaceConfigV1_1)
462 space_config = SpaceConfigV1_1(**config_data)
463
464 space = cls(
465 config=space_config,
466 raw_resource=item
467 )
468 spaces.append(space)
469
470 # Check if there are more pages
471 continue_token = response.get('metadata', {}).get('continue')
472 if not continue_token:
473 break
474
475 return spaces
476 except Exception as e:
477 handle_exception(e, "list", namespace)
478
[docs]
479 @classmethod
480 @_hyperpod_telemetry_emitter(Feature.HYPERPOD, "get_space")
481 def get(cls, name: str, namespace: str = None) -> "HPSpace":
482 """Get a specific HyperPod Space by name.
483
484 Retrieves a single space resource from the Kubernetes cluster and maps
485 the response to the SpaceConfig model for easy access to configuration
486 and status information.
487
488 **Parameters:**
489
490 .. list-table::
491 :header-rows: 1
492 :widths: 20 20 60
493
494 * - Parameter
495 - Type
496 - Description
497 * - name
498 - str
499 - The name of the space to retrieve
500 * - namespace
501 - str, optional
502 - The Kubernetes namespace. If None, uses the default namespace from current context
503
504 **Returns:**
505
506 HPSpace: The space instance with configuration and raw Kubernetes resource data
507
508 **Raises:**
509
510 Exception: If the space is not found or Kubernetes API call fails
511
512 .. dropdown:: Usage Examples
513 :open:
514
515 .. code-block:: python
516
517 >>> # Get space from default namespace
518 >>> space = HPSpace.get("my-space")
519 >>> print(f"Space status: {space.status}")
520
521 >>> # Get space from specific namespace
522 >>> space = HPSpace.get("my-space", namespace="production")
523 >>> print(f"Display name: {space.config.display_name}")
524 """
525 cls.verify_kube_config()
526
527 if not namespace:
528 namespace = get_default_namespace()
529
530 custom_api = client.CustomObjectsApi()
531
532 try:
533 response = custom_api.get_namespaced_custom_object(
534 group=SPACE_GROUP,
535 version=SPACE_VERSION,
536 namespace=namespace,
537 plural=SPACE_PLURAL,
538 name=name
539 )
540
541 # Use dynamic mapping based on SpaceConfig model
542 config_data = map_kubernetes_response_to_model(response, SpaceConfigV1_1)
543
544 space_config = SpaceConfigV1_1(**config_data)
545
546 return cls(
547 config=space_config,
548 raw_resource=response
549 )
550 except Exception as e:
551 handle_exception(e, name, namespace)
552
[docs]
553 @_hyperpod_telemetry_emitter(Feature.HYPERPOD, "delete_space")
554 def delete(self):
555 """Delete the HyperPod Space from the Kubernetes cluster.
556
557 Permanently removes the space resource from the Kubernetes cluster.
558 This operation cannot be undone and will terminate any running
559 workloads associated with the space.
560
561 **Raises:**
562
563 Exception: If the deletion fails or Kubernetes API call fails
564
565 .. dropdown:: Usage Examples
566 :open:
567
568 .. code-block:: python
569
570 >>> # Delete a space
571 >>> space = HPSpace.get("my-space")
572 >>> space.delete()
573 """
574 self.verify_kube_config()
575 logger = self.get_logger()
576
577 custom_api = client.CustomObjectsApi()
578
579 try:
580 custom_api.delete_namespaced_custom_object(
581 group=SPACE_GROUP,
582 version=SPACE_VERSION,
583 namespace=self.config.namespace,
584 plural=SPACE_PLURAL,
585 name=self.config.name
586 )
587 logger.debug(f"Successfully deleted HyperPod Space '{self.config.name}'!")
588 except Exception as e:
589 logger.error(f"Failed to delete HyperPod Space {self.config.name}!")
590 handle_exception(e, self.config.name, self.config.namespace)
591
[docs]
592 @_hyperpod_telemetry_emitter(Feature.HYPERPOD, "update_space")
593 @warn_if_addon_version_incompatible
594 def update(self, **kwargs):
595 """Update the HyperPod Space configuration.
596
597 Updates the space configuration with the provided parameters. Validates
598 MIG profiles if resource updates are requested and ensures compatibility
599 with the current node instance type. The configuration is always
600 reconstructed using the latest schema version (v1.1), preserving all
601 existing fields on the space.
602
603 **Parameters:**
604
605 .. list-table::
606 :header-rows: 1
607 :widths: 20 20 60
608
609 * - Parameter
610 - Type
611 - Description
612 * - **kwargs
613 - Any
614 - Configuration fields to update (e.g., desired_status="Stopped", display_name="New Name")
615
616 **Raises:**
617
618 RuntimeError: If MIG profile validation fails or unsupported profiles are used
619 Exception: If the update fails or Kubernetes API call fails
620
621 .. dropdown:: Usage Examples
622 :open:
623
624 .. code-block:: python
625
626 >>> # Update space status
627 >>> space = HPSpace.get("my-space")
628 >>> space.update(desired_status="Stopped")
629
630 >>> # Update display name and resources
631 >>> space.update(
632 ... display_name="Updated Space",
633 ... resources={"requests": {"cpu": "2", "memory": "4Gi"}}
634 ... )
635 """
636 self.verify_kube_config()
637 logger = self.get_logger()
638
639 # Validate MIG profile configuration
640 if "resources" in kwargs:
641 resources = kwargs["resources"]
642
643 if isinstance(resources, dict):
644 resources = ResourceRequirements(**resources)
645
646 # Validate and extract MIG profiles
647 mig_profiles = self._validate_and_extract_mig_profiles(resources)
648
649 # Remove existing MIG profiles if changing to a different one
650 if mig_profiles:
651 mig_profile = list(mig_profiles)[0]
652
653 existing_config = HPSpace.get(self.config.name, self.config.namespace).config
654 existing_mig_profiles = self._extract_mig_profiles(existing_config.resources)
655
656 if existing_mig_profiles and mig_profile not in existing_mig_profiles:
657 # Remove existing MIG profiles by setting to None
658 for existing_profile in existing_mig_profiles:
659 if existing_profile != mig_profile:
660 kwargs["resources"].setdefault("requests", {})[existing_profile] = None
661 kwargs["resources"].setdefault("limits", {})[existing_profile] = None
662
663 custom_api = client.CustomObjectsApi()
664
665 # Update space config with the input config
666 current_config = self.config.model_dump(by_alias=True)
667 # Convert any Pydantic model instances in kwargs to dicts for compatibility
668 for key, value in kwargs.items():
669 if isinstance(value, BaseModel):
670 kwargs[key] = value.model_dump(exclude_none=True)
671 current_config.update(kwargs)
672 self.config = SpaceConfigV1_1(**current_config)
673
674 # Convert to domain model and extract spec
675 domain_config = self.config.to_domain()
676 spec_updates = domain_config["space_spec"]["spec"]
677
678 try:
679 custom_api.patch_namespaced_custom_object(
680 group=SPACE_GROUP,
681 version=SPACE_VERSION,
682 namespace=self.config.namespace,
683 plural=SPACE_PLURAL,
684 name=self.config.name,
685 body={"spec": spec_updates}
686 )
687 logger.debug(f"Successfully updated HyperPod Space '{self.config.name}'!")
688 except Exception as e:
689 logger.error(f"Failed to update HyperPod Space {self.config.name}!")
690 handle_exception(e, self.config.name, self.config.namespace)
691
[docs]
692 @_hyperpod_telemetry_emitter(Feature.HYPERPOD, "start_space")
693 def start(self):
694 """Start the HyperPod Space by setting desired status to Running.
695
696 Convenience method that updates the space's desired status to "Running",
697 which will cause the Kubernetes operator to start the space workloads.
698
699 .. dropdown:: Usage Examples
700 :open:
701
702 .. code-block:: python
703
704 >>> # Start a space
705 >>> space = HPSpace.get("my-space")
706 >>> space.start()
707 """
708 self.update(desired_status="Running")
709
[docs]
710 @_hyperpod_telemetry_emitter(Feature.HYPERPOD, "stop_space")
711 def stop(self):
712 """Stop the HyperPod Space by setting desired status to Stopped.
713
714 Convenience method that updates the space's desired status to "Stopped",
715 which will cause the Kubernetes operator to stop the space workloads.
716
717 .. dropdown:: Usage Examples
718 :open:
719
720 .. code-block:: python
721
722 >>> # Stop a space
723 >>> space = HPSpace.get("my-space")
724 >>> space.stop()
725 """
726 self.update(desired_status="Stopped")
727
[docs]
728 def list_pods(self) -> List[str]:
729 """List all pods associated with this space.
730
731 Retrieves all Kubernetes pods that are labeled as belonging to this
732 space using the workspace-name label selector.
733
734 **Returns:**
735
736 List[str]: List of pod names associated with the space
737
738 **Raises:**
739
740 Exception: If the Kubernetes API call fails
741
742 .. dropdown:: Usage Examples
743 :open:
744
745 .. code-block:: python
746
747 >>> # List pods for a space
748 >>> space = HPSpace.get("my-space")
749 >>> pods = space.list_pods()
750 >>> print(f"Found {len(pods)} pods: {pods}")
751 """
752 self.verify_kube_config()
753 logger = self.get_logger()
754
755 v1 = client.CoreV1Api()
756
757 try:
758 pods = v1.list_namespaced_pod(
759 namespace=self.config.namespace,
760 label_selector=f"{SPACE_GROUP}/workspace-name={self.config.name}"
761 )
762 return [pod.metadata.name for pod in pods.items]
763 except Exception as e:
764 handle_exception(e, self.config.name, self.config.namespace)
765
[docs]
766 def get_logs(self, pod_name: Optional[str] = None, container: Optional[str] = None) -> str:
767 """Get logs from a pod associated with this space.
768
769 Retrieves logs from a specific pod and container. If no pod is specified,
770 uses the first available pod. If no container is specified, defaults to
771 the "workspace" container.
772
773 **Parameters:**
774
775 .. list-table::
776 :header-rows: 1
777 :widths: 20 20 60
778
779 * - Parameter
780 - Type
781 - Description
782 * - pod_name
783 - str, optional
784 - Name of the pod to get logs from. If None, gets logs from the first available pod
785 * - container
786 - str, optional
787 - Name of the container to get logs from. Defaults to "workspace"
788
789 **Returns:**
790
791 str: The pod logs as a string
792
793 **Raises:**
794
795 RuntimeError: If no pods are found for the space
796 Exception: If the Kubernetes API call fails
797
798 .. dropdown:: Usage Examples
799 :open:
800
801 .. code-block:: python
802
803 >>> # Get logs from default pod and container
804 >>> space = HPSpace.get("my-space")
805 >>> logs = space.get_logs()
806 >>> print(logs)
807
808 >>> # Get logs from specific pod and container
809 >>> logs = space.get_logs(pod_name="my-pod", container="sidecar")
810 """
811 self.verify_kube_config()
812 logger = self.get_logger()
813
814 if not pod_name:
815 pods = self.list_pods()
816 if not pods:
817 raise RuntimeError(f"No pods found for space '{self.config.name}'")
818 pod_name = pods[0]
819
820 if not container:
821 container = "workspace"
822
823 v1 = client.CoreV1Api()
824
825 try:
826 return v1.read_namespaced_pod_log(
827 name=pod_name,
828 namespace=self.config.namespace,
829 container=container
830 )
831 except Exception as e:
832 handle_exception(e, pod_name, self.config.namespace)
833
834 # Validates the {ide}-remote pattern: alphanumeric segments separated by single hyphens.
835 _remote_connection_type_regex = re.compile(r"^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*-remote$")
836
[docs]
837 @_hyperpod_telemetry_emitter(Feature.HYPERPOD, "create_space_access")
838 def create_space_access(self, connection_type: str = "vscode-remote") -> Dict[str, str]:
839 """Create a space access for this space.
840
841 Creates a space access resource that provides remote connection capabilities
842 to the space. Supports IDE remote development and web UI access types.
843
844 **Parameters:**
845
846 .. list-table::
847 :header-rows: 1
848 :widths: 20 20 60
849
850 * - Parameter
851 - Type
852 - Description
853 * - connection_type
854 - str, optional
855 - The connection type for remote access. Must be "web-ui" or follow the
856 '{ide}-remote' pattern (e.g. "vscode-remote", "kiro-remote", "cursor-remote").
857 Default: "vscode-remote"
858
859 **Returns:**
860
861 Dict[str, str]: Dictionary containing 'SpaceConnectionType' and 'SpaceConnectionUrl' keys
862
863 **Raises:**
864
865 ValueError: If connection_type is not "web-ui" or a valid '{ide}-remote' pattern
866 Exception: If the space access creation fails or Kubernetes API call fails
867
868 .. dropdown:: Usage Examples
869 :open:
870
871 .. code-block:: python
872
873 >>> # Create VS Code remote access
874 >>> space = HPSpace.get("my-space")
875 >>> access = space.create_space_access("vscode-remote")
876 >>> print(f"Connection URL: {access['SpaceConnectionUrl']}")
877
878 >>> # Create Kiro remote access
879 >>> access = space.create_space_access("kiro-remote")
880 >>> print(f"Connection URL: {access['SpaceConnectionUrl']}")
881
882 >>> # Create web UI access
883 >>> access = space.create_space_access("web-ui")
884 >>> print(f"Web UI URL: {access['SpaceConnectionUrl']}")
885 """
886 self.verify_kube_config()
887 logger = self.get_logger()
888
889 if connection_type != "web-ui" and not self._remote_connection_type_regex.match(connection_type):
890 raise ValueError(
891 f"--connection-type must be 'web-ui' or follow the '{{ide}}-remote' pattern "
892 f"(e.g. 'vscode-remote', 'kiro-remote', 'cursor-remote')."
893 )
894
895 config = {
896 "metadata": {
897 "namespace": self.config.namespace,
898 },
899 "spec": {
900 "workspaceName": self.config.name,
901 "workspaceConnectionType": connection_type,
902 }
903 }
904
905 custom_api = client.CustomObjectsApi()
906
907 try:
908 response = custom_api.create_namespaced_custom_object(
909 group=SPACE_ACCESS_GROUP,
910 version=SPACE_ACCESS_VERSION,
911 namespace=self.config.namespace,
912 plural=SPACE_ACCESS_PLURAL,
913 body=config
914 )
915 logger.debug(f"Successfully created space access for '{self.config.name}'!")
916 return {
917 "SpaceConnectionType": connection_type,
918 "SpaceConnectionUrl": response["status"]["workspaceConnectionUrl"]
919 }
920 except Exception as e:
921 logger.error(f"Failed to create space access for {self.config.name}!")
922 handle_exception(e, self.config.name, self.config.namespace)
923
[docs]
924 @_hyperpod_telemetry_emitter(Feature.HYPERPOD, "portforward_space")
925 def portforward_space(self, local_port: str, remote_port: str = DEFAULT_SPACE_PORT):
926 """Forward local port to the space pod for development access.
927
928 Creates a port forwarding connection from a local port to a remote port
929 on the space pod, enabling direct access to services running inside the
930 space.
931
932 **Parameters:**
933
934 .. list-table::
935 :header-rows: 1
936 :widths: 20 20 60
937
938 * - Parameter
939 - Type
940 - Description
941 * - local_port
942 - str
943 - The local port to forward from
944 * - remote_port
945 - str, optional
946 - The remote port on the space pod to forward to (default: DEFAULT_SPACE_PORT)
947
948 **Raises:**
949
950 RuntimeError: If no pods are found for the space or if the space is not in Available status
951 KeyboardInterrupt: When the user stops the port forwarding with Ctrl+C
952 Exception: If the port forwarding setup fails or Kubernetes API call fails
953
954 .. dropdown:: Usage Examples
955 :open:
956
957 .. code-block:: python
958
959 >>> # Forward local port 8080 to default remote port
960 >>> space = HPSpace.get("myspace")
961 >>> space.portforward_space("8080")
962
963 >>> # Forward local port 3000 to remote port 8888
964 >>> space.portforward_space("3000", "8888")
965
966 >>> # Access forwarded service (in another terminal)
967 >>> # curl http://localhost:8080
968 """
969
970 self.verify_kube_config()
971 logger = self.get_logger()
972
973 # Check if space is in Available status
974 if self.status and self.status.get("conditions"):
975 is_available = False
976 for condition in self.status["conditions"]:
977 if condition.get("type") == "Available" and condition.get("status") == "True":
978 is_available = True
979 break
980
981 if not is_available:
982 raise RuntimeError(f"Space '{self.config.name}' is not in Available status. Port forwarding is only allowed for available spaces.")
983
984 pods = self.list_pods()
985 if not pods:
986 raise RuntimeError(f"No pods found for space '{self.config.name}'")
987
988 pod_name = pods[0]
989 pod = Pod.get(name=pod_name, namespace=self.config.namespace)
990 pf = pod.portforward(remote_port=int(remote_port), local_port=int(local_port))
991
992 logger.debug(f"Forwarding from local port {local_port} to space pod: {pod_name}.")
993
994 try:
995 pf.run_forever()
996 except KeyboardInterrupt:
997 logger.debug("Stopping space port forward...")
998 finally:
999 pf.stop()