open-swe/tests/test_repo_snapshots.py
Johannes du Plessis 3992d3ef5d
feat: repo-scoped dynamic sandbox snapshots (#1595)
* feat: repo-scoped dynamic sandbox snapshots

Let admins build a per-repo sandbox image from a custom Dockerfile so runs
targeting that repo boot from a snapshot with its deps pre-baked. Snapshot
selection is purely additive: repos without a `ready` repo-scoped snapshot
always fall back to the configured DEFAULT_SANDBOX_SNAPSHOT_ID.

Backend adds a repo_snapshots store module (Dockerfile + build status keyed by
owner/name), threads the resolved repo through the LangSmith sandbox creation
path, runs builds via SandboxClient.create_snapshot_from_dockerfile in a
throwaway builder sandbox, and exposes admin-only CRUD + build endpoints. The
UI adds an admin-only Agents-tab page (repo picker + Monaco Dockerfile editor +
build status/logs).

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: harden repo snapshot builds

Require REPO_SNAPSHOT_BASE_IMAGE for generated Dockerfile templates so admins
cannot accidentally build a repo snapshot from a bare Python image that lacks
Open SWE's sandbox tools. Allow stale building records to be retried by tracking
build_started_at and treating old or missing timestamps as stale.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: document repo snapshot base image config

Document REPO_SNAPSHOT_BASE_IMAGE alongside sandbox snapshot setup and convert
missing base-image configuration into a handled dashboard API error so admins see
a clear configuration message instead of an unhandled template-generation error.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-23 12:24:11 -07:00

311 lines
11 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import BackgroundTasks, HTTPException
from agent.dashboard import routes
from agent.dashboard.repo_snapshots import (
RepoSnapshotConfigError,
RepoSnapshotUpdate,
create_repo_snapshot,
generate_dockerfile_template,
is_repo_snapshot_build_stale,
mark_repo_snapshot_building,
resolve_repo_snapshot_id,
run_snapshot_build,
update_repo_snapshot,
)
def test_generate_dockerfile_template_uses_base_image() -> None:
with patch.dict("os.environ", {"REPO_SNAPSHOT_BASE_IMAGE": "ghcr.io/acme/base:1"}):
template = generate_dockerfile_template("acme/repo")
assert "FROM ghcr.io/acme/base:1" in template
assert "acme/repo" in template
def test_generate_dockerfile_template_requires_base_image() -> None:
with patch.dict("os.environ", {}, clear=True):
with pytest.raises(RepoSnapshotConfigError, match="REPO_SNAPSHOT_BASE_IMAGE"):
generate_dockerfile_template("acme/repo")
@pytest.mark.asyncio
async def test_template_endpoint_returns_configuration_error() -> None:
with patch.object(
routes,
"generate_dockerfile_template",
side_effect=RepoSnapshotConfigError("base image missing"),
):
with pytest.raises(HTTPException) as exc:
await routes.api_repo_snapshot_template("acme/repo", _admin={"sub": "octo"})
assert exc.value.status_code == 500
assert "base image missing" in exc.value.detail
@pytest.mark.asyncio
async def test_create_endpoint_returns_configuration_error() -> None:
body = routes.RepoSnapshotCreate(full_name="acme/repo")
with patch.object(
routes,
"create_repo_snapshot",
new_callable=AsyncMock,
side_effect=RepoSnapshotConfigError("base image missing"),
):
with pytest.raises(HTTPException) as exc:
await routes.api_create_repo_snapshot(body, _admin={"sub": "octo"})
assert exc.value.status_code == 500
assert "base image missing" in exc.value.detail
@pytest.mark.asyncio
async def test_resolve_returns_snapshot_id_when_ready() -> None:
with patch(
"agent.dashboard.repo_snapshots._get_value",
new_callable=AsyncMock,
return_value={"status": "ready", "snapshot_id": "snap-123"},
):
result = await resolve_repo_snapshot_id("acme", "repo")
assert result == "snap-123"
@pytest.mark.asyncio
async def test_resolve_returns_none_when_not_ready() -> None:
with patch(
"agent.dashboard.repo_snapshots._get_value",
new_callable=AsyncMock,
return_value={"status": "building", "snapshot_id": "snap-123"},
):
result = await resolve_repo_snapshot_id("acme", "repo")
assert result is None
@pytest.mark.asyncio
async def test_resolve_returns_none_without_record() -> None:
with patch(
"agent.dashboard.repo_snapshots._get_value",
new_callable=AsyncMock,
return_value=None,
):
result = await resolve_repo_snapshot_id("acme", "repo")
assert result is None
@pytest.mark.asyncio
async def test_resolve_returns_none_without_owner_or_name() -> None:
assert await resolve_repo_snapshot_id(None, "repo") is None
assert await resolve_repo_snapshot_id("acme", None) is None
@pytest.mark.asyncio
async def test_resolve_swallows_errors() -> None:
with patch(
"agent.dashboard.repo_snapshots._get_value",
new_callable=AsyncMock,
side_effect=RuntimeError("store down"),
):
assert await resolve_repo_snapshot_id("acme", "repo") is None
@pytest.mark.asyncio
async def test_create_repo_snapshot_puts_new_record() -> None:
mock_put = AsyncMock()
with (
patch.dict("os.environ", {"REPO_SNAPSHOT_BASE_IMAGE": "ghcr.io/acme/base:1"}),
patch(
"agent.dashboard.repo_snapshots.get_repo_snapshot",
new_callable=AsyncMock,
return_value=None,
),
patch("agent.dashboard.repo_snapshots._client") as mock_client,
):
mock_client.return_value.store.put_item = mock_put
record = await create_repo_snapshot("acme/repo", "octo")
assert record["full_name"] == "acme/repo"
assert record["status"] == "none"
assert "FROM" in record["dockerfile"]
mock_put.assert_awaited_once()
@pytest.mark.asyncio
async def test_update_repo_snapshot_persists_fields() -> None:
mock_put = AsyncMock()
with (
patch(
"agent.dashboard.repo_snapshots.get_repo_snapshot",
new_callable=AsyncMock,
return_value={"full_name": "acme/repo", "status": "none"},
),
patch("agent.dashboard.repo_snapshots._client") as mock_client,
):
mock_client.return_value.store.put_item = mock_put
record = await update_repo_snapshot(
"acme/repo",
RepoSnapshotUpdate(dockerfile="FROM python:3.12-slim\n", vcpus=4),
)
assert record["dockerfile"] == "FROM python:3.12-slim\n"
assert record["vcpus"] == 4
mock_put.assert_awaited_once()
@pytest.mark.asyncio
async def test_mark_building_sets_status() -> None:
mock_put = AsyncMock()
with (
patch(
"agent.dashboard.repo_snapshots.get_repo_snapshot",
new_callable=AsyncMock,
return_value={"full_name": "acme/repo", "status": "ready"},
),
patch("agent.dashboard.repo_snapshots._client") as mock_client,
):
mock_client.return_value.store.put_item = mock_put
record = await mark_repo_snapshot_building("acme/repo")
assert record["status"] == "building"
assert record["build_started_at"]
mock_put.assert_awaited_once()
def test_building_record_without_started_at_is_stale() -> None:
assert is_repo_snapshot_build_stale({"status": "building"}) is True
def test_recent_building_record_is_not_stale() -> None:
record = {"status": "building", "build_started_at": datetime.now(UTC).isoformat()}
assert is_repo_snapshot_build_stale(record) is False
def test_old_building_record_is_stale() -> None:
started = datetime.now(UTC) - timedelta(hours=7)
record = {"status": "building", "build_started_at": started.isoformat()}
assert is_repo_snapshot_build_stale(record) is True
@pytest.mark.asyncio
async def test_build_endpoint_blocks_non_stale_build() -> None:
record = {
"full_name": "acme/repo",
"status": "building",
"dockerfile": "FROM x",
"build_started_at": datetime.now(UTC).isoformat(),
}
with patch.object(routes, "get_repo_snapshot", new_callable=AsyncMock, return_value=record):
with pytest.raises(HTTPException) as exc:
await routes.api_build_repo_snapshot(
"acme/repo", BackgroundTasks(), _admin={"sub": "octo"}
)
assert exc.value.status_code == 409
@pytest.mark.asyncio
async def test_build_endpoint_allows_stale_build_retry() -> None:
stale_started = (datetime.now(UTC) - timedelta(hours=7)).isoformat()
stale = {
"full_name": "acme/repo",
"status": "building",
"dockerfile": "FROM x",
"build_started_at": stale_started,
}
building = {**stale, "build_started_at": datetime.now(UTC).isoformat()}
with (
patch.object(routes, "get_repo_snapshot", new_callable=AsyncMock, return_value=stale),
patch.object(
routes,
"mark_repo_snapshot_building",
new_callable=AsyncMock,
return_value=building,
) as mark_building,
):
result = await routes.api_build_repo_snapshot(
"acme/repo", BackgroundTasks(), _admin={"sub": "octo"}
)
assert result is building
mark_building.assert_awaited_once_with("acme/repo")
@pytest.mark.asyncio
async def test_run_snapshot_build_success_marks_ready() -> None:
statuses: list[tuple[str, dict | None]] = []
async def fake_set_status(full_name, status, *, status_message=None, extra=None):
statuses.append((status, extra))
with (
patch(
"agent.dashboard.repo_snapshots.get_repo_snapshot",
new_callable=AsyncMock,
return_value={"full_name": "acme/repo", "dockerfile": "FROM x"},
),
patch(
"agent.dashboard.repo_snapshots._build_snapshot_sync",
return_value=("snap-new", "build log"),
),
patch("agent.dashboard.repo_snapshots._set_status", side_effect=fake_set_status),
):
await run_snapshot_build("acme/repo")
assert statuses[-1][0] == "ready"
assert statuses[-1][1]["snapshot_id"] == "snap-new"
@pytest.mark.asyncio
async def test_run_snapshot_build_failure_marks_failed() -> None:
statuses: list[str] = []
async def fake_set_status(full_name, status, *, status_message=None, extra=None):
statuses.append(status)
with (
patch(
"agent.dashboard.repo_snapshots.get_repo_snapshot",
new_callable=AsyncMock,
return_value={"full_name": "acme/repo", "dockerfile": "FROM x"},
),
patch(
"agent.dashboard.repo_snapshots._build_snapshot_sync",
side_effect=RuntimeError("boom"),
),
patch("agent.dashboard.repo_snapshots._set_status", side_effect=fake_set_status),
):
await run_snapshot_build("acme/repo")
assert statuses[-1] == "failed"
def test_create_langsmith_sandbox_uses_repo_snapshot_override() -> None:
from agent.integrations import langsmith
fake_backend = MagicMock()
fake_backend.id = "box-1"
provider = MagicMock()
provider.get_or_create.return_value = fake_backend
with (
patch.dict("os.environ", {"DEFAULT_SANDBOX_SNAPSHOT_ID": "env-default"}, clear=True),
patch.object(langsmith, "LangSmithProvider", return_value=provider),
patch.object(langsmith, "_update_thread_sandbox_metadata"),
):
langsmith.create_langsmith_sandbox(snapshot_id="repo-snap")
assert provider.get_or_create.call_args.kwargs["snapshot_id"] == "repo-snap"
def test_create_langsmith_sandbox_falls_back_to_default() -> None:
from agent.integrations import langsmith
fake_backend = MagicMock()
fake_backend.id = "box-2"
provider = MagicMock()
provider.get_or_create.return_value = fake_backend
with (
patch.dict("os.environ", {"DEFAULT_SANDBOX_SNAPSHOT_ID": "env-default"}, clear=True),
patch.object(langsmith, "LangSmithProvider", return_value=provider),
patch.object(langsmith, "_update_thread_sandbox_metadata"),
):
langsmith.create_langsmith_sandbox()
assert provider.get_or_create.call_args.kwargs["snapshot_id"] == "env-default"