Skip to content

Commit 5f01d52

Browse files
dcramercodex
andcommitted
Recreate stale sandbox containers after image rebuild
Co-Authored-By: GPT-5 Codex <codex@openai.com>
1 parent 9f51c85 commit 5f01d52

3 files changed

Lines changed: 79 additions & 4 deletions

File tree

src/ash/sandbox/executor.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ def _clear_managed_container_state(self) -> None:
269269
async def _resolve_managed_container(self, managed_name: str | None) -> str | None:
270270
if not managed_name:
271271
return None
272+
expected_image_id = await self._manager.get_image_id(self._config.image)
272273
state = self._read_managed_container_state()
273274
refs: list[str] = []
274275
if state:
@@ -284,11 +285,34 @@ async def _resolve_managed_container(self, managed_name: str | None) -> str | No
284285
if container is None:
285286
continue
286287
attrs = container.attrs if isinstance(container.attrs, dict) else {}
288+
container_image_id = str(attrs.get("Image") or "")
289+
if (
290+
expected_image_id
291+
and container_image_id
292+
and container_image_id != expected_image_id
293+
):
294+
logger.info(
295+
"sandbox_container_image_id_mismatch_pruned",
296+
extra={
297+
"container.id": container.id[:12],
298+
"container.image_id": container_image_id,
299+
"sandbox.image": self._config.image,
300+
"sandbox.image_id": expected_image_id,
301+
},
302+
)
303+
await self._manager.remove_container(container.id, force=True)
304+
continue
287305
config = attrs.get("Config", {})
288306
config_image = (
289307
str(config.get("Image") or "") if isinstance(config, dict) else ""
290308
)
291-
if config_image != self._config.image:
309+
# Fallback for older container metadata: tag/name should match config image.
310+
# TODO(2026-03-10): Remove this fallback after stale managed containers
311+
# have rolled out and image-id matching has been in production for a bit.
312+
if config_image and config_image not in {
313+
self._config.image,
314+
expected_image_id,
315+
}:
292316
logger.info(
293317
"sandbox_container_image_mismatch_pruned",
294318
extra={

src/ash/sandbox/manager.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,16 @@ async def ensure_image(self, dockerfile_path: Path | None = None) -> bool:
152152
)
153153
return False
154154

155+
async def get_image_id(self, image_ref: str) -> str | None:
156+
"""Return canonical image id (sha256:...) for an image reference."""
157+
client = await self._ensure_client()
158+
try:
159+
image = await asyncio.to_thread(client.images.get, image_ref)
160+
except ImageNotFound:
161+
return None
162+
image_id = str(getattr(image, "id", "") or "")
163+
return image_id or None
164+
155165
async def _build_image(self, dockerfile_path: Path) -> None:
156166
client = await self._ensure_client()
157167
await asyncio.to_thread(

tests/test_sandbox_executor.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,17 @@ def __init__(
1111
*,
1212
container_id: str,
1313
image: str,
14+
image_id: str = "sha256:latest",
1415
status: str = "running",
1516
mounts: list[dict[str, str]] | None = None,
1617
) -> None:
1718
self.id = container_id
1819
self.status = status
19-
self.attrs = {"Config": {"Image": image}, "Mounts": mounts or []}
20+
self.attrs = {
21+
"Image": image_id,
22+
"Config": {"Image": image},
23+
"Mounts": mounts or [],
24+
}
2025

2126

2227
class _FakeManager:
@@ -25,6 +30,7 @@ def __init__(self) -> None:
2530
self.removed: list[str] = []
2631
self.start_should_fail = False
2732
self.exec_should_fail = False
33+
self.expected_image_id: str | None = "sha256:latest"
2834
self._containers: dict[str, _FakeContainer] = {}
2935
self._by_name: dict[str, str] = {}
3036

@@ -35,7 +41,9 @@ async def create_container(self, name=None, environment=None) -> str:
3541
container_id = f"c{len(self.created) + 1}"
3642
self.created.append(container_id)
3743
container = _FakeContainer(
38-
container_id=container_id, image="ash-sandbox:latest"
44+
container_id=container_id,
45+
image="ash-sandbox:latest",
46+
image_id=self.expected_image_id or "sha256:latest",
3947
)
4048
self._containers[container_id] = container
4149
if name:
@@ -89,6 +97,10 @@ async def remove_container(self, container_id: str, force: bool = True) -> None:
8997
if cid == container_id:
9098
self._by_name.pop(name, None)
9199

100+
async def get_image_id(self, image_ref: str) -> str | None:
101+
_ = image_ref
102+
return self.expected_image_id
103+
92104

93105
def _patch_runtime_paths(monkeypatch, tmp_path: Path) -> None:
94106
monkeypatch.setattr("ash.sandbox.executor.get_run_path", lambda: tmp_path / "run")
@@ -181,7 +193,36 @@ async def test_reuse_container_prunes_image_mismatch_then_recreates(
181193
manager = _FakeManager()
182194
shared_name = "ash-sandbox-deadbeef"
183195
manager._containers["stale"] = _FakeContainer(
184-
container_id="stale", image="old-image-id", status="running"
196+
container_id="stale",
197+
image="old-image-id",
198+
image_id="sha256:stale",
199+
status="running",
200+
)
201+
manager._by_name[shared_name] = "stale"
202+
203+
executor = SandboxExecutor()
204+
executor._manager = manager
205+
executor._initialized = True
206+
monkeypatch.setattr(executor, "_managed_container_name", lambda: shared_name)
207+
208+
result = await executor.execute("echo hi", reuse_container=True)
209+
assert result.success is True
210+
assert "stale" in manager.removed
211+
assert manager.created == ["c1"]
212+
213+
214+
async def test_reuse_container_prunes_stale_latest_image_id_then_recreates(
215+
tmp_path: Path, monkeypatch
216+
) -> None:
217+
_patch_runtime_paths(monkeypatch, tmp_path)
218+
manager = _FakeManager()
219+
manager.expected_image_id = "sha256:newlatest"
220+
shared_name = "ash-sandbox-deadbeef"
221+
manager._containers["stale"] = _FakeContainer(
222+
container_id="stale",
223+
image="ash-sandbox:latest",
224+
image_id="sha256:oldlatest",
225+
status="running",
185226
)
186227
manager._by_name[shared_name] = "stale"
187228

0 commit comments

Comments
 (0)