From 6955ecbbefa4a91d94984d2c9efcdfdb713ec0a8 Mon Sep 17 00:00:00 2001 From: ferponse Date: Sun, 7 Jun 2026 03:09:21 +0200 Subject: [PATCH 1/2] feat(server): expose snapshot restore image reference (imageUri) in the Snapshot API The Snapshot REST response (POST /sandboxes/{id}/snapshots, GET /snapshots/{id}, GET /snapshots) did not include the resulting OCI image reference, although the server already stores it in SnapshotRecord.restore_config.image. Expose it as 'imageUri' (populated once Ready) so a snapshot can be restored on a different server/runtime via the create-from-image path -- snapshotId restore is limited to the originating server's snapshot store. See opensandbox-group/OpenSandbox#994. Co-authored-by: Atenea Agent --- server/opensandbox_server/api/schema.py | 10 ++++++++++ server/opensandbox_server/services/snapshot_service.py | 1 + 2 files changed, 11 insertions(+) diff --git a/server/opensandbox_server/api/schema.py b/server/opensandbox_server/api/schema.py index f3be288e0..9d1858121 100644 --- a/server/opensandbox_server/api/schema.py +++ b/server/opensandbox_server/api/schema.py @@ -618,6 +618,16 @@ class Snapshot(BaseModel): ..., description="Current snapshot lifecycle status and detailed state information", ) + image_uri: Optional[str] = Field( + None, + alias="imageUri", + description=( + "OCI image reference to restore this snapshot from. Populated once the " + "snapshot reaches the Ready state. Exposing it enables restoring the " + "snapshot on a different server or runtime via the create-from-image path " + "(snapshotId restore is limited to the originating server's snapshot store)." + ), + ) created_at: datetime = Field( ..., alias="createdAt", diff --git a/server/opensandbox_server/services/snapshot_service.py b/server/opensandbox_server/services/snapshot_service.py index 0258d1517..8e0af2512 100644 --- a/server/opensandbox_server/services/snapshot_service.py +++ b/server/opensandbox_server/services/snapshot_service.py @@ -515,6 +515,7 @@ def _to_snapshot_response(record: SnapshotRecord) -> Snapshot: message=record.status.message, lastTransitionAt=record.status.last_transition_at, ), + imageUri=record.restore_config.image if record.restore_config else None, createdAt=record.created_at, ) From a8f173b07ebaff67e06683c14878257f4019d007 Mon Sep 17 00:00:00 2001 From: ferponse Date: Sun, 7 Jun 2026 03:27:28 +0200 Subject: [PATCH 2/2] test(server): cover snapshot imageUri exposure in the Snapshot API Service-level: get_snapshot exposes restore_config.image as image_uri once Ready; absent while Creating. Route-level: GET /v1/snapshots/{id} serializes the camelCase imageUri alias and omits it when None. Co-authored-by: Atenea Agent --- server/tests/test_routes_snapshots.py | 52 +++++++++++++++++++++++++++ server/tests/test_snapshot_service.py | 35 ++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/server/tests/test_routes_snapshots.py b/server/tests/test_routes_snapshots.py index 77dccccd5..617c70600 100644 --- a/server/tests/test_routes_snapshots.py +++ b/server/tests/test_routes_snapshots.py @@ -267,3 +267,55 @@ def get_sandbox(sandbox_id: str): assert response.status_code == 501 assert response.json()["code"] == "SNAPSHOT::NOT_IMPLEMENTED" + + +def test_get_snapshot_exposes_image_uri_alias( + client: TestClient, + auth_headers: dict, + monkeypatch, +) -> None: + now = datetime.now(timezone.utc) + + class StubService: + @staticmethod + def get_snapshot(snapshot_id: str) -> Snapshot: + return Snapshot( + id=snapshot_id, + sandboxId="sbx-001", + name="ready-snap", + status=SnapshotStatus(state="Ready"), + imageUri="opensandbox-snapshots:snap-001", + createdAt=now, + ) + + monkeypatch.setattr(lifecycle, "snapshot_service", StubService()) + + response = client.get("/v1/snapshots/snap-001", headers=auth_headers) + + assert response.status_code == 200 + assert response.json()["imageUri"] == "opensandbox-snapshots:snap-001" + + +def test_get_snapshot_omits_image_uri_when_absent( + client: TestClient, + auth_headers: dict, + monkeypatch, +) -> None: + now = datetime.now(timezone.utc) + + class StubService: + @staticmethod + def get_snapshot(snapshot_id: str) -> Snapshot: + return Snapshot( + id=snapshot_id, + sandboxId="sbx-001", + status=SnapshotStatus(state="Creating"), + createdAt=now, + ) + + monkeypatch.setattr(lifecycle, "snapshot_service", StubService()) + + response = client.get("/v1/snapshots/snap-001", headers=auth_headers) + + assert response.status_code == 200 + assert "imageUri" not in response.json() diff --git a/server/tests/test_snapshot_service.py b/server/tests/test_snapshot_service.py index dcb4f2e33..b8d242376 100644 --- a/server/tests/test_snapshot_service.py +++ b/server/tests/test_snapshot_service.py @@ -629,3 +629,38 @@ def test_snapshot_service_recovers_deleting_snapshot(tmp_path) -> None: assert runtime.delete_calls == [("snap-delete", "opensandbox-snapshots:snap-delete")] assert repo.get("snap-delete") is None + + +def test_get_snapshot_exposes_image_uri_when_ready(tmp_path) -> None: + repo = SQLiteSnapshotRepository(tmp_path / "snapshots.db") + repo.create( + _snapshot_record( + "snap-ready", + SnapshotState.READY, + image="opensandbox-snapshots:snap-ready", + ) + ) + service = PersistedSnapshotService( + repo, + StubSandboxService(), + snapshot_runtime=StubSnapshotRuntime(), + ) + + fetched = service.get_snapshot("snap-ready") + + assert fetched.image_uri == "opensandbox-snapshots:snap-ready" + + +def test_snapshot_response_has_no_image_uri_while_creating(tmp_path) -> None: + repo = SQLiteSnapshotRepository(tmp_path / "snapshots.db") + service = PersistedSnapshotService( + repo, + StubSandboxService(), + snapshot_runtime=StubSnapshotRuntime(), + snapshot_executor=CapturingExecutor(), + ) + + created = service.create_snapshot("sbx-001", CreateSnapshotRequest(name="checkpoint")) + + assert created.status.state == "Creating" + assert created.image_uri is None