Skip to content

Commit 20bd0b9

Browse files
mohamedzeidan2021Mohamed Zeidan
andauthored
fix: preserve all core container fields in ModelBuilder CreateModel requests (#6488)
ModelBuilder._core_container_to_dict() rebuilt each core ContainerDefinition by hand, copying only Image, ModelDataUrl, Environment and ImageConfig into container_def(). Every other field was discarded, so for a list of sagemaker.core.resources.Model objects: - a model-package reference (ModelPackageName, no Image) lost its only identifying field and was emitted as {"Image": Unassigned(), "Environment": {}}, which botocore rejects before the call leaves the client; - a container with ModelDataSource kept its image, so creation proceeded while the request silently lost the model data source; - ModelDataSource, AdditionalModelDataSources, Mode, ContainerHostname, InferenceSpecificationName, MultiModelConfig and ContainerMetricsConfig were dropped, and ImageConfig was passed through as a pydantic shape object rather than a dict. The core serializer (sagemaker.core.utils.utils.serialize) already emits the exact CreateModel request shape for every ContainerDefinition field, so the container is now routed through it instead of being rebuilt field by field. A container legitimately has no image when it references a model package, so the two Image lookups in _create_sagemaker_model() are now a single .get() instead of a subscript, which would otherwise raise KeyError: 'Image'. Fixes #6476 Co-authored-by: Mohamed Zeidan <zeidmo@amazon.com>
1 parent b56e329 commit 20bd0b9

2 files changed

Lines changed: 258 additions & 35 deletions

File tree

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

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3098,38 +3098,42 @@ def _prepare_pipeline_container_defs(self):
30983098
return containers
30993099

31003100
def _core_container_to_dict(self, container):
3101-
"""Convert core ContainerDefinition to dict using container_def helper."""
3102-
from sagemaker.core.utils.utils import Unassigned
3101+
"""Convert a core ContainerDefinition into a CreateModel container dict.
31033102
3104-
# Helper to check if value is Unassigned
3105-
def get_value(obj, attr, default=None):
3106-
if not hasattr(obj, attr):
3107-
return default
3108-
val = getattr(obj, attr)
3109-
return default if isinstance(val, Unassigned) else val
3103+
The core serializer already emits the exact CreateModel request shape for
3104+
every field a ``ContainerDefinition`` supports, so the container is routed
3105+
through it instead of copying a fixed subset of fields. Copying a subset
3106+
dropped fields such as ``ModelPackageName`` and ``ModelDataSource``.
31103107
3111-
return container_def(
3112-
container.image,
3113-
get_value(container, "model_data_url"),
3114-
get_value(container, "environment", {}),
3115-
image_config=get_value(container, "image_config"),
3116-
)
3108+
Args:
3109+
container (sagemaker.core.shapes.ContainerDefinition): The container to convert.
3110+
3111+
Returns:
3112+
dict: A container definition usable with the CreateModel API.
3113+
"""
3114+
from sagemaker.core.utils.utils import serialize
3115+
3116+
return serialize(container) or {}
31173117

31183118
def _create_sagemaker_model(self):
31193119
"""Create a SageMaker Model Entity."""
31203120
container_def = self._prepare_container_def()
31213121

3122+
# A container does not always carry an image: a model-package reference
3123+
# identifies its image through ModelPackageName instead, so Image is
3124+
# looked up rather than indexed.
3125+
resolved_image_uri = (
3126+
container_def.get("Image")
3127+
if isinstance(container_def, dict)
3128+
else container_def[0].get("Image")
3129+
)
3130+
31223131
if not isinstance(self.sagemaker_session, PipelineSession):
31233132
# _base_name, model_name are not needed under PipelineSession.
31243133
# the model_data may be Pipeline variable
31253134
# which may break the _base_name generation
3126-
image_uri = (
3127-
container_def["Image"]
3128-
if isinstance(container_def, dict)
3129-
else container_def[0]["Image"]
3130-
)
31313135
self._ensure_base_name_if_needed(
3132-
image_uri=image_uri,
3136+
image_uri=resolved_image_uri,
31333137
script_uri=self.source_dir,
31343138
model_uri=self._get_model_uri(),
31353139
)
@@ -3162,9 +3166,6 @@ def _create_sagemaker_model(self):
31623166
# A pipeline-variable image is only resolved at execution time, so it
31633167
# cannot be inspected here; set enable_network_isolation explicitly then.
31643168
enable_network_isolation = self._enable_network_isolation
3165-
resolved_image_uri = (
3166-
container_def["Image"] if isinstance(container_def, dict) else container_def[0]["Image"]
3167-
)
31683169
if (
31693170
not enable_network_isolation
31703171
and isinstance(resolved_image_uri, str)

‎sagemaker-serve/tests/unit/test_model_builder_deploy.py‎

Lines changed: 234 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@
1414
from sagemaker.core.inference_config import (
1515
ResourceRequirements,
1616
)
17+
from sagemaker.core.shapes import (
18+
ContainerDefinition,
19+
ImageConfig,
20+
ModelDataSource,
21+
S3ModelDataSource,
22+
)
23+
from sagemaker.core.workflow.pipeline_context import PipelineSession
1724

1825

1926
class TestModelBuilderContainerDef(unittest.TestCase):
@@ -104,27 +111,242 @@ def test_prepare_container_def_base_invalid_pipeline_models(self):
104111

105112
self.assertIn("must be sagemaker.core.resources.Model instances", str(context.exception))
106113

107-
@patch("sagemaker.serve.model_builder.container_def")
108-
def test_core_container_to_dict(self, mock_def):
114+
def test_core_container_to_dict(self):
109115
"""Test _core_container_to_dict converts container properly."""
110-
from sagemaker.core.utils.utils import Unassigned
111-
112-
mock_container = Mock()
113-
mock_container.image = "test-image"
114-
mock_container.model_data_url = "s3://bucket/model.tar.gz"
115-
mock_container.environment = {"KEY": "value"}
116-
mock_container.image_config = Unassigned()
116+
container = ContainerDefinition(
117+
image="test-image",
118+
model_data_url="s3://bucket/model.tar.gz",
119+
environment={"KEY": "value"},
120+
)
117121

118122
builder = ModelBuilder(
119123
model=Mock(),
120124
role_arn="arn:aws:iam::123456789012:role/TestRole",
121125
sagemaker_session=self.mock_session,
122126
)
123127

124-
mock_def.return_value = {"Image": "test-image"}
125-
builder._core_container_to_dict(mock_container)
128+
result = builder._core_container_to_dict(container)
129+
130+
self.assertEqual(
131+
result,
132+
{
133+
"Image": "test-image",
134+
"ModelDataUrl": "s3://bucket/model.tar.gz",
135+
"Environment": {"KEY": "value"},
136+
},
137+
)
138+
139+
140+
@patch("sagemaker.serve.model_builder.resolve_nested_dict_value_from_config")
141+
@patch("sagemaker.serve.model_builder.resolve_value_from_config")
142+
@patch.object(ModelBuilder, "_init_sagemaker_session_if_does_not_exist")
143+
class TestCoreContainerDefinitionFields(unittest.TestCase):
144+
"""Core container fields must survive the CreateModel conversion (issue #6476).
145+
146+
``_core_container_to_dict`` used to copy a fixed subset of fields, which
147+
dropped ``ModelPackageName`` and ``ModelDataSource`` from the generated
148+
``CreateModel`` request.
149+
"""
150+
151+
ROLE_ARN = "arn:aws:iam::123456789012:role/TestRole"
152+
IMAGE = "123456789012.dkr.ecr.us-west-2.amazonaws.com/sagemaker-scikit-learn:1.4-2"
153+
PACKAGE_ARN = "arn:aws:sagemaker:us-west-2:123456789012:model-package/example/1"
154+
155+
def _container_defs(self, container, mock_resolve, mock_resolve_nested):
156+
"""Run _create_sagemaker_model and return the container_defs sent to create_model."""
157+
mock_resolve.side_effect = lambda value, *args, **kwargs: value
158+
mock_resolve_nested.side_effect = lambda value, *args, **kwargs: value
159+
160+
session = Mock()
161+
session.boto_region_name = "us-west-2"
162+
session.default_bucket.return_value = "test-bucket"
163+
session.default_bucket_prefix = None
164+
session.config = {}
165+
session.sagemaker_config = {}
166+
167+
builder = ModelBuilder(
168+
model=[
169+
Model(
170+
model_name="source-model",
171+
primary_container=container,
172+
execution_role_arn=self.ROLE_ARN,
173+
)
174+
],
175+
role_arn=self.ROLE_ARN,
176+
sagemaker_session=session,
177+
instance_type="ml.m5.large",
178+
)
179+
builder.model_name = "test-model"
180+
builder.sagemaker_session = Mock(spec=PipelineSession)
181+
182+
builder._create_sagemaker_model()
183+
184+
builder.sagemaker_session.create_model.assert_called_once()
185+
return builder.sagemaker_session.create_model.call_args.kwargs["container_defs"]
186+
187+
def test_model_package_container_keeps_model_package_name(
188+
self, mock_init, mock_resolve, mock_resolve_nested
189+
):
190+
"""A model-package reference must build a request instead of failing on Image."""
191+
container_defs = self._container_defs(
192+
ContainerDefinition(model_package_name=self.PACKAGE_ARN),
193+
mock_resolve,
194+
mock_resolve_nested,
195+
)
196+
197+
self.assertEqual(container_defs, [{"ModelPackageName": self.PACKAGE_ARN}])
198+
199+
def test_model_data_source_container_keeps_model_data_source(
200+
self, mock_init, mock_resolve, mock_resolve_nested
201+
):
202+
"""A ModelDataSource must not be silently dropped from the request."""
203+
container_defs = self._container_defs(
204+
ContainerDefinition(
205+
image=self.IMAGE,
206+
model_data_source=ModelDataSource(
207+
s3_data_source=S3ModelDataSource(
208+
s3_data_type="S3Prefix",
209+
compression_type="None",
210+
s3_uri="s3://example-bucket/model/",
211+
)
212+
),
213+
environment={"EXAMPLE": "true"},
214+
),
215+
mock_resolve,
216+
mock_resolve_nested,
217+
)
218+
219+
self.assertEqual(
220+
container_defs,
221+
[
222+
{
223+
"Image": self.IMAGE,
224+
"ModelDataSource": {
225+
"S3DataSource": {
226+
"S3DataType": "S3Prefix",
227+
"CompressionType": "None",
228+
"S3Uri": "s3://example-bucket/model/",
229+
}
230+
},
231+
"Environment": {"EXAMPLE": "true"},
232+
}
233+
],
234+
)
235+
236+
def test_plain_image_container_is_unchanged(self, mock_init, mock_resolve, mock_resolve_nested):
237+
"""A plain image container must keep producing the same request fields."""
238+
container_defs = self._container_defs(
239+
ContainerDefinition(
240+
image=self.IMAGE,
241+
model_data_url="s3://example-bucket/model.tar.gz",
242+
environment={"A": "1"},
243+
),
244+
mock_resolve,
245+
mock_resolve_nested,
246+
)
247+
248+
self.assertEqual(
249+
container_defs,
250+
[
251+
{
252+
"Image": self.IMAGE,
253+
"ModelDataUrl": "s3://example-bucket/model.tar.gz",
254+
"Environment": {"A": "1"},
255+
}
256+
],
257+
)
258+
259+
def test_image_config_is_serialized_to_a_plain_dict(
260+
self, mock_init, mock_resolve, mock_resolve_nested
261+
):
262+
"""ImageConfig must reach the request as a dict, not as a shape object."""
263+
container_defs = self._container_defs(
264+
ContainerDefinition(
265+
image=self.IMAGE,
266+
image_config=ImageConfig(repository_access_mode="Vpc"),
267+
),
268+
mock_resolve,
269+
mock_resolve_nested,
270+
)
271+
272+
self.assertEqual(
273+
container_defs,
274+
[{"Image": self.IMAGE, "ImageConfig": {"RepositoryAccessMode": "Vpc"}}],
275+
)
276+
277+
@patch("sagemaker.serve.model_builder.Model.get")
278+
def test_model_package_container_under_interactive_session(
279+
self, mock_model_get, mock_init, mock_resolve, mock_resolve_nested
280+
):
281+
"""Outside a PipelineSession the base-name lookup must tolerate a missing Image."""
282+
mock_resolve.side_effect = lambda value, *args, **kwargs: value
283+
mock_resolve_nested.side_effect = lambda value, *args, **kwargs: value
284+
285+
session = Mock()
286+
session.boto_region_name = "us-west-2"
287+
session.default_bucket.return_value = "test-bucket"
288+
session.default_bucket_prefix = None
289+
session.config = {}
290+
session.sagemaker_config = {}
291+
292+
builder = ModelBuilder(
293+
model=[
294+
Model(
295+
model_name="source-model",
296+
primary_container=ContainerDefinition(model_package_name=self.PACKAGE_ARN),
297+
execution_role_arn=self.ROLE_ARN,
298+
)
299+
],
300+
role_arn=self.ROLE_ARN,
301+
sagemaker_session=session,
302+
instance_type="ml.m5.large",
303+
)
304+
builder.model_name = "test-model"
305+
306+
builder._create_sagemaker_model()
307+
308+
kwargs = session.create_model.call_args.kwargs
309+
self.assertEqual(kwargs["container_defs"], [{"ModelPackageName": self.PACKAGE_ARN}])
310+
311+
def test_model_package_container_passes_real_boto_param_validation(
312+
self, mock_init, mock_resolve, mock_resolve_nested
313+
):
314+
"""End-to-end proof against the real SageMaker service model (offline).
315+
316+
boto3's client-side ParamValidator needs no network and no credentials. The
317+
request the old converter produced carried ``Image: Unassigned()`` and was
318+
rejected by that validator; the request produced now is accepted.
319+
"""
320+
import boto3
321+
from botocore import validate
322+
323+
from sagemaker.core.utils.utils import Unassigned
324+
325+
shape = (
326+
boto3.client("sagemaker", region_name="us-west-2")
327+
.meta.service_model.operation_model("CreateModel")
328+
.input_shape
329+
)
330+
validator = validate.ParamValidator()
331+
base = {
332+
"ModelName": "test-model",
333+
"ExecutionRoleArn": self.ROLE_ARN,
334+
}
335+
336+
# What the old field copy produced for a model-package reference -- boto rejects it.
337+
old_report = validator.validate(
338+
{**base, "Containers": [{"Image": Unassigned(), "Environment": {}}]}, shape
339+
)
340+
self.assertTrue(old_report.has_errors())
341+
342+
container_defs = self._container_defs(
343+
ContainerDefinition(model_package_name=self.PACKAGE_ARN),
344+
mock_resolve,
345+
mock_resolve_nested,
346+
)
126347

127-
mock_def.assert_called_once()
348+
new_report = validator.validate({**base, "Containers": container_defs}, shape)
349+
self.assertFalse(new_report.has_errors(), new_report.generate_report())
128350

129351

130352
class TestModelBuilderDeploy(unittest.TestCase):

0 commit comments

Comments
 (0)