Skip to content

Commit dfba789

Browse files
art-dsitclaude
andauthored
Bundle ProvisionedInstance through the sandbox env (#16)
* Collapse cleanup-provider resolver and defer Ubuntu AMI lookup `_resolve_cleanup_provider` duplicated `_resolve_provider`; the three cleanup paths now share the latter. `_find_ami_ubu24` moves from `schema.py` to `_instance_provider.py` and is called lazily from `DefaultEc2InstanceProvider.create_instance`, cached per region on the class so subsequent samples (and `cli_cleanup`) don't repeat the SSM lookup. `from_settings()` is now a pure-Python operation and no longer takes a `session` argument. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Keep from_settings(session=) backwards-compatible; note AMI cache lifetime from_settings dropped its session parameter, which turned any external caller passing session= into a pydantic ValidationError. Restore it as an accepted-but-ignored kwarg so callers (e.g. custom Ec2InstanceProvider wrappers) don't break; AMI resolution stays deferred either way. Also document that the per-region AMI cache is intentionally never invalidated (short-lived process, one AMI per run is desirable). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Make from_settings reject session (and other unknown kwargs) loudly Reverses the previous commit's silent-compat approach. Dropping the session parameter alone didn't break callers: pydantic BaseModel defaults to extra="ignore", so session= (and any typo'd override) was silently swallowed by **kwargs and dropped. That silent no-op is worse than a clean break — a caller passing a session thinks it's used. Validate kwargs against the model's fields and raise TypeError on any unknown key. session= now fails loudly; this is the intended breaking change (documented in CHANGELOG). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Use pydantic extra="forbid" instead of manual kwarg validation More idiomatic than the hand-rolled set-diff: the model now rejects unknown fields at construction, so a stale from_settings(session=...) or any typo'd override raises a ValidationError instead of being silently dropped (pydantic's default is extra="ignore"). Also catches bad keys in a user-supplied config dict via config_deserialize. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * shut UP * tersify * Bundle ProvisionedInstance through the sandbox env `region` was an explicit parameter on the env constructor, on the sample-cleanup tracker (Set[Tuple[str, str]]), on the provider's find_sandbox_instances(), and on the cli_cleanup fallback dance. All of those were really one piece of information — instance metadata — travelling alongside instance_id. Pass ProvisionedInstance through whole: - frozen so it can sit in a Set - Ec2SandboxEnvironment.__init__ takes one (with flat self.X aliases for the rest of the file's reads) - _tracked_instances is Set[ProvisionedInstance] - find_sandbox_instances() drops its region param; the default impl derives it from its own config (with AWS_REGION fallback for the cli_cleanup path that builds an empty config) - cli_cleanup drops the fallback_region env-var read terminate_instance(instance_id, region) keeps its signature: region is the instance's own region, which a control plane in a different region (e.g. the Lambda-backed provider) needs to route the call. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * tidy changelog --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 92d6eb3 commit dfba789

5 files changed

Lines changed: 73 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- custom `Ec2InstanceProvider` must drop the `region` parameter from `find_sandbox_instances()`.
56
- `Ec2SandboxEnvironmentConfig.from_settings()` no longer accepts a `session` argument (use Ec2SandboxEnvironment.set_session()).
67
- Custom `Ec2InstanceProvider`s are now resolved regardless of entry-point import order.
78
- Interrupted samples (Ctrl-C, failed setup script) no longer leak EC2 instances.

src/ec2sandbox/_ec2_sandbox_environment.py

Lines changed: 25 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from importlib.metadata import entry_points
1010
from logging import getLogger
1111
from pathlib import Path
12-
from typing import Any, ClassVar, Dict, List, Set, Tuple, Union
12+
from typing import Any, ClassVar, Dict, List, Set, Union
1313

1414
import boto3
1515
from botocore.exceptions import ClientError, WaiterError
@@ -32,6 +32,7 @@
3232
MARKER_TAG_KEY,
3333
DefaultEc2InstanceProvider,
3434
Ec2InstanceProvider,
35+
ProvisionedInstance,
3536
get_ec2_instance_provider,
3637
get_provider_session,
3738
)
@@ -57,13 +58,13 @@ class Ec2SandboxEnvironment(SandboxEnvironment):
5758

5859
_providers_loaded: ClassVar[bool] = False
5960

60-
# Process-global tracker of provisioned (instance_id, region) tuples.
61+
# Process-global tracker of provisioned instances.
6162
# sample_init registers, successful sample_cleanup deregisters,
6263
# task_cleanup sweeps survivors (Ctrl-C, setup-script failures, etc.).
6364
# Matches the k8s / proxmox sandbox pattern of a shared tracker — note
6465
# that inspect_ai calls task_cleanup with task_name="shutdown" (a phase
6566
# marker, not the real task name) so we cannot key by task_name.
66-
_tracked_instances: ClassVar[Set[Tuple[str, str]]] = set()
67+
_tracked_instances: ClassVar[Set[ProvisionedInstance]] = set()
6768

6869
@classmethod
6970
def set_session(cls, session: boto3.Session) -> None:
@@ -100,25 +101,22 @@ def _ensure_providers_loaded(cls) -> None:
100101
"failed loading inspect_ai entry point %s", ep.value, exc_info=True
101102
)
102103

103-
def __init__(
104-
self,
105-
instance_id: str,
106-
region: str,
107-
s3_bucket: str,
108-
s3_key_prefix: str = "",
109-
):
110-
self.instance_id = instance_id
111-
self.region = region
112-
self.s3_bucket = s3_bucket
113-
self.s3_key_prefix = s3_key_prefix
104+
def __init__(self, provisioned: ProvisionedInstance):
105+
self.provisioned = provisioned
106+
# Flat aliases so the rest of the class (and downstream readers)
107+
# don't have to reach through self.provisioned for every call.
108+
self.instance_id = provisioned.instance_id
109+
self.region = provisioned.region
110+
self.s3_bucket = provisioned.s3_bucket
111+
self.s3_key_prefix = provisioned.s3_key_prefix
114112
session = self._get_session()
115-
self.ssm_client = session.client("ssm", region_name=region)
113+
self.ssm_client = session.client("ssm", region_name=self.region)
116114
self.s3_client = session.client(
117115
"s3",
118-
region_name=region,
119-
endpoint_url=f"https://s3.{region}.amazonaws.com",
116+
region_name=self.region,
117+
endpoint_url=f"https://s3.{self.region}.amazonaws.com",
120118
)
121-
self.ec2_client = session.client("ec2", region_name=region)
119+
self.ec2_client = session.client("ec2", region_name=self.region)
122120

123121
@classmethod
124122
@override
@@ -209,15 +207,8 @@ async def sample_init(
209207
result.region,
210208
)
211209

212-
cls._tracked_instances.add((result.instance_id, result.region))
213-
214-
environment = Ec2SandboxEnvironment(
215-
instance_id=result.instance_id,
216-
region=result.region,
217-
s3_bucket=result.s3_bucket,
218-
s3_key_prefix=result.s3_key_prefix,
219-
)
220-
return {"default": environment}
210+
cls._tracked_instances.add(result)
211+
return {"default": Ec2SandboxEnvironment(result)}
221212

222213
@classmethod
223214
@override
@@ -241,7 +232,7 @@ async def sample_cleanup(
241232
if not isinstance(env, Ec2SandboxEnvironment):
242233
continue
243234
await provider.terminate_instance(env.instance_id, env.region)
244-
cls._tracked_instances.discard((env.instance_id, env.region))
235+
cls._tracked_instances.discard(env.provisioned)
245236
return None
246237

247238
@classmethod
@@ -270,15 +261,15 @@ async def task_cleanup(
270261
return None
271262

272263
provider, _ = cls._resolve_provider(config)
273-
for instance_id, region in tracked:
264+
for inst in tracked:
274265
try:
275-
await provider.terminate_instance(instance_id, region)
266+
await provider.terminate_instance(inst.instance_id, inst.region)
276267
except Exception as e:
277268
# Per-item: one bad termination must not block the others.
278269
cls.logger.warning(
279270
"task_cleanup: failed to terminate %s in %s: %s",
280-
instance_id,
281-
region,
271+
inst.instance_id,
272+
inst.region,
282273
e,
283274
)
284275
return None
@@ -347,8 +338,7 @@ async def cli_cleanup(cls, id: str | None) -> None:
347338

348339
provider, _ = cls._resolve_provider(None)
349340

350-
fallback_region = os.getenv("AWS_REGION", os.getenv("AWS_DEFAULT_REGION", ""))
351-
instances = await provider.find_sandbox_instances(fallback_region)
341+
instances = await provider.find_sandbox_instances()
352342

353343
if not instances:
354344
print("\nNo EC2 sandbox instances found to clean up.\n")
@@ -370,8 +360,7 @@ async def cli_cleanup(cls, id: str | None) -> None:
370360
return
371361

372362
for inst in instances:
373-
region = inst.region or fallback_region
374-
await provider.terminate_instance(inst.instance_id, region)
363+
await provider.terminate_instance(inst.instance_id, inst.region)
375364

376365
@staticmethod
377366
def _confirm_cleanup() -> bool:

src/ec2sandbox/_instance_provider.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from __future__ import annotations
1616

1717
import logging
18+
import os
1819
from dataclasses import dataclass
1920
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, runtime_checkable
2021

@@ -35,12 +36,13 @@
3536
MARKER_TAG_KEY = "inspect_sandbox"
3637

3738

38-
@dataclass
39+
@dataclass(frozen=True)
3940
class ProvisionedInstance:
4041
"""Result of provisioning an EC2 sandbox instance.
4142
4243
Contains everything the :class:`Ec2SandboxEnvironment` needs for
4344
runtime operations (exec / read_file / write_file via SSM + S3).
45+
Frozen so it can be used as a set element by the cleanup tracker.
4446
"""
4547

4648
instance_id: str
@@ -49,7 +51,7 @@ class ProvisionedInstance:
4951
s3_key_prefix: str = ""
5052

5153

52-
@dataclass
54+
@dataclass(frozen=True)
5355
class SandboxInstanceInfo:
5456
"""Metadata for a discovered sandbox instance (used by cli_cleanup)."""
5557

@@ -93,10 +95,15 @@ async def create_instance(
9395
...
9496

9597
async def terminate_instance(self, instance_id: str, region: str) -> None:
96-
"""Terminate an EC2 instance."""
98+
"""Terminate an EC2 instance.
99+
100+
``region`` is the instance's own region (from ``create_instance``'s
101+
:class:`ProvisionedInstance`); a control plane in a different
102+
region (e.g. a Lambda) uses it to route the call.
103+
"""
97104
...
98105

99-
async def find_sandbox_instances(self, region: str) -> list[SandboxInstanceInfo]:
106+
async def find_sandbox_instances(self) -> list[SandboxInstanceInfo]:
100107
"""Find running sandbox instances for interactive cleanup."""
101108
...
102109

@@ -203,7 +210,8 @@ class DefaultEc2InstanceProvider:
203210
etc.) from the supplied :class:`Ec2SandboxEnvironmentConfig` at the
204211
point they are needed — ``create_instance`` requires the full set,
205212
while ``terminate_instance`` and ``find_sandbox_instances`` only
206-
need the supplied ``region``.
213+
need a region (the instance's own region for terminate; the
214+
configured region for find).
207215
"""
208216

209217
# Process-global Ubuntu 24.04 AMI cache keyed on region. Canonical's
@@ -315,7 +323,14 @@ async def terminate_instance(self, instance_id: str, region: str) -> None:
315323
ec2 = self._session.client("ec2", region_name=region or None)
316324
ec2.terminate_instances(InstanceIds=[instance_id])
317325

318-
async def find_sandbox_instances(self, region: str) -> list[SandboxInstanceInfo]:
326+
async def find_sandbox_instances(self) -> list[SandboxInstanceInfo]:
327+
# cli_cleanup builds this provider with an empty config (no region),
328+
# so fall back to AWS_REGION / AWS_DEFAULT_REGION as the session does.
329+
region = (
330+
self._config.region
331+
or os.getenv("AWS_REGION")
332+
or os.getenv("AWS_DEFAULT_REGION")
333+
)
319334
ec2 = self._session.client("ec2", region_name=region or None)
320335
response = ec2.describe_instances(
321336
Filters=[
@@ -338,7 +353,7 @@ async def find_sandbox_instances(self, region: str) -> list[SandboxInstanceInfo]
338353
SandboxInstanceInfo(
339354
instance_id=instance["InstanceId"],
340355
name=name,
341-
region=region,
356+
region=region or "",
342357
)
343358
)
344359
return results

tests/ec2sandboxtest/integration/test_cleanup_behaviour.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
REGION = "eu-west-2"
3131

3232

33+
def _tracked_ids() -> set[str]:
34+
return {p.instance_id for p in Ec2SandboxEnvironment._tracked_instances}
35+
36+
3337
async def _provision(
3438
config: Ec2SandboxEnvironmentConfig,
3539
task_name: str,
@@ -47,7 +51,7 @@ async def test_happy_path_terminates_via_sample_cleanup(
4751
"""sample_cleanup(interrupted=False) terminates the instance and clears the tracker.""" # noqa: E501
4852
envs, inst_id = await _provision(ec2_config, "test_happy")
4953
try:
50-
assert (inst_id, REGION) in Ec2SandboxEnvironment._tracked_instances
54+
assert inst_id in _tracked_ids()
5155

5256
await Ec2SandboxEnvironment.sample_cleanup(
5357
task_name="test_happy",
@@ -56,7 +60,7 @@ async def test_happy_path_terminates_via_sample_cleanup(
5660
interrupted=False,
5761
)
5862

59-
assert (inst_id, REGION) not in Ec2SandboxEnvironment._tracked_instances
63+
assert inst_id not in _tracked_ids()
6064
assert wait_until_terminated(inst_id) in ("terminated", "shutting-down", None)
6165
finally:
6266
# Defensive: even if assertions failed, don't leak the instance.
@@ -80,14 +84,14 @@ async def test_interrupted_sample_cleanup_skips_then_task_cleanup_sweeps(
8084

8185
time.sleep(5)
8286
assert instance_state(inst_id) in ("pending", "running")
83-
assert (inst_id, REGION) in Ec2SandboxEnvironment._tracked_instances
87+
assert inst_id in _tracked_ids()
8488

8589
# inspect_ai always calls task_cleanup with task_name="shutdown".
8690
await Ec2SandboxEnvironment.task_cleanup(
8791
task_name="shutdown", config=ec2_config, cleanup=True
8892
)
8993

90-
assert (inst_id, REGION) not in Ec2SandboxEnvironment._tracked_instances
94+
assert inst_id not in _tracked_ids()
9195
assert wait_until_terminated(inst_id) in ("terminated", "shutting-down", None)
9296
finally:
9397
if instance_state(inst_id) in ("pending", "running"):
@@ -124,9 +128,7 @@ async def test_multiple_samples_one_interrupted(
124128
envs_a, inst_a = await _provision(ec2_config, "test_multi")
125129
envs_b, inst_b = await _provision(ec2_config, "test_multi")
126130
try:
127-
assert {(inst_a, REGION), (inst_b, REGION)}.issubset(
128-
Ec2SandboxEnvironment._tracked_instances
129-
)
131+
assert {inst_a, inst_b}.issubset(_tracked_ids())
130132

131133
# Sample A succeeds.
132134
await Ec2SandboxEnvironment.sample_cleanup(
@@ -135,8 +137,8 @@ async def test_multiple_samples_one_interrupted(
135137
environments=envs_a,
136138
interrupted=False,
137139
)
138-
assert (inst_a, REGION) not in Ec2SandboxEnvironment._tracked_instances
139-
assert (inst_b, REGION) in Ec2SandboxEnvironment._tracked_instances
140+
assert inst_a not in _tracked_ids()
141+
assert inst_b in _tracked_ids()
140142

141143
# Sample B interrupted.
142144
await Ec2SandboxEnvironment.sample_cleanup(
@@ -145,14 +147,14 @@ async def test_multiple_samples_one_interrupted(
145147
environments=envs_b,
146148
interrupted=True,
147149
)
148-
assert (inst_b, REGION) in Ec2SandboxEnvironment._tracked_instances
150+
assert inst_b in _tracked_ids()
149151

150152
# task_cleanup sweeps the leftover.
151153
await Ec2SandboxEnvironment.task_cleanup(
152154
task_name="shutdown", config=ec2_config, cleanup=True
153155
)
154156

155-
assert (inst_b, REGION) not in Ec2SandboxEnvironment._tracked_instances
157+
assert inst_b not in _tracked_ids()
156158
assert wait_until_terminated(inst_a) in ("terminated", "shutting-down", None)
157159
assert wait_until_terminated(inst_b) in ("terminated", "shutting-down", None)
158160
finally:

tests/ec2sandboxtest/test_cleanup_unit.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ async def terminate_instance(self, instance_id: str, region: str) -> None:
3131
raise RuntimeError(f"simulated terminate failure for {instance_id}")
3232
self.terminated.append((instance_id, region))
3333

34-
async def find_sandbox_instances(self, region): # pragma: no cover
34+
async def find_sandbox_instances(self): # pragma: no cover
3535
return []
3636

3737

@@ -61,9 +61,15 @@ async def create_instance(**kwargs):
6161
)
6262

6363

64+
def _provisioned(instance_id: str, region: str = "eu-west-2") -> ProvisionedInstance:
65+
return ProvisionedInstance(
66+
instance_id=instance_id, region=region, s3_bucket="bucket-1"
67+
)
68+
69+
6470
async def test_sample_init_registers_instance_in_tracker():
6571
await _seed_environment("task_a", instance_id="i-111")
66-
assert Ec2SandboxEnvironment._tracked_instances == {("i-111", "eu-west-2")}
72+
assert Ec2SandboxEnvironment._tracked_instances == {_provisioned("i-111")}
6773

6874

6975
async def test_sample_cleanup_interrupted_leaves_tracker_intact():
@@ -79,7 +85,7 @@ async def test_sample_cleanup_interrupted_leaves_tracker_intact():
7985
)
8086

8187
assert terminator.terminated == []
82-
assert Ec2SandboxEnvironment._tracked_instances == {("i-111", "eu-west-2")}
88+
assert Ec2SandboxEnvironment._tracked_instances == {_provisioned("i-111")}
8389

8490

8591
async def test_sample_cleanup_success_terminates_and_deregisters():
@@ -107,8 +113,8 @@ async def test_task_cleanup_sweeps_remaining_tracked_instances():
107113
await _seed_environment("task_a", instance_id="i-111")
108114
await _seed_environment("task_b", instance_id="i-222")
109115
assert Ec2SandboxEnvironment._tracked_instances == {
110-
("i-111", "eu-west-2"),
111-
("i-222", "eu-west-2"),
116+
_provisioned("i-111"),
117+
_provisioned("i-222"),
112118
}
113119

114120
terminator = _FakeProvider()
@@ -195,4 +201,4 @@ async def test_sample_cleanup_only_deregisters_its_own_environments():
195201
)
196202

197203
assert terminator.terminated == [("i-aaa", "eu-west-2")]
198-
assert Ec2SandboxEnvironment._tracked_instances == {("i-bbb", "eu-west-2")}
204+
assert Ec2SandboxEnvironment._tracked_instances == {_provisioned("i-bbb")}

0 commit comments

Comments
 (0)