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
2 changes: 2 additions & 0 deletions changes/vercel-sandbox/multi-region.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add `region` and `failover_regions` configuration for sandbox creation, forks,
and updates, plus multi-region snapshot availability reporting.
8 changes: 6 additions & 2 deletions src/vercel-sandbox/examples/sandbox_03_snapshot_restore.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down
1 change: 1 addition & 0 deletions src/vercel-sandbox/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions src/vercel-sandbox/tests/sandbox_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -34,6 +35,7 @@ def sync_credentials_factory() -> SandboxCredentials:
sandbox_sync.SandboxServiceOptions(
base_url=base_url,
credentials_factory=sync_credentials_factory,
region=region,
)
]

Expand All @@ -44,5 +46,6 @@ async def async_credentials_factory() -> SandboxCredentials:
SandboxServiceOptions(
base_url=base_url,
credentials_factory=async_credentials_factory,
region=region,
)
]
17 changes: 17 additions & 0 deletions src/vercel-sandbox/tests/test_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
79 changes: 78 additions & 1 deletion src/vercel-sandbox/tests/test_sandbox_public_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"]
Expand All @@ -344,6 +350,8 @@ def handler(request: httpx.Request) -> httpx.Response:
"name": "preview",
"currentSessionId": "sbx_123",
"tags": {"env": "updated"},
"region": "sfo1",
"failoverRegions": [],
}
},
{
Expand Down Expand Up @@ -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")
Expand All @@ -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 == {}
Expand All @@ -438,6 +455,8 @@ def update_handler(request: httpx.Request) -> httpx.Response:
"deleteEvicted": True,
},
"tags": {"env": "updated"},
"region": "sfo1",
"failoverRegions": [],
},
{"ports": [], "tags": {}},
{"keepLastSnapshots": None},
Expand Down Expand Up @@ -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),
Expand All @@ -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",
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand Down
19 changes: 19 additions & 0 deletions src/vercel-sandbox/vercel/sandbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
CompletedProcess,
DirectoryEntry,
DurationInput,
FailoverRegionsInput,
GitSource,
NetworkPolicy,
NetworkPolicyKeyValueMatcher,
Expand Down Expand Up @@ -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.
Expand All @@ -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.

Expand All @@ -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,
)

Expand All @@ -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.
Expand Down Expand Up @@ -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.

Expand All @@ -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,
)

Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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,
)


Expand Down
Loading