/ server / opensandbox_server / api / schema.py
schema.py
  1  # Copyright 2025 Alibaba Group Holding Ltd.
  2  #
  3  # Licensed under the Apache License, Version 2.0 (the "License");
  4  # you may not use this file except in compliance with the License.
  5  # You may obtain a copy of the License at
  6  #
  7  #     http://www.apache.org/licenses/LICENSE-2.0
  8  #
  9  # Unless required by applicable law or agreed to in writing, software
 10  # distributed under the License is distributed on an "AS IS" BASIS,
 11  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 12  # See the License for the specific language governing permissions and
 13  # limitations under the License.
 14  
 15  """
 16  Pydantic schemas for OpenSandbox Lifecycle API.
 17  
 18  This module defines data models based on the OpenAPI specification
 19  for request/response validation and serialization.
 20  """
 21  
 22  from datetime import datetime
 23  from typing import Dict, List, Literal, Optional
 24  
 25  from pydantic import BaseModel, Field, RootModel, model_validator
 26  
 27  
 28  # ============================================================================
 29  # Image Specification
 30  # ============================================================================
 31  
 32  class ImageAuth(BaseModel):
 33      """
 34      Registry authentication credentials for private container registries.
 35      """
 36      username: str = Field(..., description="Registry username or service account")
 37      password: str = Field(..., description="Registry password or authentication token")
 38  
 39  
 40  class ImageSpec(BaseModel):
 41      """
 42      Container image specification for sandbox provisioning.
 43  
 44      Supports public registry images and private registry images with authentication.
 45      """
 46      uri: str = Field(
 47          ...,
 48          description="Container image URI in standard format (e.g., 'python:3.11', 'gcr.io/my-project/app:v1.0')",
 49      )
 50      auth: Optional[ImageAuth] = Field(
 51          None,
 52          description="Registry authentication credentials (required for private registries)",
 53      )
 54  
 55  
 56  class PlatformSpec(BaseModel):
 57      """
 58      Runtime platform constraint for scheduling/provisioning.
 59      """
 60  
 61      os: str = Field(
 62          ...,
 63          description="Target operating system (for example 'linux').",
 64      )
 65      arch: str = Field(
 66          ...,
 67          description="Target CPU architecture (for example 'amd64' or 'arm64').",
 68      )
 69  
 70  
 71  # ============================================================================
 72  # Resource Limits
 73  # ============================================================================
 74  
 75  class ResourceLimits(RootModel[Dict[str, str]]):
 76      """
 77      Runtime resource constraints as key-value pairs.
 78  
 79      Similar to Kubernetes resource specifications, allows flexible definition
 80      of resource limits. Common resource types include cpu, memory, and gpu.
 81      """
 82      root: Dict[str, str] = Field(
 83          default_factory=dict,
 84          example={"cpu": "500m", "memory": "512Mi", "gpu": "1"},
 85      )
 86  
 87  
 88  class NetworkRule(BaseModel):
 89      """
 90      Egress rule: allow/deny a specific domain or wildcard.
 91      """
 92  
 93      action: str = Field(..., description="Whether to allow or deny matching targets (allow | deny).")
 94      target: str = Field(
 95          ...,
 96          description="FQDN or wildcard domain (e.g., 'example.com', '*.example.com').",
 97          min_length=1,
 98      )
 99  
100      class Config:
101          populate_by_name = True
102  
103  
104  class NetworkPolicy(BaseModel):
105      """
106      Egress network policy matching the sidecar /policy payload.
107      """
108  
109      default_action: Optional[str] = Field(
110          default=None,
111          alias="defaultAction",
112          description="Default action when no egress rule matches (allow | deny). If omitted, sidecar defaults to deny.",
113      )
114      egress: list[NetworkRule] = Field(
115          default_factory=list,
116          description="Ordered egress rules. Empty/omitted yields allow-all at startup.",
117      )
118  
119      class Config:
120          populate_by_name = True
121  
122  
123  # ============================================================================
124  # Volume Definitions
125  # ============================================================================
126  
127  
128  class Host(BaseModel):
129      """
130      Host path bind mount backend.
131  
132      Maps a directory on the host filesystem into the container.
133      Only available when the runtime supports host mounts.
134  
135      Security note: Host paths are restricted by server-side allowlist.
136      Users must specify paths under permitted prefixes.
137      """
138  
139      path: str = Field(
140          ...,
141          description="Absolute path on the host filesystem to mount.",
142          pattern=r"^(/|[A-Za-z]:[\\/])",
143      )
144  
145  
146  class PVC(BaseModel):
147      """
148      Platform-managed named volume backend.
149  
150      A runtime-neutral abstraction for referencing a platform-managed named volume.
151      If ``createIfNotExists`` is true (the default) and the volume does not
152      yet exist, it will be created automatically using the provisioning hints below.
153  
154      - Kubernetes: maps to a PersistentVolumeClaim in the same namespace.
155      - Docker: maps to a Docker named volume (created via ``docker volume create``).
156      """
157  
158      claim_name: str = Field(
159          ...,
160          alias="claimName",
161          description=(
162              "Name of the volume on the target platform. "
163              "In Kubernetes this is the PVC name; in Docker this is the named volume name."
164          ),
165          pattern=r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
166          max_length=253,
167      )
168  
169      create_if_not_exists: bool = Field(
170          True,
171          alias="createIfNotExists",
172          description=(
173              "When true, the volume is automatically created if it does not exist. "
174              "When false, referencing a non-existent volume fails with an error."
175          ),
176      )
177      delete_on_sandbox_termination: bool = Field(
178          False,
179          alias="deleteOnSandboxTermination",
180          description=(
181              "When true, the volume is automatically removed when the sandbox is "
182              "deleted. Only applies to volumes that were auto-created by the server "
183              "(Docker only). Pre-existing volumes are never removed. Has no effect "
184              "on Kubernetes PVCs, whose lifecycle is managed by the StorageClass "
185              "reclaim policy."
186          ),
187      )
188  
189      # Provisioning hints — used only when auto-creating a new volume.
190      # Ignored if the volume already exists on the platform.
191      storage_class: Optional[str] = Field(
192          None,
193          alias="storageClass",
194          description=(
195              "Kubernetes StorageClass name for auto-created PVCs. "
196              "None means use the cluster default. Ignored for Docker volumes."
197          ),
198      )
199      storage: Optional[str] = Field(
200          None,
201          description=(
202              "Storage capacity request for auto-created PVCs (e.g. '1Gi', '10Gi'). "
203              "Defaults to server-side configured value when omitted. "
204              "Ignored for Docker volumes."
205          ),
206          pattern=r"^\d+(\.\d+)?(Ki|Mi|Gi|Ti|Pi|Ei)?$",
207      )
208      access_modes: Optional[List[str]] = Field(
209          None,
210          alias="accessModes",
211          description=(
212              "Access modes for auto-created PVCs (e.g. ['ReadWriteOnce']). "
213              "Defaults to ['ReadWriteOnce'] when omitted. Ignored for Docker volumes."
214          ),
215      )
216  
217      class Config:
218          populate_by_name = True
219  
220  
221  class OSSFS(BaseModel):
222      """
223      Alibaba Cloud OSS mount backend via ossfs.
224  
225      The runtime mounts a host-side OSS path under ``storage.ossfs_mount_root``
226      and then bind-mounts the resolved path into the sandbox container. Prefix
227      selection is expressed via ``Volume.subPath``.
228      In Docker runtime, OSSFS backend requires the server host to be Linux with FUSE support.
229      """
230  
231      bucket: str = Field(
232          ...,
233          description="OSS bucket name.",
234          min_length=3,
235          max_length=63,
236      )
237      endpoint: str = Field(
238          ...,
239          description="OSS endpoint, e.g. 'oss-cn-hangzhou.aliyuncs.com'.",
240          min_length=1,
241      )
242      version: Literal["1.0", "2.0"] = Field(
243          "2.0",
244          description="ossfs major version used by runtime mount integration.",
245      )
246      options: Optional[List[str]] = Field(
247          None,
248          description=(
249              "Additional ossfs mount options. Runtime encodes options by version: "
250              "1.0 => 'ossfs ... -o <option>', 2.0 => 'ossfs2 config line --<option>'. "
251              "Provide raw option payloads without leading '-'."
252          ),
253      )
254      access_key_id: Optional[str] = Field(
255          None,
256          alias="accessKeyId",
257          description="OSS access key ID for inline credentials mode.",
258          min_length=1,
259      )
260      access_key_secret: Optional[str] = Field(
261          None,
262          alias="accessKeySecret",
263          description="OSS access key secret for inline credentials mode.",
264          min_length=1,
265      )
266      class Config:
267          populate_by_name = True
268  
269      @model_validator(mode="after")
270      def validate_inline_credentials(self) -> "OSSFS":
271          """Ensure inline credentials are provided for current OSSFS mode."""
272          if not self.access_key_id or not self.access_key_secret:
273              raise ValueError(
274                  "OSSFS inline credentials are required: accessKeyId and accessKeySecret."
275              )
276          return self
277  
278  
279  class Volume(BaseModel):
280      """
281      Storage mount definition for a sandbox.
282  
283      Each volume entry contains:
284      - A unique name identifier
285      - Exactly one backend struct (host, pvc, etc.) with backend-specific fields
286      - Common mount settings (mountPath, readOnly, subPath)
287      """
288  
289      name: str = Field(
290          ...,
291          description="Unique identifier for the volume within the sandbox.",
292          pattern=r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
293          max_length=63,
294      )
295      host: Optional[Host] = Field(
296          None,
297          description="Host path bind mount backend.",
298      )
299      pvc: Optional[PVC] = Field(
300          None,
301          description="Platform-managed named volume backend (PVC in Kubernetes, named volume in Docker).",
302      )
303      ossfs: Optional[OSSFS] = Field(
304          None,
305          description="OSSFS mount backend.",
306      )
307      mount_path: str = Field(
308          ...,
309          alias="mountPath",
310          description="Absolute path inside the container where the volume is mounted.",
311          pattern=r"^/.*",
312      )
313      read_only: bool = Field(
314          False,
315          alias="readOnly",
316          description="If true, the volume is mounted as read-only. Defaults to false (read-write).",
317      )
318      sub_path: Optional[str] = Field(
319          None,
320          alias="subPath",
321          description="Optional subdirectory under the backend path to mount.",
322      )
323  
324      class Config:
325          populate_by_name = True
326  
327      @model_validator(mode="after")
328      def validate_exactly_one_backend(self) -> "Volume":
329          """Ensure exactly one backend type is specified."""
330          backends = [self.host, self.pvc, self.ossfs]
331          specified = [b for b in backends if b is not None]
332          if len(specified) == 0:
333              raise ValueError("Exactly one backend (host, pvc, ossfs) must be specified, but none was provided.")
334          if len(specified) > 1:
335              raise ValueError("Exactly one backend (host, pvc, ossfs) must be specified, but multiple were provided.")
336          return self
337  
338  
339  # ============================================================================
340  # Sandbox Status
341  # ============================================================================
342  
343  class SandboxStatus(BaseModel):
344      """
345      Detailed status information with lifecycle state and transition details.
346      """
347      state: str = Field(
348          ...,
349          description="Current lifecycle state (Pending, Running, Pausing, Paused, Stopping, Terminated, Failed)",
350      )
351      reason: Optional[str] = Field(
352          None,
353          description="Short machine-readable reason code for the current state",
354      )
355      message: Optional[str] = Field(
356          None,
357          description="Human-readable message describing the current state or reason for state transition",
358      )
359      last_transition_at: Optional[datetime] = Field(
360          None,
361          alias="lastTransitionAt",
362          description="Timestamp of the last state transition",
363      )
364  
365      class Config:
366          populate_by_name = True
367  
368  
369  # ============================================================================
370  # Sandbox Models
371  # ============================================================================
372  
373  class CreateSandboxRequest(BaseModel):
374      """
375      Request to create a new sandbox from a container image.
376      """
377      image: ImageSpec = Field(..., description="Container image specification for the sandbox")
378      platform: Optional[PlatformSpec] = Field(
379          None,
380          description=(
381              "Optional platform constraint for sandbox scheduling/runtime selection. "
382              "If omitted, runtime default behavior applies (runtime-specific and not a fixed "
383              "architecture guarantee). If specified, runtime must satisfy this platform or fail "
384              "explicitly."
385          ),
386      )
387      timeout: Optional[int] = Field(
388          None,
389          ge=60,
390          description=(
391              "Sandbox timeout in seconds (minimum 60). "
392              "The maximum is controlled by server.max_sandbox_timeout_seconds. "
393              "When omitted or null, the sandbox will not auto-terminate and must be deleted explicitly. "
394              "Note: manual cleanup support is runtime-dependent; Kubernetes providers may reject "
395              "null timeout when the workload provider does not support non-expiring sandboxes."
396          ),
397      )
398      resource_limits: ResourceLimits = Field(
399          ...,
400          alias="resourceLimits",
401          description="Runtime resource constraints for the sandbox instance",
402      )
403      env: Optional[Dict[str, Optional[str]]] = Field(
404          None,
405          description="Environment variables to inject into the sandbox runtime",
406      )
407      metadata: Optional[Dict[str, str]] = Field(
408          None,
409          description="Custom key-value metadata for management, filtering, and tagging",
410      )
411      entrypoint: List[str] = Field(
412          ...,
413          min_length=1,
414          description="The command to execute as the sandbox's entry process",
415          example=["python", "/app/main.py"],
416      )
417      network_policy: Optional[NetworkPolicy] = Field(
418          None,
419          alias="networkPolicy",
420          description=(
421              "Optional outbound network policy. Shape matches the egress sidecar /policy endpoint. "
422              "Empty/omitted means allow-all until updated."
423          ),
424      )
425      secure_access: bool = Field(
426          False,
427          alias="secureAccess",
428          description=(
429              "Opts the sandbox into secured access for endpoint access. "
430              "Currently supported only for Kubernetes sandboxes exposed through ingress gateway mode. "
431              "When enabled, the server provisions access credentials and returns required endpoint headers."
432          ),
433      )
434      volumes: Optional[List[Volume]] = Field(
435          None,
436          description=(
437              "Storage mounts for the sandbox. Each volume entry specifies a named backend-specific "
438              "storage source and common mount settings. Exactly one backend type must be specified per volume entry."
439          ),
440      )
441      extensions: Optional[Dict[str, str]] = Field(
442          None,
443          description="Opaque container for provider-specific or transient parameters not covered by the core API",
444      )
445  
446      class Config:
447          populate_by_name = True
448  
449  
450  class CreateSandboxResponse(BaseModel):
451      """
452      Response from creating a new sandbox.
453  
454      Contains essential information without image and updatedAt.
455      """
456      id: str = Field(..., description="Unique sandbox identifier")
457      status: SandboxStatus = Field(..., description="Current lifecycle status and detailed state information")
458      metadata: Optional[Dict[str, str]] = Field(None, description="Custom metadata from creation request")
459      platform: Optional[PlatformSpec] = Field(
460          None,
461          description=(
462              "Platform constraint echoed from request or workload template. "
463              "Null when no scheduling constraint is provided."
464          ),
465      )
466      expires_at: Optional[datetime] = Field(
467          None,
468          alias="expiresAt",
469          description="Timestamp when sandbox will auto-terminate. Null when manual cleanup is enabled.",
470      )
471      created_at: datetime = Field(..., alias="createdAt", description="Sandbox creation timestamp")
472      entrypoint: List[str] = Field(..., description="Entry process specification from creation request")
473  
474      class Config:
475          populate_by_name = True
476  
477  
478  class Sandbox(BaseModel):
479      """
480      Runtime execution environment provisioned from a container image.
481  
482      This is the complete representation of the sandbox resource.
483      """
484      id: str = Field(..., description="Unique sandbox identifier")
485      image: ImageSpec = Field(..., description="Container image specification used to provision this sandbox")
486      platform: Optional[PlatformSpec] = Field(
487          None,
488          description=(
489              "Platform constraint echoed from request or workload template. "
490              "Null when no scheduling constraint is provided."
491          ),
492      )
493      status: SandboxStatus = Field(..., description="Current lifecycle status and detailed state information")
494      metadata: Optional[Dict[str, str]] = Field(None, description="Custom metadata from creation request")
495      entrypoint: List[str] = Field(..., description="The command to execute as the sandbox's entry process")
496      expires_at: Optional[datetime] = Field(
497          None,
498          alias="expiresAt",
499          description="Timestamp when sandbox will auto-terminate. Null when manual cleanup is enabled.",
500      )
501      created_at: datetime = Field(..., alias="createdAt", description="Sandbox creation timestamp")
502  
503      class Config:
504          populate_by_name = True
505  
506  
507  # ============================================================================
508  # List Sandboxes
509  # ============================================================================
510  
511  class SandboxFilter(BaseModel):
512      """
513      Filtering criteria for listing sandboxes.
514      """
515      state: Optional[List[str]] = Field(
516          None,
517          min_length=1,
518          description="Filter by lifecycle state (status.state) - supports OR logic",
519      )
520      metadata: Optional[Dict[str, str]] = Field(
521          None,
522          description="Filter by metadata key-value pairs (AND logic)",
523      )
524  
525  
526  class PaginationRequest(BaseModel):
527      """
528      Pagination parameters for list requests.
529      """
530      page: int = Field(1, ge=1, description="Page number")
531      page_size: int = Field(
532          20,
533          ge=1,
534          le=200,
535          alias="pageSize",
536          description="Number of items per page",
537      )
538  
539      class Config:
540          populate_by_name = True
541  
542  
543  class ListSandboxesRequest(BaseModel):
544      """
545      Request body for complex listing queries.
546      """
547      filter: SandboxFilter = Field(
548          default_factory=SandboxFilter,
549          description="Filtering criteria (all conditions combined with AND logic)",
550      )
551      pagination: Optional[PaginationRequest] = Field(None, description="Pagination parameters")
552  
553  
554  class PaginationInfo(BaseModel):
555      """
556      Pagination metadata for list responses.
557      """
558      page: int = Field(..., ge=1, description="Current page number")
559      page_size: int = Field(..., ge=1, alias="pageSize", description="Number of items per page")
560      total_items: int = Field(..., ge=0, alias="totalItems", description="Total number of items matching the filter")
561      total_pages: int = Field(..., ge=0, alias="totalPages", description="Total number of pages")
562      has_next_page: bool = Field(..., alias="hasNextPage", description="Whether there are more pages after the current one")
563  
564      class Config:
565          populate_by_name = True
566  
567  
568  class ListSandboxesResponse(BaseModel):
569      """
570      Paginated collection of sandboxes.
571      """
572      items: List[Sandbox] = Field(..., description="List of sandboxes")
573      pagination: PaginationInfo = Field(..., description="Pagination metadata")
574  
575  
576  # ============================================================================
577  # Renew Expiration
578  # ============================================================================
579  
580  class RenewSandboxExpirationRequest(BaseModel):
581      """
582      Request to renew sandbox expiration time.
583      """
584      expires_at: datetime = Field(
585          ...,
586          alias="expiresAt",
587          description="New absolute expiration time in UTC (RFC 3339 format). Must be in the future.",
588      )
589  
590      class Config:
591          populate_by_name = True
592  
593  
594  class RenewSandboxExpirationResponse(BaseModel):
595      """
596      Response for renewing sandbox expiration.
597      """
598      expires_at: datetime = Field(
599          ...,
600          alias="expiresAt",
601          description="The new absolute expiration time in UTC (RFC 3339 format)",
602      )
603  
604      class Config:
605          populate_by_name = True
606  
607  
608  # ============================================================================
609  # Endpoint
610  # ============================================================================
611  
612  class Endpoint(BaseModel):
613      """
614      Endpoint for accessing a service running in the sandbox.
615      """
616      endpoint: str = Field(
617          ...,
618          description="Public endpoint string (host[:port]/path) exposed for the sandbox service",
619      )
620      headers: Optional[dict[str, str]] = Field(
621          default=None,
622          description="Optional headers required when accessing the endpoint (e.g., for header-based routing).",
623      )
624  
625  
626  # ============================================================================
627  # Error Response
628  # ============================================================================
629  
630  class ErrorResponse(BaseModel):
631      """
632      Standard error response for all non-2xx HTTP responses.
633  
634      HTTP status code indicates the error category; code and message provide details.
635      """
636      code: str = Field(
637          ...,
638          description="Machine-readable error code (e.g., INVALID_REQUEST, NOT_FOUND, INTERNAL_ERROR)",
639      )
640      message: str = Field(
641          ...,
642          description="Human-readable error message describing what went wrong and how to fix it",
643      )
644  
645  
646  # ============================================================================
647  # Pool Models
648  # ============================================================================
649  
650  class PoolCapacitySpec(BaseModel):
651      """
652      Capacity configuration that controls the size of the resource pool.
653      """
654      buffer_max: int = Field(
655          ...,
656          alias="bufferMax",
657          ge=0,
658          description="Maximum number of nodes kept in the warm buffer.",
659      )
660      buffer_min: int = Field(
661          ...,
662          alias="bufferMin",
663          ge=0,
664          description="Minimum number of nodes that must remain in the buffer.",
665      )
666      pool_max: int = Field(
667          ...,
668          alias="poolMax",
669          ge=0,
670          description="Maximum total number of nodes allowed in the entire pool.",
671      )
672      pool_min: int = Field(
673          ...,
674          alias="poolMin",
675          ge=0,
676          description="Minimum total size of the pool.",
677      )
678  
679      class Config:
680          populate_by_name = True
681  
682  
683  class CreatePoolRequest(BaseModel):
684      """
685      Request to create a new pre-warmed resource pool.
686  
687      A Pool manages a set of pre-warmed pods that can be rapidly allocated
688      to sandboxes, reducing cold-start latency.
689      """
690      name: str = Field(
691          ...,
692          description="Unique name for the pool (must be a valid Kubernetes resource name).",
693          pattern=r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
694          max_length=253,
695      )
696      template: Dict = Field(
697          ...,
698          description=(
699              "Kubernetes PodTemplateSpec defining the pod configuration for pre-warmed nodes. "
700              "Follows the same schema as spec.template in a Kubernetes Deployment."
701          ),
702      )
703      capacity_spec: PoolCapacitySpec = Field(
704          ...,
705          alias="capacitySpec",
706          description="Capacity configuration controlling pool size and buffer behavior.",
707      )
708  
709      class Config:
710          populate_by_name = True
711  
712  
713  class UpdatePoolRequest(BaseModel):
714      """
715      Request to update an existing pool's capacity configuration.
716  
717      Only capacity settings can be updated after pool creation.
718      Updating the pod template requires recreating the pool.
719      """
720      capacity_spec: PoolCapacitySpec = Field(
721          ...,
722          alias="capacitySpec",
723          description="New capacity configuration for the pool.",
724      )
725  
726      class Config:
727          populate_by_name = True
728  
729  
730  class PoolStatus(BaseModel):
731      """
732      Observed runtime state of a pool.
733      """
734      total: int = Field(..., description="Total number of nodes in the pool.")
735      allocated: int = Field(..., description="Number of nodes currently allocated to sandboxes.")
736      available: int = Field(..., description="Number of nodes currently available in the pool.")
737      revision: str = Field(..., description="Latest revision identifier of the pool.")
738  
739  
740  class PoolResponse(BaseModel):
741      """
742      Full representation of a Pool resource.
743      """
744      name: str = Field(..., description="Unique pool name.")
745      capacity_spec: PoolCapacitySpec = Field(
746          ...,
747          alias="capacitySpec",
748          description="Capacity configuration of the pool.",
749      )
750      status: Optional[PoolStatus] = Field(
751          None,
752          description="Observed runtime state of the pool. May be absent if not yet reconciled.",
753      )
754      created_at: Optional[datetime] = Field(
755          None,
756          alias="createdAt",
757          description="Pool creation timestamp.",
758      )
759  
760      class Config:
761          populate_by_name = True
762  
763  
764  class ListPoolsResponse(BaseModel):
765      """
766      Collection of pools.
767      """
768      items: List[PoolResponse] = Field(..., description="List of pools.")