Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions porter_sandbox/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,11 +27,13 @@
LogLine,
LogsResponse,
LookupResult,
MetricSummaryResponse,
Pagination,
ReadinessResponse,
SandboxDomainSpec,
SandboxEgressSpec,
SandboxNetworkingSpec,
SandboxResourcesSpec,
SandboxSpec,
StatusResponse,
VolumeFileEntry,
Expand DownExpand Up@@ -86,6 +88,7 @@
"LogLineLevel",
"LogsResponse",
"LookupResult",
"MetricSummaryResponse",
"NotFoundError",
"Pagination",
"Porter",
Expand All@@ -99,6 +102,7 @@
"SandboxEgressSpec",
"SandboxError",
"SandboxNetworkingSpec",
"SandboxResourcesSpec",
"SandboxSpec",
"SandboxTimeoutError",
"Sandboxes",
Expand Down
30 changes: 29 additions & 1 deletion porter_sandbox/_models.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,24 @@ class LookupResult(BaseModel):
id: str = Field(description="The resolved resource id")


class MetricSummaryResponse(BaseModel):
"""p50/p90 CPU and memory usage over a lookback window with utilization against the sandbox's limits. Memory values include page cache, so they slightly overestimate resident memory."""
model_config = ConfigDict(populate_by_name=True)

window_seconds: int = Field(description="Lookback window the summary was computed over.")
has_data: bool = Field(description="False when Prometheus returned no series for the sandbox's pod, which\nhappens for freshly-started sandboxes (Prometheus scrapes at ~1m) and\nfor pods shorter-lived than the [3m, 24h] window. When false, callers\nshould render \"no data yet\" rather than the zeroed metric fields.\n")
cpu_cores_p_50: float = Field(alias="cpu_cores_p50", description="50th percentile CPU usage in cores.")
cpu_cores_p_90: float = Field(alias="cpu_cores_p90", description="90th percentile CPU usage in cores.")
cpu_limit_cores: float = Field(description="CPU limit configured on the sandbox pod, in cores. Zero when no limit is set.")
cpu_util_p_50_pct: float = Field(alias="cpu_util_p50_pct", description="p50 CPU usage as a percentage of the CPU limit. Zero when no limit is set.")
cpu_util_p_90_pct: float = Field(alias="cpu_util_p90_pct", description="p90 CPU usage as a percentage of the CPU limit. Zero when no limit is set.")
mem_bytes_p_50: int = Field(alias="mem_bytes_p50", description="50th percentile memory usage in bytes. Includes page cache.")
mem_bytes_p_90: int = Field(alias="mem_bytes_p90", description="90th percentile memory usage in bytes. Includes page cache.")
mem_limit_bytes: int = Field(description="Memory limit configured on the sandbox pod, in bytes. Zero when no limit is set.")
mem_util_p_50_pct: float = Field(alias="mem_util_p50_pct", description="p50 memory usage as a percentage of the memory limit. Zero when no limit is set.")
mem_util_p_90_pct: float = Field(alias="mem_util_p90_pct", description="p90 memory usage as a percentage of the memory limit. Zero when no limit is set.")


class Pagination(BaseModel):
current_page: int = Field(description="Current page number (1-based)")
total_pages: int = Field(description="Total number of pages")
Expand All@@ -108,6 +126,15 @@ class SandboxNetworkingSpec(BaseModel):
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")


class SandboxResourcesSpec(BaseModel):
"""CPU and memory for the sandbox, as Kubernetes quantities. An omitted
field keeps the cluster's default sandbox size for that resource. The
sandbox can use up to the given amount.
"""
cpu: str | None = Field(default=None, description="CPU cores, e.g. \"2\", \"500m\".")
memory: str | None = Field(default=None, description="Memory, e.g. \"2Gi\", \"512Mi\".")


class SandboxSpec(BaseModel):
image: str = Field(description="Container image to run")
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")
Expand All@@ -119,6 +146,7 @@ class SandboxSpec(BaseModel):
volume_mounts: dict[str, str] | None = Field(default=None, description="Volumes to mount, keyed by the absolute mount path inside the\nsandbox; values are volume IDs.\n")
networking: list[SandboxNetworkingSpec] | None = Field(default=None, description="Network exposure for the sandbox. Omit to expose nothing. Currently\nonly one entry is supported.\n")
egress: SandboxEgressSpec | None = Field(default=None)
resources: SandboxResourcesSpec | None = Field(default=None)
ttl_seconds: int | None = Field(default=None, description="Maximum lifetime in seconds, counted from creation. The sandbox is\nterminated once it elapses. Omit for no limit.\n")


Expand DownExpand Up@@ -175,4 +203,4 @@ class VolumeSpec(BaseModel):
name: str | None = Field(default=None, description="Volume name, unique within the cluster. Must be a valid DNS label\n(lowercase alphanumeric and dashes). Defaults to the volume's id\nwhen omitted.\n")


__all__ = ["CountPoint", "CountResponse", "CreateResponse", "Error", "ExecRequest", "ExecResponse", "ExecTarget", "FilterValuesResponse", "HealthResponse", "ListResponse", "LogLine", "LogsResponse", "LookupResult", "Pagination", "ReadinessResponse", "SandboxDomainSpec", "SandboxEgressSpec", "SandboxNetworkingSpec", "SandboxSpec", "StatusResponse", "Volume", "VolumeFileEntry", "VolumeFileListResponse", "VolumeFileMoveRequest", "VolumeListResponse", "VolumeSpec"]
__all__ = ["CountPoint", "CountResponse", "CreateResponse", "Error", "ExecRequest", "ExecResponse", "ExecTarget", "FilterValuesResponse", "HealthResponse", "ListResponse", "LogLine", "LogsResponse", "LookupResult", "MetricSummaryResponse", "Pagination", "ReadinessResponse", "SandboxDomainSpec", "SandboxEgressSpec", "SandboxNetworkingSpec", "SandboxResourcesSpec", "SandboxSpec", "StatusResponse", "Volume", "VolumeFileEntry", "VolumeFileListResponse", "VolumeFileMoveRequest", "VolumeListResponse", "VolumeSpec"]
33 changes: 33 additions & 0 deletions porter_sandbox/resources/sandboxes.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@
ListResponse,
LogsResponse,
LookupResult,
MetricSummaryResponse,
SandboxSpec,
StatusResponse,
)
Expand DownExpand Up@@ -156,6 +157,22 @@ def exec_sandbox(self, id: str, body: ExecRequest, timeout: float | None = None)
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, timeout=timeout, retry=False)
return _coerce(ExecResponse, response)

def get_sandbox_metrics_summary(self, id: str, since: str | None = None) -> MetricSummaryResponse:
"""
Get sandbox CPU and memory percentile summary

Return p50/p90 CPU and memory usage over a lookback window for the
sandbox, with utilization computed against the sandbox pod's limits.
Memory values include page cache, so they slightly overestimate
resident memory.
"""
path = f"/v1/sandbox/{id}/metrics-summary"
params: dict[str, Any] = {}
if since is not None:
params["since"] = since
response = self._client._request(method="GET", path=path, params=params)
return _coerce(MetricSummaryResponse, response)


class AsyncSandboxes:
"""Sandboxes resource."""
Expand DownExpand Up@@ -282,3 +299,19 @@ async def exec_sandbox(self, id: str, body: ExecRequest, timeout: float | None =
path = f"/v1/sandbox/{id}/exec"
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, timeout=timeout, retry=False)
return _coerce(ExecResponse, response)

async def get_sandbox_metrics_summary(self, id: str, since: str | None = None) -> MetricSummaryResponse:
"""
Get sandbox CPU and memory percentile summary

Return p50/p90 CPU and memory usage over a lookback window for the
sandbox, with utilization computed against the sandbox pod's limits.
Memory values include page cache, so they slightly overestimate
resident memory.
"""
path = f"/v1/sandbox/{id}/metrics-summary"
params: dict[str, Any] = {}
if since is not None:
params["since"] = since
response = await self._client._request(method="GET", path=path, params=params)
return _coerce(MetricSummaryResponse, response)
11 changes: 10 additions & 1 deletion porter_sandbox/sandboxes.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,12 @@

import builtins

from porter_sandbox._models import SandboxEgressSpec, SandboxNetworkingSpec, SandboxSpec
from porter_sandbox._models import (
SandboxEgressSpec,
SandboxNetworkingSpec,
SandboxResourcesSpec,
SandboxSpec,
)
from porter_sandbox.enums import SandboxesPhase
from porter_sandbox.resources.sandboxes import AsyncSandboxes as AsyncSandboxesResource
from porter_sandbox.resources.sandboxes import Sandboxes as SandboxesResource
Expand DownExpand Up@@ -36,6 +41,7 @@ def create(
volume_mounts: dict[str, str] | None = None,
networking: list[SandboxNetworkingSpec] | None = None,
egress: SandboxEgressSpec | None = None,
resources: SandboxResourcesSpec | None = None,
ttl_seconds: int | None = None,
) -> Sandbox:
spec = SandboxSpec(
Expand All@@ -49,6 +55,7 @@ def create(
volume_mounts=volume_mounts,
networking=networking,
egress=egress,
resources=resources,
ttl_seconds=ttl_seconds,
)
created = self._resource.create_sandbox(body=spec)
Expand DownExpand Up@@ -100,6 +107,7 @@ async def create(
volume_mounts: dict[str, str] | None = None,
networking: list[SandboxNetworkingSpec] | None = None,
egress: SandboxEgressSpec | None = None,
resources: SandboxResourcesSpec | None = None,
ttl_seconds: int | None = None,
) -> AsyncSandbox:
spec = SandboxSpec(
Expand All@@ -113,6 +121,7 @@ async def create(
volume_mounts=volume_mounts,
networking=networking,
egress=egress,
resources=resources,
ttl_seconds=ttl_seconds,
)
created = await self._resource.create_sandbox(body=spec)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "porter-sandbox"
version = "0.1.44"
version = "0.1.47"
description = "Python SDK for the Porter Sandbox API"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
16 changes: 16 additions & 0 deletions tests/test_models_round_trip.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,11 +15,13 @@
HealthResponse,
LogsResponse,
LookupResult,
MetricSummaryResponse,
Pagination,
ReadinessResponse,
SandboxDomainSpec,
SandboxEgressSpec,
SandboxNetworkingSpec,
SandboxResourcesSpec,
SandboxSpec,
VolumeFileListResponse,
VolumeFileMoveRequest,
Expand DownExpand Up@@ -105,6 +107,13 @@ def test_lookup_result_round_trip() -> None:
assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized


def test_metric_summary_response_round_trip() -> None:
instance = MetricSummaryResponse(window_seconds=1, has_data=True, cpu_cores_p_50=1.0, cpu_cores_p_90=1.0, cpu_limit_cores=1.0, cpu_util_p_50_pct=1.0, cpu_util_p_90_pct=1.0, mem_bytes_p_50=1, mem_bytes_p_90=1, mem_limit_bytes=1, mem_util_p_50_pct=1.0, mem_util_p_90_pct=1.0)
serialized = instance.model_dump(by_alias=True, exclude_none=True)
round_tripped = MetricSummaryResponse.model_validate(serialized)
assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized


def test_pagination_round_trip() -> None:
instance = Pagination(current_page=1, total_pages=1, has_next_page=True)
serialized = instance.model_dump(by_alias=True, exclude_none=True)
Expand DownExpand Up@@ -140,6 +149,13 @@ def test_sandbox_networking_spec_round_trip() -> None:
assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized


def test_sandbox_resources_spec_round_trip() -> None:
instance = SandboxResourcesSpec()
serialized = instance.model_dump(by_alias=True, exclude_none=True)
round_tripped = SandboxResourcesSpec.model_validate(serialized)
assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized


def test_sandbox_spec_round_trip() -> None:
instance = SandboxSpec(image="x")
serialized = instance.model_dump(by_alias=True, exclude_none=True)
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading