Skip to content

Commit 4f486d7

Browse files
mohamedzeidan2021Mohamed Zeidan
andauthored
fix: pass the configured session to ModelBuilder's Model reloads (#6487)
ModelBuilder.build() creates the SageMaker Model through the configured sagemaker_session, but reloaded it with a session-less Model.get(). That helper builds its own boto3 session from the ambient credential chain, so DescribeModel is signed with the default account's credentials: in a cross-account setup CreateModel succeeds in the target account and the reload then fails with "Could not find model". Single-account setups hide the defect because both sessions resolve to the same account. Pass sagemaker_session.boto_session to Model.get() at all three Model reload/lookup sites in model_builder.py (_create_sagemaker_model, _find_reusable_model and _get_model_for_endpoint - the latter two read the model name from the configured session's client and then described it session-lessly, the same defect on build()'s reuse path), matching the pattern the Endpoint.get() call sites already use after #6069. Model.get() also records the session on the resource it returns, so refresh, delete and wait calls on the Model that build() hands back stay in the configured account as well. Fixes #6459 Co-authored-by: Mohamed Zeidan <zeidmo@amazon.com>
1 parent 20bd0b9 commit 4f486d7

2 files changed

Lines changed: 216 additions & 3 deletions

File tree

‎sagemaker-serve/src/sagemaker/serve/model_builder.py‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2468,7 +2468,11 @@ def _get_model_for_endpoint(self, endpoint_name: str) -> Optional[Model]:
24682468
model_name = variants[0].get("ModelName")
24692469
if not model_name:
24702470
return None
2471-
return Model.get(model_name=model_name, region=self.region)
2471+
return Model.get(
2472+
model_name=model_name,
2473+
session=self.sagemaker_session.boto_session,
2474+
region=self.region,
2475+
)
24722476
except Exception as e:
24732477
logger.warning(
24742478
"Could not resolve the Model backing endpoint %s: %s.",
@@ -2498,7 +2502,11 @@ def _find_reusable_model(self) -> Optional["Model"]:
24982502
if model_arn:
24992503
# Extract model name from ARN: arn:aws:sagemaker:region:account:model/name
25002504
model_name = model_arn.rsplit("/", 1)[-1]
2501-
return Model.get(model_name=model_name, region=self.region)
2505+
return Model.get(
2506+
model_name=model_name,
2507+
session=self.sagemaker_session.boto_session,
2508+
region=self.region,
2509+
)
25022510
except Exception as e:
25032511
logger.warning("Could not search Models for reuse: %s", e)
25042512

@@ -3185,7 +3193,14 @@ def _create_sagemaker_model(self):
31853193
self.sagemaker_session.create_model(**create_model_args)
31863194
if isinstance(self.sagemaker_session, PipelineSession):
31873195
return
3188-
return Model.get(model_name=self.model_name, region=self.region)
3196+
# CreateModel above went through the configured session, so the reload
3197+
# must use it too: a session-less get() describes the model in the
3198+
# default boto session's account and fails cross-account.
3199+
return Model.get(
3200+
model_name=self.model_name,
3201+
session=self.sagemaker_session.boto_session,
3202+
region=self.region,
3203+
)
31893204

31903205
def _create_model(self):
31913206
"""Create a SageMaker Model instance from the current configuration."""
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"""Unit tests for session propagation on ModelBuilder's model-reload paths (issue #6459).
2+
3+
``build()`` creates the SageMaker Model with the configured ``sagemaker_session``
4+
and then reloads it with ``Model.get()``. A session-less ``Model.get()`` builds its
5+
own boto3 session, so the DescribeModel call is signed with the default
6+
credentials: cross-account builds create the model in the target account and then
7+
fail to describe it. The same applies to the Model lookups used for resource reuse.
8+
"""
9+
10+
import unittest
11+
from unittest.mock import MagicMock, Mock, patch
12+
13+
import boto3
14+
15+
from sagemaker.core.resources import Base, Model
16+
from sagemaker.core.workflow.pipeline_context import PipelineSession
17+
from sagemaker.serve.model_builder import ModelBuilder
18+
from sagemaker.serve.utils.types import ModelServer
19+
20+
ROLE_ARN = "arn:aws:iam::111111111111:role/TargetAccountSageMakerRole"
21+
REGION = "us-east-2"
22+
IMAGE_URI = "111111111111.dkr.ecr.us-east-2.amazonaws.com/inference:latest"
23+
MODEL_NAME = "modelbuilder-session-propagation"
24+
MODEL_ARN = f"arn:aws:sagemaker:{REGION}:111111111111:model/{MODEL_NAME}"
25+
26+
# Two real, distinguishable boto3 sessions. CONFIGURED_BOTO_SESSION is what the
27+
# caller hands to ModelBuilder (the target account); DEFAULT_BOTO_SESSION stands
28+
# in for the process default session (a different account).
29+
CONFIGURED_BOTO_SESSION = boto3.Session(
30+
region_name=REGION,
31+
aws_access_key_id="configured-key",
32+
aws_secret_access_key="configured-secret",
33+
)
34+
DEFAULT_BOTO_SESSION = boto3.Session(
35+
region_name=REGION,
36+
aws_access_key_id="default-key",
37+
aws_secret_access_key="default-secret",
38+
)
39+
40+
41+
def _configured_session():
42+
"""A sagemaker_session wired to CONFIGURED_BOTO_SESSION (the target account)."""
43+
session = Mock()
44+
session.boto_region_name = REGION
45+
session.boto_session = CONFIGURED_BOTO_SESSION
46+
session.default_bucket.return_value = "test-bucket"
47+
session.default_bucket_prefix = None
48+
session.config = {}
49+
session.sagemaker_config = {}
50+
session.settings = Mock()
51+
session.settings.include_jumpstart_tags = False
52+
session.settings._local_download_dir = None
53+
session.sagemaker_client = MagicMock()
54+
return session
55+
56+
57+
def _make_builder(session):
58+
builder = ModelBuilder(
59+
model=Mock(),
60+
model_server=ModelServer.TORCHSERVE,
61+
role_arn=ROLE_ARN,
62+
sagemaker_session=session,
63+
instance_type="ml.m5.large",
64+
)
65+
builder.model_name = MODEL_NAME
66+
builder.region = REGION
67+
return builder
68+
69+
70+
@patch("sagemaker.serve.model_builder.resolve_nested_dict_value_from_config")
71+
@patch("sagemaker.serve.model_builder.resolve_value_from_config")
72+
@patch.object(ModelBuilder, "_init_sagemaker_session_if_does_not_exist")
73+
@patch.object(ModelBuilder, "_ensure_base_name_if_needed")
74+
@patch.object(ModelBuilder, "_prepare_container_def")
75+
class TestCreateSageMakerModelReloadSession(unittest.TestCase):
76+
"""The reload after CreateModel must use the configured session."""
77+
78+
def _run(self, session, mocks):
79+
mock_prepare, mock_resolve, mock_resolve_nested = mocks
80+
mock_prepare.return_value = {"Image": IMAGE_URI, "Environment": {}}
81+
mock_resolve.side_effect = lambda value, *args, **kwargs: value
82+
mock_resolve_nested.side_effect = lambda value, *args, **kwargs: value
83+
84+
builder = _make_builder(_configured_session())
85+
if session is not None:
86+
builder.sagemaker_session = session
87+
return builder, builder._create_sagemaker_model()
88+
89+
def test_reload_passes_configured_session_to_model_get(
90+
self, mock_prepare, mock_base_name, mock_init, mock_resolve, mock_resolve_nested
91+
):
92+
reloaded = Mock(model_name=MODEL_NAME, model_arn=MODEL_ARN)
93+
94+
with patch(
95+
"sagemaker.serve.model_builder.Model.get", return_value=reloaded
96+
) as mock_model_get:
97+
builder, result = self._run(None, (mock_prepare, mock_resolve, mock_resolve_nested))
98+
99+
builder.sagemaker_session.create_model.assert_called_once()
100+
mock_model_get.assert_called_once_with(
101+
model_name=MODEL_NAME,
102+
session=CONFIGURED_BOTO_SESSION,
103+
region=REGION,
104+
)
105+
self.assertIs(result, reloaded)
106+
107+
def test_reload_describe_client_is_built_from_configured_session(
108+
self, mock_prepare, mock_base_name, mock_init, mock_resolve, mock_resolve_nested
109+
):
110+
"""DescribeModel must be issued by a client built from the configured session.
111+
112+
Model.get() resolves its client through Base.get_sagemaker_client; with no
113+
session that helper creates one from the ambient credential chain, which is
114+
the default account.
115+
"""
116+
captured = {}
117+
118+
def fake_get_client(session=None, region_name=None, service_name="sagemaker"):
119+
captured["session"] = session
120+
captured["region_name"] = region_name
121+
client = MagicMock()
122+
client.describe_model.return_value = {"ModelName": MODEL_NAME}
123+
return client
124+
125+
with patch.object(Base, "get_sagemaker_client", side_effect=fake_get_client):
126+
_builder, result = self._run(None, (mock_prepare, mock_resolve, mock_resolve_nested))
127+
128+
self.assertIs(captured["session"], CONFIGURED_BOTO_SESSION)
129+
self.assertIsNot(captured["session"], DEFAULT_BOTO_SESSION)
130+
self.assertEqual(captured["region_name"], REGION)
131+
self.assertIsInstance(result, Model)
132+
self.assertEqual(result.model_name, MODEL_NAME)
133+
134+
def test_pipeline_session_build_still_returns_without_reloading(
135+
self, mock_prepare, mock_base_name, mock_init, mock_resolve, mock_resolve_nested
136+
):
137+
"""A PipelineSession build returns before the reload and must stay that way."""
138+
with patch("sagemaker.serve.model_builder.Model.get") as mock_model_get:
139+
_builder, result = self._run(
140+
Mock(spec=PipelineSession), (mock_prepare, mock_resolve, mock_resolve_nested)
141+
)
142+
143+
self.assertIsNone(result)
144+
mock_model_get.assert_not_called()
145+
146+
147+
class TestModelLookupSession(unittest.TestCase):
148+
"""The Model lookups used for resource reuse must use the configured session."""
149+
150+
def setUp(self):
151+
self.session = _configured_session()
152+
self.builder = _make_builder(self.session)
153+
self.found_model = Mock(model_name=MODEL_NAME, model_arn=MODEL_ARN)
154+
155+
def test_find_reusable_model_passes_configured_session(self):
156+
with (
157+
patch.object(
158+
ModelBuilder, "_resolve_model_source_id", return_value="s3://bucket/model"
159+
),
160+
patch(
161+
"sagemaker.serve.model_reuse.find_sagemaker_model_arn_by_tag",
162+
return_value=MODEL_ARN,
163+
),
164+
patch(
165+
"sagemaker.serve.model_builder.Model.get", return_value=self.found_model
166+
) as mock_model_get,
167+
):
168+
result = self.builder._find_reusable_model()
169+
170+
mock_model_get.assert_called_once_with(
171+
model_name=MODEL_NAME,
172+
session=CONFIGURED_BOTO_SESSION,
173+
region=REGION,
174+
)
175+
self.assertIs(result, self.found_model)
176+
177+
def test_get_model_for_endpoint_passes_configured_session(self):
178+
client = self.session.sagemaker_client
179+
client.describe_endpoint.return_value = {"EndpointConfigName": "test-config"}
180+
client.describe_endpoint_config.return_value = {
181+
"ProductionVariants": [{"ModelName": MODEL_NAME}]
182+
}
183+
184+
with patch(
185+
"sagemaker.serve.model_builder.Model.get", return_value=self.found_model
186+
) as mock_model_get:
187+
result = self.builder._get_model_for_endpoint("test-endpoint")
188+
189+
mock_model_get.assert_called_once_with(
190+
model_name=MODEL_NAME,
191+
session=CONFIGURED_BOTO_SESSION,
192+
region=REGION,
193+
)
194+
self.assertIs(result, self.found_model)
195+
196+
197+
if __name__ == "__main__":
198+
unittest.main()

0 commit comments

Comments
 (0)