Skip to content

Commit bf3bb4e

Browse files
art-dsitclaude
andcommitted
Stop reimplementing region resolution; let the boto3 session do it
Region was resolved by a hand-rolled os.getenv("AWS_REGION", "AWS_DEFAULT_REGION") ladder in ~two places. That reimplements botocore's own chain incompletely (it skips the active profile's ~/.aws/config), and schema.py turned "no region configured" into a ValueError instead of the loud NoRegionError boto3 raises. Let the session resolve region: build the client with region_name=config.region (an explicit override, or None to fall through the chain) and read the concrete value back off client.meta.region_name for the AMI lookup, SSM client, and ProvisionedInstance/SandboxInstanceInfo. region stays instance data. - schema.from_settings: drop the AWS_REGION ladder + the required-region ValueError; region is now an optional override (None -> session resolves). - DefaultEc2InstanceProvider: region no longer a required config field; resolve it off the ec2 client in create_instance and find_sandbox_instances. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bd7224d commit bf3bb4e

5 files changed

Lines changed: 74 additions & 45 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ and allow the end-user to specify the rest.
8383
The following environment variables must be set:
8484

8585
```bash
86-
INSPECT_EC2_SANDBOX_REGION=eu-west-1
8786
INSPECT_EC2_SANDBOX_VPC_ID=vpc-123456
8887
INSPECT_EC2_SANDBOX_SECURITY_GROUP_ID=sg-56781234
8988
INSPECT_EC2_SANDBOX_SUBNET_ID=subnet-654321
@@ -94,12 +93,17 @@ INSPECT_EC2_SANDBOX_S3_BUCKET=ec2sandboxstack-databucket123-456
9493
The following environment variables are optional:
9594

9695
```bash
96+
INSPECT_EC2_SANDBOX_REGION=eu-west-1
9797
INSPECT_EC2_SANDBOX_AMI_ID=ami-123456
9898
INSPECT_EC2_SANDBOX_INSTANCE_TYPE=t3a.small
9999
INSPECT_EC2_SANDBOX_S3_KEY_PREFIX=sandbox-comms
100100
INSPECT_EC2_SANDBOX_EXTRA_TAGS_STR='tagname1=tagvalue1;tagname2=tagvalue2'
101101
```
102102

103+
`INSPECT_EC2_SANDBOX_REGION` is only needed to override the region. When it
104+
is unset the boto3 session resolves the region the usual way (`AWS_REGION`,
105+
`AWS_DEFAULT_REGION`, then the active profile's `~/.aws/config`).
106+
103107
### Configuration
104108

105109
As an alternative to the above environment variables you can specify the configuration directly in code, e.g

src/ec2sandbox/_instance_provider.py

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

1717
import logging
18-
import os
1918
from dataclasses import dataclass
2019
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, runtime_checkable
2120

@@ -206,12 +205,12 @@ class DefaultEc2InstanceProvider:
206205
"""Default :class:`Ec2InstanceProvider` using direct boto3 calls.
207206
208207
Used by the EC2 sandbox when no custom provider has been registered.
209-
Reads the infrastructure fields (``region``, ``security_group_id``,
208+
Reads the infrastructure fields (``security_group_id``, ``subnet_id``,
210209
etc.) from the supplied :class:`Ec2SandboxEnvironmentConfig` at the
211-
point they are needed — ``create_instance`` requires the full set,
212-
while ``terminate_instance`` and ``find_sandbox_instances`` only
213-
need a region (the instance's own region for terminate; the
214-
configured region for find).
210+
point they are needed. Region is never required in the config: each
211+
method builds its client with ``region_name=config.region`` (an
212+
explicit override, or ``None`` to let the session resolve it) and reads
213+
the resolved region back off the client.
215214
"""
216215

217216
# Process-global Ubuntu 24.04 AMI cache keyed on region. Canonical's
@@ -249,7 +248,6 @@ async def create_instance(
249248
) -> ProvisionedInstance:
250249
cfg = self._config
251250
required = {
252-
"region": cfg.region,
253251
"security_group_id": cfg.security_group_id,
254252
"subnet_id": cfg.subnet_id,
255253
"instance_profile": cfg.instance_profile,
@@ -264,11 +262,17 @@ async def create_instance(
264262
"register an Ec2InstanceProvider."
265263
)
266264

265+
# region_name=cfg.region is an explicit override when set; otherwise
266+
# the session resolves it and raises NoRegionError if nothing is
267+
# configured. Read the resolved value back off the client so the rest
268+
# of the method (AMI lookup, SSM client, ProvisionedInstance) uses a
269+
# concrete region rather than a possibly-None config field.
270+
ec2_client = self._session.client("ec2", region_name=cfg.region)
271+
region = ec2_client.meta.region_name
272+
267273
if not ami_id:
268-
assert cfg.region is not None # validated above
269-
ami_id = self._resolve_ubu24_ami(cfg.region)
274+
ami_id = self._resolve_ubu24_ami(region)
270275

271-
ec2_client = self._session.client("ec2", region_name=cfg.region)
272276
instance_params: dict[str, Any] = {
273277
"ImageId": ami_id,
274278
"InstanceType": instance_type,
@@ -297,7 +301,7 @@ async def create_instance(
297301
waiter = ec2_client.get_waiter("instance_running")
298302
waiter.wait(InstanceIds=[instance_id])
299303

300-
ssm_client = self._session.client("ssm", region_name=cfg.region)
304+
ssm_client = self._session.client("ssm", region_name=region)
301305
_wait_for_ssm(instance_id, ssm_client)
302306
except BaseException:
303307
try:
@@ -310,11 +314,10 @@ async def create_instance(
310314
)
311315
raise
312316

313-
assert cfg.region is not None # validated above
314317
assert cfg.s3_bucket is not None # validated above
315318
return ProvisionedInstance(
316319
instance_id=instance_id,
317-
region=cfg.region,
320+
region=region,
318321
s3_bucket=cfg.s3_bucket,
319322
s3_key_prefix=cfg.s3_key_prefix,
320323
)
@@ -324,14 +327,11 @@ async def terminate_instance(self, instance_id: str, region: str) -> None:
324327
ec2.terminate_instances(InstanceIds=[instance_id])
325328

326329
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-
)
334-
ec2 = self._session.client("ec2", region_name=region or None)
330+
# cli_cleanup builds this provider with an empty config, so region is
331+
# usually None here and the session resolves it. Read the resolved
332+
# region back off the client to stamp on each SandboxInstanceInfo.
333+
ec2 = self._session.client("ec2", region_name=self._config.region)
334+
region = ec2.meta.region_name
335335
response = ec2.describe_instances(
336336
Filters=[
337337
{"Name": f"tag:{MARKER_TAG_KEY}", "Values": ["true"]},
@@ -353,7 +353,7 @@ async def find_sandbox_instances(self) -> list[SandboxInstanceInfo]:
353353
SandboxInstanceInfo(
354354
instance_id=instance["InstanceId"],
355355
name=name,
356-
region=region or "",
356+
region=region,
357357
)
358358
)
359359
return results

src/ec2sandbox/schema.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
Inspect EC2 sandbox environments.
66
"""
77

8-
import os
98
from typing import Optional, Tuple
109

1110
from pydantic import BaseModel, ConfigDict
@@ -66,10 +65,14 @@ class Ec2SandboxEnvironmentConfig(BaseModel):
6665
s3_key_prefix: str = ""
6766
volume_size: Optional[int] = None
6867

68+
# Optional explicit region override. None -> the boto3 session resolves
69+
# the region (AWS_REGION / AWS_DEFAULT_REGION / ~/.aws/config) when it
70+
# builds a client. A set value sits at the top of that chain.
71+
region: Optional[str] = None
72+
6973
# Direct-EC2-path fields — required when no Ec2InstanceProvider is
7074
# registered, ignored otherwise. ``sample_init`` validates these at
7175
# call time when the direct path is taken.
72-
region: Optional[str] = None
7376
# TODO is vpc_id actually needed? We could just force a subnet ID.
7477
vpc_id: Optional[str] = None
7578
security_group_id: Optional[str] = None
@@ -102,15 +105,11 @@ def from_settings(cls, **kwargs):
102105
# Override with any provided kwargs
103106
params.update(kwargs)
104107

105-
region = params["region"]
106-
if region is None:
107-
region = os.getenv("AWS_REGION")
108-
if not isinstance(region, str):
109-
raise ValueError(
110-
"Region must be specified either in settings,"
111-
f" or as an environment variable {env_prefix}REGION or AWS_REGION."
112-
)
113-
params["region"] = region
108+
# region is left as-is (may be None). Don't reimplement botocore's
109+
# resolution chain: the boto3 session resolves the region (AWS_REGION
110+
# / AWS_DEFAULT_REGION / ~/.aws/config) at client-construction time,
111+
# and raises NoRegionError loudly if nothing is configured. An explicit
112+
# INSPECT_EC2_SANDBOX_REGION (or region= kwarg) overrides that chain.
114113

115114
# AMI resolution is deferred to DefaultEc2InstanceProvider.create_instance
116115
# so that callers who only need terminate/find don't pay for an SSM

tests/ec2sandboxtest/test_from_settings.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,23 +36,26 @@ def test_kwarg_overrides_env_var() -> None:
3636
assert config.instance_type == "t3a.xlarge"
3737

3838

39-
def test_region_falls_back_to_aws_region() -> None:
40-
"""AWS_REGION is used when INSPECT_EC2_SANDBOX_REGION is unset."""
41-
env_vars = env_vars_all()
42-
env_vars.pop("INSPECT_EC2_SANDBOX_REGION")
43-
env_vars["AWS_REGION"] = "eu-west-1"
44-
with mock.patch.dict(os.environ, env_vars, clear=True):
39+
def test_explicit_region_env_var_sets_region() -> None:
40+
"""INSPECT_EC2_SANDBOX_REGION is carried through as an explicit override."""
41+
with mock.patch.dict(os.environ, env_vars_all(), clear=True):
4542
config = Ec2SandboxEnvironmentConfig.from_settings()
46-
assert config.region == "eu-west-1"
43+
assert config.region == "eu-west-2"
44+
4745

46+
def test_region_left_none_for_session_to_resolve() -> None:
47+
"""Without INSPECT_EC2_SANDBOX_REGION, from_settings leaves region None.
4848
49-
def test_missing_region_raises_value_error() -> None:
50-
"""With no region from any source, from_settings raises a clear error."""
49+
Region resolution is deferred to the boto3 session at client-construction
50+
time, so from_settings must not reimplement the chain by reading AWS_REGION
51+
itself — that would shadow ~/.aws/config and the rest of botocore's chain.
52+
"""
5153
env_vars = env_vars_all()
5254
env_vars.pop("INSPECT_EC2_SANDBOX_REGION")
55+
env_vars["AWS_REGION"] = "eu-west-1"
5356
with mock.patch.dict(os.environ, env_vars, clear=True):
54-
with pytest.raises(ValueError, match="Region must be specified"):
55-
Ec2SandboxEnvironmentConfig.from_settings()
57+
config = Ec2SandboxEnvironmentConfig.from_settings()
58+
assert config.region is None
5659

5760

5861
def test_s3_key_prefix_leading_slash_raises() -> None:

tests/ec2sandboxtest/test_instance_provider.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,29 @@ async def test_create_instance_resolves_and_caches_ami_when_empty():
105105
assert ssm_client.get_parameters.call_count == 1
106106

107107

108+
@pytest.mark.asyncio
109+
async def test_create_instance_stamps_session_resolved_region():
110+
"""With no region in config, it's resolved off the client and stamped.
111+
112+
The ec2 client is built with region_name=None so the session resolves the
113+
region; ProvisionedInstance.region then carries the concrete value.
114+
"""
115+
provider, ec2_client, _ = _make_provider_with_mocks(_make_config(region=None))
116+
ec2_client.meta.region_name = "us-east-1"
117+
118+
result = await provider.create_instance(
119+
instance_type="t3a.micro",
120+
ami_id="ami-123",
121+
tags=[("Name", "x")],
122+
)
123+
124+
assert result.region == "us-east-1"
125+
ec2_call = next(
126+
c for c in provider._session.client.call_args_list if c.args[0] == "ec2"
127+
)
128+
assert ec2_call.kwargs["region_name"] is None
129+
130+
108131
@pytest.mark.asyncio
109132
async def test_create_instance_with_volume_size_sets_block_device_mappings():
110133
provider, ec2_client, _ = _make_provider_with_mocks(_make_config(volume_size=100))

0 commit comments

Comments
 (0)