1from pydantic import BaseModel, ConfigDict, Field, field_validator
2from typing import Optional, List, Dict, Literal, Any
3from enum import Enum
4
5
[docs]
6class OwnershipType(str, Enum):
7 PUBLIC = "Public"
8 OWNER_ONLY = "OwnerOnly"
9
10
[docs]
11class DesiredStatus(str, Enum):
12 RUNNING = "Running"
13 STOPPED = "Stopped"
14
15
[docs]
16class VolumeSpec(BaseModel):
17 """VolumeSpec defines a volume to mount from an existing PVC"""
18 name: str = Field(
19 description="Name is a unique identifier for this volume within the pod (maps to pod.spec.volumes[].name)",
20 min_length=1
21 )
22 mount_path: str = Field(
23 alias="mountPath",
24 description="MountPath is the path where the volume should be mounted (Unix-style path, e.g. /data)",
25 min_length=1
26 )
27 persistent_volume_claim_name: str = Field(
28 alias="persistentVolumeClaimName",
29 description="PersistentVolumeClaimName is the name of the existing PVC to mount",
30 min_length=1
31 )
32
33
[docs]
34class ContainerConfig(BaseModel):
35 """ContainerConfig defines container command and args configuration"""
36 command: Optional[List[str]] = Field(
37 default=None,
38 description="Command specifies the container command"
39 )
40 args: Optional[List[str]] = Field(
41 default=None,
42 description="Args specifies the container arguments"
43 )
44
45
[docs]
46class TemplateRef(BaseModel):
47 """TemplateRef defines a reference to a WorkspaceTemplate"""
48 name: str = Field(
49 description="Name of the WorkspaceTemplate"
50 )
51 namespace: Optional[str] = Field(
52 default=None,
53 description="Namespace where the WorkspaceTemplate is located"
54 )
55
56
[docs]
57class IdleDetectionSpec(BaseModel):
58 """IdleDetectionSpec defines idle detection methods"""
59 http_get: Optional[Dict[str, Any]] = Field(
60 default=None,
61 alias="httpGet",
62 description="HTTPGet specifies the HTTP request to perform for idle detection"
63 )
64
65
[docs]
66class IdleShutdownSpec(BaseModel):
67 """IdleShutdownSpec defines idle shutdown configuration"""
68 enabled: bool = Field(
69 description="Enabled indicates if idle shutdown is enabled"
70 )
71 idle_timeout_in_minutes: int = Field(
72 alias="idleTimeoutInMinutes",
73 description="IdleTimeoutInMinutes specifies idle timeout in minutes",
74 ge=1
75 )
76 detection: IdleDetectionSpec = Field(
77 description="Detection specifies how to detect idle state"
78 )
79
80
[docs]
81class StorageSpec(BaseModel):
82 """StorageSpec defines the storage configuration for Workspace"""
83 storage_class_name: Optional[str] = Field(
84 default=None,
85 alias="storageClassName",
86 description="StorageClassName specifies the storage class to use for persistent storage"
87 )
88 size: Optional[str] = Field(
89 default="10Gi",
90 description="Size specifies the size of the persistent volume. Supports standard Kubernetes resource quantities (e.g., '10Gi', '500Mi', '1Ti'). Integer values without units are interpreted as bytes"
91 )
92 mount_path: Optional[str] = Field(
93 default="/home",
94 alias="mountPath",
95 description="MountPath specifies where to mount the persistent volume in the container. Default is /home/jovyan (jovyan is the standard user in Jupyter images)"
96 )
97
98
[docs]
99class ResourceRequirements(BaseModel):
100 """ResourceRequirements describes the compute resource requirements"""
101 requests: Optional[Dict[str, Optional[str]]] = Field(
102 default=None,
103 description="Requests describes the minimum amount of compute resources required. If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, otherwise to an implementation-defined value. Requests cannot exceed Limits."
104 )
105 limits: Optional[Dict[str, Optional[str]]] = Field(
106 default=None,
107 description="Limits describes the maximum amount of compute resources allowed."
108 )
109
110
[docs]
111class SpaceConfig(BaseModel):
112 """SpaceConfig defines the desired state of a Space"""
113 model_config = ConfigDict(extra="forbid")
114
115 name: str = Field(
116 description="Space name",
117 min_length=1,
118 max_length=63,
119 pattern=r'^[a-z0-9]([-a-z0-9]*[a-z0-9])?$'
120 )
121 display_name: str = Field(
122 alias="display_name",
123 description="Display Name of the space",
124 min_length=1
125 )
126 namespace: str = Field(
127 default="default",
128 description="Kubernetes namespace",
129 min_length=1
130 )
131 image: Optional[str] = Field(
132 default=None,
133 description="Image specifies the container image to use"
134 )
135 desired_status: Optional[DesiredStatus] = Field(
136 default=None,
137 alias="desired_status",
138 description="DesiredStatus specifies the desired operational status"
139 )
140 ownership_type: Optional[OwnershipType] = Field(
141 default=None,
142 alias="ownership_type",
143 description="OwnershipType specifies who can modify the space. 'Public' means anyone with RBAC permissions can update/delete the space. 'OwnerOnly' means only the creator can update/delete the space."
144 )
145 resources: Optional[ResourceRequirements] = Field(
146 default=None,
147 description="Resources specifies the resource requirements"
148 )
149 storage: Optional[StorageSpec] = Field(
150 default=None,
151 description="Storage specifies the storage configuration"
152 )
153 volumes: Optional[List[VolumeSpec]] = Field(
154 default=None,
155 description="Volumes specifies additional volumes to mount from existing PersistentVolumeClaims"
156 )
157 container_config: Optional[ContainerConfig] = Field(
158 default=None,
159 alias="container_config",
160 description="ContainerConfig specifies container command and args configuration"
161 )
162 node_selector: Optional[Dict[str, str]] = Field(
163 default=None,
164 alias="node_selector",
165 description="NodeSelector specifies node selection constraints for the space pod (JSON string)"
166 )
167 affinity: Optional[Dict[str, Any]] = Field(
168 default=None,
169 description="Affinity specifies node affinity and anti-affinity rules for the space pod (JSON string)"
170 )
171 tolerations: Optional[List[Dict[str, Any]]] = Field(
172 default=None,
173 description="Tolerations specifies tolerations for the space pod to schedule on nodes with matching taints (JSON string)"
174 )
175 lifecycle: Optional[Dict[str, Any]] = Field(
176 default=None,
177 description="Lifecycle specifies actions that the management system should take in response to container lifecycle events (JSON string)"
178 )
179 template_ref: Optional[TemplateRef] = Field(
180 default=None,
181 alias="template_ref",
182 description="TemplateRef references a WorkspaceTemplate to use as base configuration. When set, template provides defaults and workspace spec fields act as overrides"
183 )
184 idle_shutdown: Optional[IdleShutdownSpec] = Field(
185 default=None,
186 alias="idle_shutdown",
187 description="IdleShutdown specifies idle shutdown configuration"
188 )
189 app_type: Optional[str] = Field(
190 default=None,
191 alias="app_type",
192 description="AppType specifies the application type for this workspace"
193 )
194 service_account_name: Optional[str] = Field(
195 default=None,
196 alias="service_account_name",
197 description="ServiceAccountName specifies the name of the ServiceAccount to use for the workspace pod"
198 )
199
[docs]
200 @field_validator('volumes')
201 def validate_no_duplicate_volumes(cls, v):
202 """Validate no duplicate volume names or mount paths."""
203 if not v:
204 return v
205
206 # Check for duplicate volume names
207 names = [vol.name for vol in v]
208 if len(names) != len(set(names)):
209 raise ValueError("Duplicate volume names found")
210
211 # Check for duplicate mount paths
212 mount_paths = [vol.mount_path for vol in v]
213 if len(mount_paths) != len(set(mount_paths)):
214 raise ValueError("Duplicate mount paths found")
215
216 return v
217
[docs]
218 def to_domain(self) -> Dict:
219 """
220 Convert flat config to domain model for space creation
221 """
222 # Create the space spec
223 spec = {
224 "displayName": self.display_name
225 }
226
227 # Add optional spec fields
228 if self.image is not None:
229 spec["image"] = self.image
230 if self.desired_status is not None:
231 spec["desiredStatus"] = self.desired_status.value
232 if self.ownership_type is not None:
233 spec["ownershipType"] = self.ownership_type.value
234 if self.resources is not None:
235 spec["resources"] = self.resources.model_dump(exclude_none=True)
236 if self.storage is not None:
237 spec["storage"] = self.storage.model_dump(exclude_none=True, by_alias=True)
238 if self.volumes is not None:
239 spec["volumes"] = [vol.model_dump(exclude_none=True, by_alias=True) for vol in self.volumes]
240 if self.container_config is not None:
241 spec["containerConfig"] = self.container_config.model_dump(exclude_none=True)
242 if self.node_selector is not None:
243 spec["nodeSelector"] = self.node_selector
244 if self.affinity is not None:
245 spec["affinity"] = self.affinity
246 if self.tolerations is not None:
247 spec["tolerations"] = self.tolerations
248 if self.lifecycle is not None:
249 spec["lifecycle"] = self.lifecycle
250 if self.template_ref is not None:
251 spec["templateRef"] = self.template_ref.model_dump(exclude_none=True, by_alias=True)
252 if self.idle_shutdown is not None:
253 spec["idleShutdown"] = self.idle_shutdown.model_dump(exclude_none=True, by_alias=True)
254 if self.app_type is not None:
255 spec["appType"] = self.app_type
256 if self.service_account_name is not None:
257 spec["serviceAccountName"] = self.service_account_name
258
259 # Create metadata
260 metadata = {"name": self.name}
261 if self.namespace is not None:
262 metadata["namespace"] = self.namespace
263
264 # Create the complete space configuration
265 space_config = {
266 "apiVersion": "workspace.jupyter.org/v1alpha1",
267 "kind": "Workspace",
268 "metadata": metadata,
269 "spec": spec
270 }
271
272 return {
273 "name": self.name,
274 "namespace": self.namespace,
275 "space_spec": space_config
276 }