diff --git a/changes/vercel-sandbox/multi-region.feature.md b/changes/vercel-sandbox/multi-region.feature.md new file mode 100644 index 00000000..72f7d01a --- /dev/null +++ b/changes/vercel-sandbox/multi-region.feature.md @@ -0,0 +1,2 @@ +Add `region` and `failover_regions` configuration for sandbox creation, forks, +and updates, plus multi-region snapshot availability reporting. diff --git a/src/vercel-sandbox/examples/sandbox_03_snapshot_restore.py b/src/vercel-sandbox/examples/sandbox_03_snapshot_restore.py index e6c12426..423846a4 100644 --- a/src/vercel-sandbox/examples/sandbox_03_snapshot_restore.py +++ b/src/vercel-sandbox/examples/sandbox_03_snapshot_restore.py @@ -25,7 +25,11 @@ async def _main() -> None: restored = None snapshot = None - async with sandbox.create_sandbox(name=base_name) as base: + async with sandbox.create_sandbox( + name=base_name, + region="iad1", + failover_regions=("sfo1",), + ) as base: try: await base.fs.write_text("state/message.txt", "restored from snapshot\n") snapshot = await base.snapshot() @@ -36,7 +40,7 @@ async def _main() -> None: ) content = await restored.fs.read_text("state/message.txt") assert content == "restored from snapshot\n" - print(f"{restored_name}: restored {snapshot.id}") + print(f"{restored_name}: restored {snapshot.id} from {snapshot.regions}") finally: if snapshot is not None: await snapshot.delete() diff --git a/src/vercel-sandbox/tests/conftest.py b/src/vercel-sandbox/tests/conftest.py index ff2a7808..edfd36d6 100644 --- a/src/vercel-sandbox/tests/conftest.py +++ b/src/vercel-sandbox/tests/conftest.py @@ -14,6 +14,7 @@ def mock_env_clear(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, Non "VERCEL_PROJECT_ID", "VERCEL_OIDC_TOKEN", "VERCEL_OIDC_TOKEN_HEADER", + "VERCEL_REGION", ): monkeypatch.delenv(name, raising=False) diff --git a/src/vercel-sandbox/tests/sandbox_fixtures.py b/src/vercel-sandbox/tests/sandbox_fixtures.py index e3120b86..ae2cc757 100644 --- a/src/vercel-sandbox/tests/sandbox_fixtures.py +++ b/src/vercel-sandbox/tests/sandbox_fixtures.py @@ -12,6 +12,7 @@ def sandbox_service_options( token: str = "token", team_id: str = "team_123", project_id: str = "prj_123", + region: str | None = None, sync: bool | None = None, ) -> list[ServiceOptions]: """Build Sandbox options for the test's current session mode.""" @@ -34,6 +35,7 @@ def sync_credentials_factory() -> SandboxCredentials: sandbox_sync.SandboxServiceOptions( base_url=base_url, credentials_factory=sync_credentials_factory, + region=region, ) ] @@ -44,5 +46,6 @@ async def async_credentials_factory() -> SandboxCredentials: SandboxServiceOptions( base_url=base_url, credentials_factory=async_credentials_factory, + region=region, ) ] diff --git a/src/vercel-sandbox/tests/test_options.py b/src/vercel-sandbox/tests/test_options.py index 5f12ce2b..5470258f 100644 --- a/src/vercel-sandbox/tests/test_options.py +++ b/src/vercel-sandbox/tests/test_options.py @@ -12,6 +12,23 @@ ) +def test_options_resolves_region_from_argument_or_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("VERCEL_REGION", raising=False) + assert SandboxServiceOptions().region is None + + monkeypatch.setenv("VERCEL_REGION", "iad1") + assert SandboxServiceOptions().region == "iad1" + assert SandboxServiceOptions(region="sfo1").region == "sfo1" + assert sandbox.sync.SandboxServiceOptions().region == "iad1" + assert sandbox.sync.SandboxServiceOptions(region="cle1").region == "cle1" + + monkeypatch.setenv("VERCEL_REGION", "") + assert SandboxServiceOptions().region is None + assert sandbox.sync.SandboxServiceOptions().region is None + + def test_options_equality_preserves_custom_credential_factory_identity() -> None: assert SandboxServiceOptions() == SandboxServiceOptions() assert SandboxServiceOptions( diff --git a/src/vercel-sandbox/tests/test_sandbox_public_flow.py b/src/vercel-sandbox/tests/test_sandbox_public_flow.py index e6d9fc4d..4d62b3eb 100644 --- a/src/vercel-sandbox/tests/test_sandbox_public_flow.py +++ b/src/vercel-sandbox/tests/test_sandbox_public_flow.py @@ -75,6 +75,8 @@ def _sandbox_response( "image": "vercel/sandbox/universal:latest", "status": status, "persistent": True, + "region": "iad1", + "failoverRegions": ["sfo1", "cle1"], "timeout": 300000, "snapshotExpiration": 0, "keepLastSnapshots": { @@ -91,6 +93,7 @@ def _sandbox_response( "projectId": project_id, "status": session_status or status, "cwd": "/vercel/sandbox", + "region": "cle1", "memory": 2048, "vcpus": 1, "timeout": 300000, @@ -155,6 +158,7 @@ def _snapshot_response( "id": snapshot_id, "sourceSessionId": session_id, "region": "iad1", + "regions": ["iad1", "sfo1", "cle1"], "status": status, "sizeBytes": 1024, "createdAt": 1, @@ -329,6 +333,8 @@ def handler(request: httpx.Request) -> httpx.Response: "deleteEvicted": False, }, "tags": {"env": "test"}, + "region": "iad1", + "failoverRegions": ["sfo1", "cle1"], } response = _sandbox_response(project_id="prj_other") payload = response["sandbox"] @@ -344,6 +350,8 @@ def handler(request: httpx.Request) -> httpx.Response: "name": "preview", "currentSessionId": "sbx_123", "tags": {"env": "updated"}, + "region": "sfo1", + "failoverRegions": [], } }, { @@ -390,8 +398,12 @@ def update_handler(request: httpx.Request) -> httpx.Response: delete_evicted=False, ), tags={"env": "test"}, + region="iad1", + failover_regions=("sfo1", "cle1"), ) assert handle.image == "vercel/sandbox/universal:latest" + assert handle.region == "iad1" + assert handle.failover_regions == ("sfo1", "cle1") assert not hasattr(handle, "runtime") assert handle.current_session is not None assert not hasattr(handle.current_session, "runtime") @@ -407,11 +419,16 @@ def update_handler(request: httpx.Request) -> httpx.Response: execution_time_limit=4.5, snapshot_expiration=0, snapshot_retention=SnapshotRetention(count=1, expiration=0), + region="sfo1", + failover_regions=(), ) assert handle.tags == {"env": "updated"} + assert handle.region == "sfo1" + assert handle.failover_regions == () + assert handle.current_session is retained_session + assert handle.current_session.region == "cle1" assert handle.routes[0].url == "https://preview.sandbox.test" assert handle.project_id == "prj_other" - assert handle.current_session is retained_session await handle.update(tags={}, ports=[]) assert handle.tags == {} @@ -438,6 +455,8 @@ def update_handler(request: httpx.Request) -> httpx.Response: "deleteEvicted": True, }, "tags": {"env": "updated"}, + "region": "sfo1", + "failoverRegions": [], }, {"ports": [], "tags": {}}, {"keepLastSnapshots": None}, @@ -491,6 +510,8 @@ async def test_public_fork_sandbox_encodes_overrides_polls_and_cleans_up( env={}, tags={}, snapshot_expiration=0, + region="iad1", + failover_regions=("sfo1",), snapshot_retention=SnapshotRetention( count=2, expiration=timedelta(days=1), @@ -505,6 +526,8 @@ async def test_public_fork_sandbox_encodes_overrides_polls_and_cleans_up( assert json.loads(request.content) == { "name": "forked", "ports": [], + "region": "iad1", + "failoverRegions": ["sfo1"], "timeout": 12500, "resources": {"vcpus": 2, "memory": 4096}, "image": "team/project/image:v1", @@ -545,6 +568,58 @@ def test_sync_fork_sandbox_uses_inherited_defaults(mock_env_clear: None) -> None assert json.loads(request.content) == {} +@respx.mock +async def test_service_region_defaults_placement_operations_and_allows_call_overrides( + mock_env_clear: None, +) -> None: + create_route = respx.post("https://sandbox.test/v3/sandboxes").mock( + return_value=httpx.Response(200, json=_sandbox_response(name="created")) + ) + fork_route = respx.post("https://sandbox.test/v2/sandboxes/source/fork").mock( + return_value=httpx.Response(200, json=_sandbox_response(name="forked")) + ) + update_route = respx.patch("https://sandbox.test/v2/sandboxes/created").mock( + return_value=httpx.Response( + 200, + json={"sandbox": {"name": "created", "currentSessionId": "sbx_123"}}, + ) + ) + + async with session(service_options=_session_options(region="iad1")): + created = await sandbox.create_sandbox(name="created") + await sandbox.fork_sandbox(source_sandbox="source", region="sfo1") + await created.update(tags={"updated": "true"}) + + assert json.loads(create_route.calls.last.request.content) == { + "projectId": "prj_123", + "name": "created", + "region": "iad1", + } + assert json.loads(fork_route.calls.last.request.content) == {"region": "sfo1"} + assert json.loads(update_route.calls.last.request.content) == { + "tags": {"updated": "true"}, + "region": "iad1", + } + + +@respx.mock +def test_sync_service_region_defaults_fork_from_environment( + mock_env_clear: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VERCEL_REGION", "cle1") + fork_route = respx.post("https://sandbox.test/v2/sandboxes/source/fork").mock( + return_value=httpx.Response( + 200, json=_sandbox_response(name="forked", session_id="sbx_fork") + ) + ) + + with session(service_options=_session_options(sync=True)): + sandbox_sync.fork_sandbox(source_sandbox="source") + + assert json.loads(fork_route.calls.last.request.content) == {"region": "cle1"} + + @respx.mock async def test_network_policy_async_public_flow(mock_env_clear: None) -> None: create_route = respx.post("https://sandbox.test/v3/sandboxes").mock( @@ -1826,6 +1901,8 @@ async def test_closed_session_rejects_handles_and_lazy_readers(mock_env_clear: N runtime_session = resumed.current_session command = await handle.create_process("sleep", ["30"]) snapshot = await handle.snapshot() + assert snapshot.region == "iad1" + assert snapshot.regions == ("iad1", "sfo1", "cle1") with pytest.raises(VercelSessionClosedError): await handle.create_process("true") diff --git a/src/vercel-sandbox/vercel/sandbox/__init__.py b/src/vercel-sandbox/vercel/sandbox/__init__.py index 6fcdb8ea..52b1f325 100644 --- a/src/vercel-sandbox/vercel/sandbox/__init__.py +++ b/src/vercel-sandbox/vercel/sandbox/__init__.py @@ -51,6 +51,7 @@ CompletedProcess, DirectoryEntry, DurationInput, + FailoverRegionsInput, GitSource, NetworkPolicy, NetworkPolicyKeyValueMatcher, @@ -106,6 +107,8 @@ def create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, destroy: bool = True, ) -> CreateSandboxOperation: """Prepare an asynchronous sandbox creation operation. @@ -132,6 +135,8 @@ def create_sandbox( snapshot_expiration: Default lifetime for snapshots created from this sandbox. snapshot_retention: Automatic snapshot retention policy. + region: Preferred region for the sandbox. + failover_regions: Regions available if creation in ``region`` fails. destroy: Whether context-manager exit destroys the sandbox after stopping it. Awaiting the operation never triggers cleanup. @@ -157,6 +162,8 @@ def create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, + region=region, + failover_regions=failover_regions, destroy=destroy, ) @@ -176,6 +183,8 @@ def fork_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, destroy: bool = True, ) -> ForkSandboxOperation: """Prepare an asynchronous sandbox fork operation. @@ -204,6 +213,8 @@ def fork_sandbox( tags: Metadata tag override. snapshot_expiration: Default snapshot lifetime override. snapshot_retention: Automatic snapshot retention override. + region: Preferred region override. + failover_regions: Failover region override. destroy: Whether context-manager exit destroys the fork after stopping it. Awaiting the operation never triggers cleanup. @@ -228,6 +239,8 @@ def fork_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, + region=region, + failover_regions=failover_regions, destroy=destroy, ) @@ -249,6 +262,8 @@ async def get_or_create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, ) -> tuple[Sandbox, bool]: """Get a named sandbox or create it when it does not exist. @@ -277,6 +292,8 @@ async def get_or_create_sandbox( snapshot_expiration: Default lifetime for snapshots created from this sandbox. snapshot_retention: Automatic snapshot retention policy. + region: Preferred region for a newly created sandbox. + failover_regions: Failover regions for a newly created sandbox. Returns: A ``(sandbox, created)`` tuple. ``created`` is true when this call @@ -300,6 +317,8 @@ async def get_or_create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, + region=region, + failover_regions=failover_regions, ) diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/api_client.py b/src/vercel-sandbox/vercel/sandbox/_internal/api_client.py index f49f314d..e7f51785 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/api_client.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/api_client.py @@ -145,6 +145,8 @@ class _SandboxCreationOverridesRequest(_ApiRequestModel): keep_last_snapshots: SnapshotRetention | None = Field( default=None, serialization_alias="keepLastSnapshots" ) + region: str | None = None + failover_regions: list[str] | None = Field(default=None, serialization_alias="failoverRegions") @field_serializer("timeout") def _serialize_duration(self, value: timedelta | None) -> int | None: @@ -192,6 +194,8 @@ class _UpdateSandboxRequest(_ApiRequestModel): default=None, serialization_alias="snapshotExpiration" ) current_snapshot_id: str | None = Field(default=None, serialization_alias="currentSnapshotId") + region: str | None = None + failover_regions: list[str] | None = Field(default=None, serialization_alias="failoverRegions") @field_serializer("timeout") def _serialize_duration(self, value: timedelta | None) -> int | None: @@ -393,6 +397,10 @@ class _SandboxPayload(_ApiModel): ) cwd: str | None = None region: str | None = None + failover_regions: tuple[str, ...] | None = Field( + default=None, + validation_alias=AliasChoices("failover_regions", "failoverRegions"), + ) memory: int | None = None vcpus: int | None = None execution_time_limit: int | None = Field( @@ -443,6 +451,7 @@ class _SnapshotPayload(_ApiModel): serialization_alias="sourceSessionId", ) region: str + regions: tuple[str, ...] | None = None status: Literal["created", "deleted", "failed"] size_bytes: int = Field( validation_alias=AliasChoices("size_bytes", "sizeBytes"), serialization_alias="sizeBytes" @@ -693,6 +702,7 @@ def _sandbox_state( project_id=payload.project_id or project_id, cwd=payload.cwd, region=payload.region, + failover_regions=payload.failover_regions or (), memory=payload.memory, vcpus=payload.vcpus, execution_time_limit=parse_duration(payload.execution_time_limit, MILLISECOND), @@ -734,6 +744,7 @@ def _snapshot_state(payload: _SnapshotPayload) -> SnapshotState: id=payload.id, source_session_id=payload.source_session_id, region=payload.region, + regions=payload.regions or (payload.region,), status=payload.status, size_bytes=payload.size_bytes, expires_at=payload.expires_at, @@ -922,6 +933,8 @@ async def create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetention | None = None, + region: str | None = None, + failover_regions: tuple[str, ...] | None = None, ) -> SandboxState: credentials = await self._credentials_factory() request = _CreateSandboxRequest( @@ -938,6 +951,8 @@ async def create_sandbox( tags=dict(tags) if tags is not None else None, snapshot_expiration=snapshot_expiration, keep_last_snapshots=snapshot_retention, + region=region, + failover_regions=None if failover_regions is None else list(failover_regions), ) data = await self._request_json( "POST", "v3/sandboxes", credentials=credentials, body=request.to_api_dict() @@ -960,6 +975,8 @@ async def fork_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetention | None = None, + region: str | None = None, + failover_regions: tuple[str, ...] | None = None, ) -> SandboxState: credentials = await self._credentials_factory() request = _ForkSandboxRequest( @@ -974,6 +991,8 @@ async def fork_sandbox( tags=dict(tags) if tags is not None else None, snapshot_expiration=snapshot_expiration, keep_last_snapshots=snapshot_retention, + region=region, + failover_regions=None if failover_regions is None else list(failover_regions), ) data = await self._request_json( "POST", @@ -1073,6 +1092,8 @@ async def update_sandbox( snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetentionUpdate = _OMITTED, current_snapshot_id: str | None = None, + region: str | None = None, + failover_regions: tuple[str, ...] | None = None, ) -> SandboxState: credentials = await self._credentials_factory() effective_project_id = project_id or credentials.project_id @@ -1086,6 +1107,8 @@ async def update_sandbox( tags=dict(tags) if tags is not None else None, snapshot_expiration=snapshot_expiration, current_snapshot_id=current_snapshot_id, + region=region, + failover_regions=None if failover_regions is None else list(failover_regions), ) body = request.to_api_dict() if not isinstance(snapshot_retention, _Omitted): diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py b/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py index 4cac592b..6adcaae9 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py @@ -42,6 +42,7 @@ CompletedProcess, DirectoryEntry, DurationInput, + FailoverRegionsInput, NetworkPolicy, ProcessLog, SandboxQuery, @@ -54,6 +55,7 @@ SnapshotRetentionUpdate, _parse_snapshot_expiration, _WriteFile, + normalize_failover_regions, ) from vercel.sandbox._internal.pagination import ( QuerySandboxesPage, @@ -1286,6 +1288,8 @@ async def update( snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetentionUpdate = _OMITTED, current_snapshot_id: str | None = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, ) -> Self: """Update mutable sandbox configuration. @@ -1312,6 +1316,8 @@ async def update( snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, current_snapshot_id=current_snapshot_id, + region=region, + failover_regions=normalize_failover_regions(failover_regions), ) self._apply_payload(payload) return self @@ -1419,6 +1425,8 @@ class _CreateSandboxParams: tags: Mapping[str, str] | None = None snapshot_expiration: SnapshotExpiration | None = None snapshot_retention: SnapshotRetention | None = None + region: str | None = None + failover_regions: tuple[str, ...] | None = None class CreateSandboxOperation: @@ -1465,6 +1473,8 @@ async def _run_once(self) -> Sandbox: tags=self._params.tags, snapshot_expiration=self._params.snapshot_expiration, snapshot_retention=self._params.snapshot_retention, + region=self._params.region, + failover_regions=self._params.failover_regions, ) def __await__(self) -> Generator[Any, None, Sandbox]: @@ -1511,6 +1521,8 @@ class _ForkSandboxParams: tags: Mapping[str, str] | None = None snapshot_expiration: SnapshotExpiration | None = None snapshot_retention: SnapshotRetention | None = None + region: str | None = None + failover_regions: tuple[str, ...] | None = None class ForkSandboxOperation: @@ -1556,6 +1568,8 @@ async def _run_once(self) -> Sandbox: tags=self._params.tags, snapshot_expiration=self._params.snapshot_expiration, snapshot_retention=self._params.snapshot_retention, + region=self._params.region, + failover_regions=self._params.failover_regions, ) def __await__(self) -> Generator[Any, None, Sandbox]: @@ -1705,6 +1719,8 @@ def create_sandbox_operation( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, destroy: bool = True, ) -> CreateSandboxOperation: return CreateSandboxOperation( @@ -1723,6 +1739,8 @@ def create_sandbox_operation( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, + region=region, + failover_regions=normalize_failover_regions(failover_regions), ), destroy=destroy, ) @@ -1744,6 +1762,8 @@ def fork_sandbox_operation( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, destroy: bool = True, ) -> ForkSandboxOperation: return ForkSandboxOperation( @@ -1762,6 +1782,8 @@ def fork_sandbox_operation( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, + region=region, + failover_regions=normalize_failover_regions(failover_regions), ), destroy=destroy, ) @@ -1805,6 +1827,8 @@ async def get_or_create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, ) -> tuple[Sandbox, bool]: try: state, created = await service.get_or_create_sandbox( @@ -1823,6 +1847,8 @@ async def get_or_create_sandbox( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, + region=region, + failover_regions=normalize_failover_regions(failover_regions), ) return ( Sandbox( diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/models.py b/src/vercel-sandbox/vercel/sandbox/_internal/models.py index 6f4c8a2f..f7cd9b0a 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/models.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/models.py @@ -21,11 +21,24 @@ JSONValue: TypeAlias = PydanticJsonValue JSONObject: TypeAlias = dict[str, JSONValue] DurationInput: TypeAlias = int | float | timedelta | None +FailoverRegionsInput: TypeAlias = Iterable[str] | None _MIN_SNAPSHOT_EXPIRATION = timedelta(days=1) _MAX_SNAPSHOT_EXPIRATION = timedelta(days=365 * 10) _ZERO_DELTA = timedelta(0) +def normalize_failover_regions(regions: FailoverRegionsInput) -> tuple[str, ...] | None: + """Normalize sandbox failover regions while preserving explicit emptiness.""" + if regions is None: + return None + normalized = tuple(regions) + if any(not isinstance(region, str) or not region for region in normalized): + raise ValueError("failover_regions must contain non-empty strings") + if len(set(normalized)) != len(normalized): + raise ValueError("failover_regions must not contain duplicates") + return normalized + + @dataclass(frozen=True, slots=True) class NetworkPolicyMatcher: """Match one request value using one comparison strategy.""" diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/options.py b/src/vercel-sandbox/vercel/sandbox/_internal/options.py index cf88f73c..4176d77c 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/options.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/options.py @@ -5,6 +5,7 @@ from typing import Protocol from vercel._internal.core.options import ServiceOptions +from vercel.env import get_env from vercel.sandbox._internal.errors import SandboxCredentialsError DEFAULT_SANDBOX_API_BASE_URL = "https://vercel.com/api" @@ -67,6 +68,7 @@ class SandboxServiceOptions(_SandboxServiceOptionsKey): base_url: str credentials_factory: SandboxCredentialsFactory file_transfer_timeout: timedelta + region: str | None def __init__( self, @@ -74,6 +76,7 @@ def __init__( base_url: str | None = None, credentials_factory: SandboxCredentialsFactory | None = None, file_transfer_timeout: timedelta | None = None, + region: str | None = None, ) -> None: object.__setattr__( self, @@ -92,6 +95,7 @@ def __init__( if file_transfer_timeout is not None else _DEFAULT_FILE_TRANSFER_TIMEOUT, ) + object.__setattr__(self, "region", region or get_env().VERCEL_REGION) @dataclass(frozen=True, slots=True, init=False) @@ -101,6 +105,7 @@ class SyncSandboxServiceOptions(_SandboxServiceOptionsKey): base_url: str credentials_factory: SyncSandboxCredentialsFactory file_transfer_timeout: timedelta + region: str | None def __init__( self, @@ -108,6 +113,7 @@ def __init__( base_url: str | None = None, credentials_factory: SyncSandboxCredentialsFactory | None = None, file_transfer_timeout: timedelta | None = None, + region: str | None = None, ) -> None: object.__setattr__( self, @@ -126,3 +132,4 @@ def __init__( if file_transfer_timeout is not None else _DEFAULT_FILE_TRANSFER_TIMEOUT, ) + object.__setattr__(self, "region", region or get_env().VERCEL_REGION) diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py b/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py index 1d9645ce..6c5fc3df 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py @@ -258,6 +258,10 @@ def id(self) -> str: def source_session_id(self) -> str: return self._payload.source_session_id + @property + def regions(self) -> tuple[str, ...]: + return self._payload.regions + @property def region(self) -> str: return self._payload.region @@ -462,6 +466,10 @@ def cwd(self) -> str | None: def region(self) -> str | None: return self._payload.region + @property + def failover_regions(self) -> tuple[str, ...]: + return self._payload.failover_regions + @property def memory(self) -> int | None: return self._payload.memory diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/service.py b/src/vercel-sandbox/vercel/sandbox/_internal/service.py index 1dbd0ff0..6404eeac 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/service.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/service.py @@ -264,6 +264,8 @@ async def create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetention | None = None, + region: str | None = None, + failover_regions: tuple[str, ...] | None = None, ) -> SandboxState: self._ensure_open() sandbox = await self._api_client.create_sandbox( @@ -280,6 +282,8 @@ async def create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, + region=region or self._options.region, + failover_regions=failover_regions, ) return await self._wait_for_ready_sandbox(sandbox, project_id=project_id) @@ -299,6 +303,8 @@ async def fork_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetention | None = None, + region: str | None = None, + failover_regions: tuple[str, ...] | None = None, ) -> SandboxState: self._ensure_open() sandbox = await self._api_client.fork_sandbox( @@ -315,6 +321,8 @@ async def fork_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, + region=region or self._options.region, + failover_regions=failover_regions, ) return await self._wait_for_ready_sandbox(sandbox, project_id=project_id) @@ -352,6 +360,8 @@ async def get_or_create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetention | None = None, + region: str | None = None, + failover_regions: tuple[str, ...] | None = None, ) -> tuple[SandboxState, bool]: """Return a named sandbox and whether it had to be created.""" try: @@ -389,6 +399,8 @@ async def get_or_create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, + region=region, + failover_regions=failover_regions, ) return sandbox, True @@ -431,6 +443,8 @@ async def update_sandbox( snapshot_expiration: SnapshotExpiration | None = None, snapshot_retention: SnapshotRetentionUpdate = _OMITTED, current_snapshot_id: str | None = None, + region: str | None = None, + failover_regions: tuple[str, ...] | None = None, ) -> SandboxState: self._ensure_open() return await self._api_client.update_sandbox( @@ -446,6 +460,8 @@ async def update_sandbox( snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, current_snapshot_id=current_snapshot_id, + region=region or self._options.region, + failover_regions=failover_regions, ) async def resume_sandbox( @@ -1110,6 +1126,7 @@ def factory() -> SandboxService: base_url=sync_options.base_url, credentials_factory=_adapt_sync_credentials_factory(sync_options.credentials_factory), file_transfer_timeout=sync_options.file_transfer_timeout, + region=sync_options.region, ) return SandboxService( api_client=SandboxApiClient( diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/state.py b/src/vercel-sandbox/vercel/sandbox/_internal/state.py index 1e1b2d60..9ea3c1ad 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/state.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/state.py @@ -70,6 +70,7 @@ class SandboxState: project_id: str | None = None cwd: str | None = None region: str | None = None + failover_regions: tuple[str, ...] = () memory: int | None = None vcpus: int | None = None execution_time_limit: timedelta | None = None @@ -101,6 +102,7 @@ class SnapshotState: id: str source_session_id: str region: str + regions: tuple[str, ...] status: Literal["created", "deleted", "failed"] size_bytes: int expires_at: int | None = None diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py b/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py index 21bd58a9..e4653e4f 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py @@ -36,6 +36,7 @@ CompletedProcess, DirectoryEntry, DurationInput, + FailoverRegionsInput, NetworkPolicy, ProcessLog, SandboxQuery, @@ -47,6 +48,7 @@ SnapshotRetentionUpdate, _parse_snapshot_expiration, _WriteFile, + normalize_failover_regions, ) from vercel.sandbox._internal.pagination import ( QuerySandboxesPage, @@ -1405,6 +1407,8 @@ def update( snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetentionUpdate = _OMITTED, current_snapshot_id: str | None = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, ) -> Self: """Update mutable sandbox configuration. @@ -1431,6 +1435,8 @@ def update( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, + region=region, + failover_regions=normalize_failover_regions(failover_regions), current_snapshot_id=current_snapshot_id, ) ) @@ -1507,6 +1513,8 @@ def create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, destroy: bool = True, ) -> _ManagedSyncSandbox: try: @@ -1525,6 +1533,8 @@ def create_sandbox( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, + region=region, + failover_regions=normalize_failover_regions(failover_regions), ) ) return _ManagedSyncSandbox( @@ -1552,6 +1562,8 @@ def fork_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, destroy: bool = True, ) -> _ManagedSyncSandbox: try: @@ -1570,6 +1582,8 @@ def fork_sandbox( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, + region=region, + failover_regions=normalize_failover_regions(failover_regions), ) ) return _ManagedSyncSandbox( @@ -1621,6 +1635,8 @@ def get_or_create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, ) -> tuple[SyncSandbox, bool]: try: state, created = iter_coroutine( @@ -1640,6 +1656,8 @@ def get_or_create_sandbox( tags=tags, snapshot_expiration=_parse_snapshot_expiration(snapshot_expiration), snapshot_retention=snapshot_retention, + region=region, + failover_regions=normalize_failover_regions(failover_regions), ) ) return ( diff --git a/src/vercel-sandbox/vercel/sandbox/sync.py b/src/vercel-sandbox/vercel/sandbox/sync.py index 4c6858e4..8a364f72 100644 --- a/src/vercel-sandbox/vercel/sandbox/sync.py +++ b/src/vercel-sandbox/vercel/sandbox/sync.py @@ -24,6 +24,7 @@ CompletedProcess, DirectoryEntry, DurationInput, + FailoverRegionsInput, GitSource, NetworkPolicy, NetworkPolicyKeyValueMatcher, @@ -101,6 +102,8 @@ def create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, destroy: bool = True, ) -> _ManagedSyncSandbox: """Create a sandbox and wait until it is ready. @@ -127,6 +130,8 @@ def create_sandbox( snapshot_expiration: Default lifetime for snapshots created from this sandbox. snapshot_retention: Automatic snapshot retention policy. + region: Preferred region for the sandbox. + failover_regions: Regions available if creation in ``region`` fails. destroy: Whether context-manager exit destroys the sandbox after stopping it. @@ -151,6 +156,8 @@ def create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, + region=region, + failover_regions=failover_regions, destroy=destroy, ) @@ -170,6 +177,8 @@ def fork_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, destroy: bool = True, ) -> _ManagedSyncSandbox: """Fork a sandbox and wait until the fork is ready. @@ -199,6 +208,8 @@ def fork_sandbox( tags: Metadata tag override. snapshot_expiration: Default snapshot lifetime override. snapshot_retention: Automatic snapshot retention override. + region: Preferred region override. + failover_regions: Failover region override. destroy: Whether context-manager exit destroys the fork after stopping it. @@ -223,6 +234,8 @@ def fork_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, + region=region, + failover_regions=failover_regions, destroy=destroy, ) @@ -244,6 +257,8 @@ def get_or_create_sandbox( tags: Mapping[str, str] | None = None, snapshot_expiration: SnapshotExpirationInput = None, snapshot_retention: SnapshotRetention | None = None, + region: str | None = None, + failover_regions: FailoverRegionsInput = None, ) -> tuple[SyncSandbox, bool]: """Get a named sandbox or create it when it does not exist. @@ -272,6 +287,8 @@ def get_or_create_sandbox( snapshot_expiration: Default lifetime for snapshots created from this sandbox. snapshot_retention: Automatic snapshot retention policy. + region: Preferred region for a newly created sandbox. + failover_regions: Failover regions for a newly created sandbox. Returns: A ``(sandbox, created)`` tuple. ``created`` is true when this call @@ -295,6 +312,8 @@ def get_or_create_sandbox( tags=tags, snapshot_expiration=snapshot_expiration, snapshot_retention=snapshot_retention, + region=region, + failover_regions=failover_regions, )