diff --git a/porter_sandbox/__init__.py b/porter_sandbox/__init__.py index a97ce99..21f95c9 100644 --- a/porter_sandbox/__init__.py +++ b/porter_sandbox/__init__.py @@ -39,6 +39,9 @@ SandboxNetworkingSpec, SandboxResourcesSpec, SandboxSpec, + Snapshot, + SnapshotListResponse, + SnapshotSpec, StatusResponse, VolumeFileEntry, VolumeFileListResponse, @@ -54,6 +57,8 @@ SandboxDomainSpecVisibility, SandboxesPhase, SandboxMetric, + SnapshotMode, + SnapshotStatus, StatusResponsePhase, VolumeFileEntryType, VolumeObjectSpecAccess, @@ -66,6 +71,7 @@ from .readyz import AsyncReadyz, Readyz from .sandbox import AsyncSandbox, Sandbox from .sandboxes import AsyncSandboxes, Sandboxes +from .snapshots import AsyncSnapshots, Snapshots from .volume import AsyncObjectVolume, AsyncVolume, ObjectVolume, Volume, VolumeFile from .volumes import AsyncVolumes, Volumes @@ -77,6 +83,7 @@ "AsyncReadyz", "AsyncSandbox", "AsyncSandboxes", + "AsyncSnapshots", "AsyncVolume", "AsyncVolumes", "AuthenticationError", @@ -124,6 +131,12 @@ "Sandboxes", "SandboxesPhase", "ServerError", + "Snapshot", + "SnapshotListResponse", + "SnapshotMode", + "SnapshotSpec", + "SnapshotStatus", + "Snapshots", "StatusResponse", "StatusResponsePhase", "Volume", diff --git a/porter_sandbox/_client.py b/porter_sandbox/_client.py index 6495d15..eb53d28 100644 --- a/porter_sandbox/_client.py +++ b/porter_sandbox/_client.py @@ -9,6 +9,7 @@ from .resources.healthz import AsyncHealthz, Healthz from .resources.readyz import AsyncReadyz, Readyz from .resources.sandboxes import AsyncSandboxes, Sandboxes +from .resources.snapshots import AsyncSnapshots, Snapshots from .resources.volumes import AsyncVolumes, Volumes @@ -26,6 +27,7 @@ def __init__( config=Config.resolve(api_key=api_key, base_url=base_url, timeout=timeout), ) self.sandboxes: Sandboxes = Sandboxes(self._base) + self.snapshots: Snapshots = Snapshots(self._base) self.volumes: Volumes = Volumes(self._base) self.healthz: Healthz = Healthz(self._base) self.readyz: Readyz = Readyz(self._base) @@ -55,6 +57,7 @@ def __init__( config=Config.resolve(api_key=api_key, base_url=base_url, timeout=timeout), ) self.sandboxes: AsyncSandboxes = AsyncSandboxes(self._base) + self.snapshots: AsyncSnapshots = AsyncSnapshots(self._base) self.volumes: AsyncVolumes = AsyncVolumes(self._base) self.healthz: AsyncHealthz = AsyncHealthz(self._base) self.readyz: AsyncReadyz = AsyncReadyz(self._base) diff --git a/porter_sandbox/_models.py b/porter_sandbox/_models.py index 885bdc2..31ecde9 100644 --- a/porter_sandbox/_models.py +++ b/porter_sandbox/_models.py @@ -9,6 +9,8 @@ FilterValuesResponsePhases, LogLineLevel, SandboxDomainSpecVisibility, + SnapshotMode, + SnapshotStatus, StatusResponsePhase, VolumeFileEntryType, VolumeObjectSpecAccess, @@ -117,7 +119,7 @@ class ReadinessResponse(BaseModel): class SandboxDomainSpec(BaseModel): domain: str | None = Field(default=None, description="Fully qualified hostname for the sandbox, one label under the\ntarget ingress's domain. Unlike name it need not be unique, so\nsuccessive sandboxes can reuse one hostname (only one may be live\nat a time). Defaults to ., then\n., when omitted.\n") - visibility: SandboxDomainSpecVisibility | None = Field(default=None, description="Which sandbox ingress serves the domain when the cluster has both a\npublic and a private one. Omit to default to whichever is\nconfigured, public winning. Rejected when the sandbox exposes a\nport but the requested ingress is not configured on the cluster.\n") + visibility: SandboxDomainSpecVisibility | None = Field(default=None, description="Which sandbox ingress serves the domain when the cluster has both a\npublic and a private one. Omit to default to whichever is\nconfigured, public winning; with neither configured, the sandbox is\nserved at its cluster-internal address only. Rejected when the\nsandbox exposes a port but the requested ingress is not configured\non the cluster.\n") class SandboxEgressSpec(BaseModel): @@ -146,7 +148,8 @@ class SandboxMetricsSeries(BaseModel): class SandboxNetworkingSpec(BaseModel): port: int = Field(description="Port the workload listens on; the per-sandbox Service targets it on\nthe pod. Privileged ports (1-1023) are not allowed.\n") - domains: list[SandboxDomainSpec] | None = Field(default=None, description="Domains the port is served on through a sandbox ingress. Omit to\nserve the port at the default hostname through the default ingress.\nCurrently only one entry is supported.\n") + domains: list[SandboxDomainSpec] | None = Field(default=None, description="Domains the port is served on through a sandbox ingress. Omit to\nserve the port at the default hostname through the default ingress,\nor - on a cluster with no sandbox ingress - at the cluster-internal\naddress only. Currently only one entry is supported.\n") + internal: bool | None = Field(default=None, description="Serve the port inside the cluster only: the sandbox gets no public\nhostname and is reachable at the cluster-internal address surfaced\nas internal_address in its status. Cannot be combined with domains.\n") class SandboxResourcesSpec(BaseModel): @@ -159,7 +162,8 @@ class SandboxResourcesSpec(BaseModel): class SandboxSpec(BaseModel): - image: str = Field(description="Container image to run") + image: str = Field(description="Container image to run. Empty when snapshot_id is set, since the\nsnapshot is the image.\n") + snapshot_id: str | None = Field(default=None, description="Start the sandbox from this snapshot's filesystem instead of an image,\nso image must be left unset. The snapshot carries no command or volumes:\nan omitted command runs the base image's, and the sandbox mounts only\nthe volumes this request asks for.\n") name: str | None = Field(default=None, description="Sandbox name, unique within the cluster. Must be a valid DNS label\n(lowercase alphanumeric and dashes). Defaults to the sandbox's id\nwhen omitted.\n") tags: dict[str, str] | None = Field(default=None, description="Arbitrary key/value labels for identifying and filtering sandboxes") command: list[str] | None = Field(default=None, description="Override image entrypoint") @@ -173,6 +177,25 @@ class SandboxSpec(BaseModel): ttl_seconds: int | None = Field(default=None, description="Maximum lifetime in seconds, counted from when the sandbox starts\nrunning (from creation while it waits to start). The sandbox is\nterminated once it elapses. Omit for no limit.\n") +class Snapshot(BaseModel): + id: str = Field(description="Snapshot id") + sandbox_id: str = Field(description="Sandbox the snapshot was captured from") + mode: SnapshotMode + status: SnapshotStatus = Field(description="Capture state. A sandbox can be started from a snapshot once it is ready. A failed capture keeps its record so the reason stays visible.\n") + t_created_unix_ms: int | None = Field(default=None, description="When the capture started, in unix milliseconds") + t_ready_unix_ms: int | None = Field(default=None, description="When the capture completed, in unix milliseconds") + failure_reason: str | None = Field(default=None, description="Why the capture failed, when it did") + size_bytes: int | None = Field(default=None, description="Total size of what was captured") + + +class SnapshotListResponse(BaseModel): + snapshots: list[Snapshot] + + +class SnapshotSpec(BaseModel): + mode: SnapshotMode | None = Field(default=None, description="What to capture. Defaults to capturing the filesystem.") + + class StatusResponse(BaseModel): id: str = Field(description="Sandbox ID") name: str = Field(description="Sandbox name (the id when no name was given)") @@ -184,6 +207,7 @@ class StatusResponse(BaseModel): started_at: str | None = Field(default=None, description="When the sandbox pod started running") finished_at: str | None = Field(default=None, description="When the sandbox reached a terminal phase (succeeded, failed, or terminated)") host: str = Field(description="Public hostname the sandbox is reachable at. Empty when the sandbox\nexposes no port or the cluster has no sandbox ingress configured.\n") + internal_address: str | None = Field(default=None, description="Cluster-internal host:port the sandbox's exposed port is served on,\nreachable from workloads inside the cluster subject to their own\nnetwork policy. Empty when the sandbox exposes no port.\n") volume_mounts: dict[str, str] | None = Field(default=None, description="Volumes the sandbox mounts, keyed by mount path") exec_target: ExecTarget | None = Field(default=None, description="Where a client addresses an interactive exec into the running sandbox. Absent until the sandbox has a pod.") @@ -237,4 +261,4 @@ class VolumeSpec(BaseModel): object: VolumeObjectSpec | None = Field(default=None) -__all__ = ["CountPoint", "CountResponse", "CreateResponse", "Error", "ExecRequest", "ExecResponse", "ExecTarget", "FilterValuesResponse", "HealthResponse", "ListResponse", "LogLine", "LogsResponse", "LookupResult", "MetricSummaryResponse", "Pagination", "ReadinessResponse", "SandboxDomainSpec", "SandboxEgressSpec", "SandboxMetricsPoint", "SandboxMetricsResponse", "SandboxMetricsResult", "SandboxMetricsSeries", "SandboxNetworkingSpec", "SandboxResourcesSpec", "SandboxSpec", "StatusResponse", "Volume", "VolumeFileEntry", "VolumeFileListResponse", "VolumeFileMoveRequest", "VolumeListResponse", "VolumeObjectSpec", "VolumeSpec"] +__all__ = ["CountPoint", "CountResponse", "CreateResponse", "Error", "ExecRequest", "ExecResponse", "ExecTarget", "FilterValuesResponse", "HealthResponse", "ListResponse", "LogLine", "LogsResponse", "LookupResult", "MetricSummaryResponse", "Pagination", "ReadinessResponse", "SandboxDomainSpec", "SandboxEgressSpec", "SandboxMetricsPoint", "SandboxMetricsResponse", "SandboxMetricsResult", "SandboxMetricsSeries", "SandboxNetworkingSpec", "SandboxResourcesSpec", "SandboxSpec", "Snapshot", "SnapshotListResponse", "SnapshotSpec", "StatusResponse", "Volume", "VolumeFileEntry", "VolumeFileListResponse", "VolumeFileMoveRequest", "VolumeListResponse", "VolumeObjectSpec", "VolumeSpec"] diff --git a/porter_sandbox/enums.py b/porter_sandbox/enums.py index 0e2edc5..8457c08 100644 --- a/porter_sandbox/enums.py +++ b/porter_sandbox/enums.py @@ -44,6 +44,17 @@ class SandboxMetric(str, Enum): NETWORK_TX = "network_tx" +class SnapshotMode(str, Enum): + FILESYSTEM = "filesystem" + FULL = "full" + + +class SnapshotStatus(str, Enum): + PENDING = "pending" + READY = "ready" + FAILED = "failed" + + class StatusResponsePhase(str, Enum): QUEUED = "queued" CREATING = "creating" @@ -80,4 +91,4 @@ class VolumeType(str, Enum): OBJECT = "object" -__all__ = ["FilterValuesResponsePhases", "LogLineLevel", "SandboxDomainSpecVisibility", "SandboxesPhase", "SandboxMetric", "StatusResponsePhase", "VolumeFileEntryType", "VolumeObjectSpecAccess", "VolumePhase", "VolumeSpecType", "VolumeType"] +__all__ = ["FilterValuesResponsePhases", "LogLineLevel", "SandboxDomainSpecVisibility", "SandboxesPhase", "SandboxMetric", "SnapshotMode", "SnapshotStatus", "StatusResponsePhase", "VolumeFileEntryType", "VolumeObjectSpecAccess", "VolumePhase", "VolumeSpecType", "VolumeType"] diff --git a/porter_sandbox/porter.py b/porter_sandbox/porter.py index 3d3b06d..32bfb07 100644 --- a/porter_sandbox/porter.py +++ b/porter_sandbox/porter.py @@ -7,6 +7,7 @@ from porter_sandbox.healthz import AsyncHealthz, Healthz from porter_sandbox.readyz import AsyncReadyz, Readyz from porter_sandbox.sandboxes import AsyncSandboxes, Sandboxes +from porter_sandbox.snapshots import AsyncSnapshots, Snapshots from porter_sandbox.volumes import AsyncVolumes, Volumes @@ -28,6 +29,7 @@ def __init__( self.healthz: Healthz = Healthz(self._client.healthz) self.readyz: Readyz = Readyz(self._client.readyz) self.sandboxes: Sandboxes = Sandboxes(self._client.sandboxes) + self.snapshots: Snapshots = Snapshots(self._client.snapshots) self.volumes: Volumes = Volumes(self._client.volumes) @property @@ -64,6 +66,7 @@ def __init__( self.healthz: AsyncHealthz = AsyncHealthz(self._client.healthz) self.readyz: AsyncReadyz = AsyncReadyz(self._client.readyz) self.sandboxes: AsyncSandboxes = AsyncSandboxes(self._client.sandboxes) + self.snapshots: AsyncSnapshots = AsyncSnapshots(self._client.snapshots) self.volumes: AsyncVolumes = AsyncVolumes(self._client.volumes) @property diff --git a/porter_sandbox/resources/__init__.py b/porter_sandbox/resources/__init__.py index 4a13ef9..6db8ea1 100644 --- a/porter_sandbox/resources/__init__.py +++ b/porter_sandbox/resources/__init__.py @@ -6,6 +6,7 @@ from .healthz import AsyncHealthz, Healthz from .readyz import AsyncReadyz, Readyz from .sandboxes import AsyncSandboxes, Sandboxes +from .snapshots import AsyncSnapshots, Snapshots from .volumes import AsyncVolumes, Volumes -__all__ = ["Healthz", "AsyncHealthz", "Readyz", "AsyncReadyz", "Sandboxes", "AsyncSandboxes", "Volumes", "AsyncVolumes"] +__all__ = ["Healthz", "AsyncHealthz", "Readyz", "AsyncReadyz", "Sandboxes", "AsyncSandboxes", "Snapshots", "AsyncSnapshots", "Volumes", "AsyncVolumes"] diff --git a/porter_sandbox/resources/snapshots.py b/porter_sandbox/resources/snapshots.py new file mode 100644 index 0000000..a192f25 --- /dev/null +++ b/porter_sandbox/resources/snapshots.py @@ -0,0 +1,101 @@ +# Generated by oagen. DO NOT EDIT. + + +from __future__ import annotations + +from typing import Any, TypeVar + +from pydantic import BaseModel + +from .._async_base_client import _AsyncBaseClient +from .._base_client import _BaseClient +from .._models import Snapshot, SnapshotListResponse, SnapshotSpec + +_M = TypeVar("_M", bound=BaseModel) + + +def _coerce(model_cls: type[_M], data: Any) -> _M: + """Validate API response data into the generated model type.""" + return model_cls.model_validate(data) + + +class Snapshots: + """Snapshots resource.""" + + def __init__(self, client: _BaseClient) -> None: + self._client = client + + def create_snapshot(self, id: str, body: SnapshotSpec) -> Snapshot: + """ + Snapshot a sandbox + + Capture a running sandbox so a new sandbox can be started from it later. + The sandbox keeps running. + """ + path = f"/v1/sandbox/{id}/snapshot" + response = self._client._request(method="POST", path=path, json=body.model_dump(by_alias=True, exclude_none=True) if hasattr(body, "model_dump") else body) + return _coerce(Snapshot, response) + + def list_snapshots(self, sandbox_id: str | None = None) -> SnapshotListResponse: + """ + List snapshots + + List captured snapshots, most recent first. + """ + path = "/v1/snapshot" + params: dict[str, Any] = {} + if sandbox_id is not None: + params["sandbox_id"] = sandbox_id + response = self._client._request(method="GET", path=path, params=params) + return _coerce(SnapshotListResponse, response) + + def delete_snapshot(self, id: str) -> None: + """ + Delete snapshot + + Delete a snapshot and everything it captured. + """ + path = f"/v1/snapshot/{id}" + self._client._request(method="DELETE", path=path) + return None + + +class AsyncSnapshots: + """Snapshots resource.""" + + def __init__(self, client: _AsyncBaseClient) -> None: + self._client = client + + async def create_snapshot(self, id: str, body: SnapshotSpec) -> Snapshot: + """ + Snapshot a sandbox + + Capture a running sandbox so a new sandbox can be started from it later. + The sandbox keeps running. + """ + path = f"/v1/sandbox/{id}/snapshot" + response = await self._client._request(method="POST", path=path, json=body.model_dump(by_alias=True, exclude_none=True) if hasattr(body, "model_dump") else body) + return _coerce(Snapshot, response) + + async def list_snapshots(self, sandbox_id: str | None = None) -> SnapshotListResponse: + """ + List snapshots + + List captured snapshots, most recent first. + """ + path = "/v1/snapshot" + params: dict[str, Any] = {} + if sandbox_id is not None: + params["sandbox_id"] = sandbox_id + response = await self._client._request(method="GET", path=path, params=params) + return _coerce(SnapshotListResponse, response) + + async def delete_snapshot(self, id: str) -> None: + """ + Delete snapshot + + Delete a snapshot and everything it captured. + """ + path = f"/v1/snapshot/{id}" + await self._client._request(method="DELETE", path=path) + return None diff --git a/porter_sandbox/sandboxes.py b/porter_sandbox/sandboxes.py index 7d9bd03..816adaf 100644 --- a/porter_sandbox/sandboxes.py +++ b/porter_sandbox/sandboxes.py @@ -32,6 +32,7 @@ def create( self, image: str, *, + snapshot_id: str | None = None, name: str | None = None, tags: dict[str, str] | None = None, command: list[str] | None = None, @@ -46,6 +47,7 @@ def create( ) -> Sandbox: spec = SandboxSpec( image=image, + snapshot_id=snapshot_id, name=name, tags=tags, command=command, @@ -98,6 +100,7 @@ async def create( self, image: str, *, + snapshot_id: str | None = None, name: str | None = None, tags: dict[str, str] | None = None, command: list[str] | None = None, @@ -112,6 +115,7 @@ async def create( ) -> AsyncSandbox: spec = SandboxSpec( image=image, + snapshot_id=snapshot_id, name=name, tags=tags, command=command, diff --git a/porter_sandbox/snapshots.py b/porter_sandbox/snapshots.py new file mode 100644 index 0000000..ebad205 --- /dev/null +++ b/porter_sandbox/snapshots.py @@ -0,0 +1,52 @@ +# Generated by oagen. DO NOT EDIT. + + +from __future__ import annotations + +from porter_sandbox._models import Snapshot, SnapshotListResponse, SnapshotSpec +from porter_sandbox.resources.snapshots import AsyncSnapshots as AsyncSnapshotsResource +from porter_sandbox.resources.snapshots import Snapshots as SnapshotsResource + + +class Snapshots: + """User-facing snapshots namespace.""" + + def __init__(self, resource: SnapshotsResource) -> None: + self._resource = resource + + @property + def raw(self) -> SnapshotsResource: + """Direct access to the generated low-level snapshots resource.""" + return self._resource + + def create(self, id: str, body: SnapshotSpec) -> Snapshot: + return self._resource.create_snapshot(id=id, body=body) + + def list(self, sandbox_id: str | None = None) -> SnapshotListResponse: + return self._resource.list_snapshots(sandbox_id=sandbox_id) + + def delete(self, id: str) -> None: + self._resource.delete_snapshot(id=id) + return None + + +class AsyncSnapshots: + """User-facing snapshots namespace.""" + + def __init__(self, resource: AsyncSnapshotsResource) -> None: + self._resource = resource + + @property + def raw(self) -> AsyncSnapshotsResource: + """Direct access to the generated low-level snapshots resource.""" + return self._resource + + async def create(self, id: str, body: SnapshotSpec) -> Snapshot: + return await self._resource.create_snapshot(id=id, body=body) + + async def list(self, sandbox_id: str | None = None) -> SnapshotListResponse: + return await self._resource.list_snapshots(sandbox_id=sandbox_id) + + async def delete(self, id: str) -> None: + await self._resource.delete_snapshot(id=id) + return None diff --git a/pyproject.toml b/pyproject.toml index d0f03c3..dc89e43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "porter-sandbox" -version = "0.1.55" +version = "0.1.58" description = "Python SDK for the Porter Sandbox API" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_models_round_trip.py b/tests/test_models_round_trip.py index 7e47b84..f28443d 100644 --- a/tests/test_models_round_trip.py +++ b/tests/test_models_round_trip.py @@ -27,6 +27,8 @@ SandboxNetworkingSpec, SandboxResourcesSpec, SandboxSpec, + SnapshotListResponse, + SnapshotSpec, VolumeFileListResponse, VolumeFileMoveRequest, VolumeListResponse, @@ -196,6 +198,20 @@ def test_sandbox_spec_round_trip() -> None: assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized +def test_snapshot_list_response_round_trip() -> None: + instance = SnapshotListResponse(snapshots=[]) + serialized = instance.model_dump(by_alias=True, exclude_none=True) + round_tripped = SnapshotListResponse.model_validate(serialized) + assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized + + +def test_snapshot_spec_round_trip() -> None: + instance = SnapshotSpec() + serialized = instance.model_dump(by_alias=True, exclude_none=True) + round_tripped = SnapshotSpec.model_validate(serialized) + assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized + + def test_volume_file_list_response_round_trip() -> None: instance = VolumeFileListResponse(path="x", entries=[], truncated=True) serialized = instance.model_dump(by_alias=True, exclude_none=True) diff --git a/uv.lock b/uv.lock index 7b88538..ea79cc6 100644 --- a/uv.lock +++ b/uv.lock @@ -345,7 +345,7 @@ wheels = [ [[package]] name = "porter-sandbox" -version = "0.1.55" +version = "0.1.58" source = { editable = "." } dependencies = [ { name = "httpx" },