Skip to content

Commit ec68770

Browse files
mohamedzeidan2021Mohamed Zeidan
andauthored
fix: generate repack step for ModelBuilder.register/build in ModelStep (#5828, #5829) (#6338)
* fix: emit repack step for ModelBuilder.register/build in ModelStep (#5828, #5829) In v2, Model.register()/create() placed the Model instance (carrying sagemaker_session, role, model_data, entry_point, source_dir, ...) into the pipeline context, so ModelStep repacked it. In v3, ModelBuilder.register()/build() place the ModelBuilder into the context, but ModelStep._append_repack_model_step only accepted sagemaker.core.resources.Model, so a ModelBuilder fell through to 'No models to repack' and the model was never repacked with the user's source_code (#5828). The v3 core Model is a pydantic model with extra='forbid' and no sagemaker_session field, so it could never satisfy ModelStep's reads (#5829). Make the repack path ModelBuilder-aware: accept a ModelBuilder in the repack gate and map its attributes (model_name/role_arn/s3_model_data_url/source_code.requirements) to _RepackModelStep's parameters. Also stop passing v2-era args (dependencies/ output_path/output_kms_key) that are not part of the v3 _RepackModelStep signature and would leak into ModelTrainer; pass 'requirements' as the v3 step expects. * fix(mlops): send repacked artifact to the ModelBuilder output prefix with its KMS key _append_repack_model_step dropped output_path / output_kms_key when it stopped forwarding them to _RepackModelStep. Those were necessary to remove: neither is a named parameter of _RepackModelStep, so they fell into **kwargs and reached ModelTrainer, which is pydantic extra="forbid" -- the reason the v3 repack path was dead. But removing them left self._runtime_repack_output_prefix assigned and never read, and dropped model_kms_key entirely, so the repacked tarball silently landed in ModelTrainer's default bucket with no customer CMK. Accounts with a mandated bucket or an SSE-KMS bucket policy would either fail the repack job or write non-compliant artifacts -- the same population as #5956. Pass them the way ModelTrainer actually accepts: output_data_config is a declared ModelTrainer field, so an OutputDataConfig built from runtime_repack_output_prefix and the model's model_kms_key survives extra="forbid". setdefault so an explicit repack_model_step_settings override still wins. _repack_inputs_for now carries model_kms_key for both the ModelBuilder and the legacy Model shapes. Also documents why the core Model arm of the isinstance gate is legacy/duck-typed: no v3 @runnable_by_pipeline path produces a core Model, and that class has no sagemaker_session / role / entry_point (#5829). Adds two tests asserting the s3 path and KMS key reach _RepackModelStep, and that a user-supplied output_data_config is not overwritten. Without the wiring the first fails with KeyError: 'output_data_config'. --------- Co-authored-by: Mohamed Zeidan <zeidmo@amazon.com>
1 parent 4e5853c commit ec68770

2 files changed

Lines changed: 224 additions & 25 deletions

File tree

‎sagemaker-mlops/src/sagemaker/mlops/workflow/model_step.py‎

Lines changed: 74 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from typing import Union, List, Dict, Optional
1919

2020
from sagemaker.core.resources import Model
21+
from sagemaker.core.shapes import OutputDataConfig
2122
from sagemaker.mlops.workflow._utils import _RepackModelStep
2223
from sagemaker.core.workflow.pipeline_context import PipelineSession, _ModelStepArguments
2324
from sagemaker.mlops.workflow.retry import RetryPolicy, SageMakerJobStepRetryPolicy
@@ -211,9 +212,53 @@ def properties(self):
211212
"""A Properties object representing the appropriate SageMaker response data model."""
212213
return self._properties
213214

215+
@staticmethod
216+
def _repack_inputs_for(model):
217+
"""Return the repack inputs for ``model`` mapped to _RepackModelStep's params.
218+
219+
Handles both a v3 ``ModelBuilder`` (what ``ModelBuilder.register()``/``.build()``
220+
place into the pipeline context) and a legacy ``sagemaker.core.resources.Model``.
221+
A ``ModelBuilder`` exposes these under different names (``role_arn``,
222+
``s3_model_data_url``, ``model_name``) and carries its inference requirements on
223+
its ``source_code``; normalize them here. See GH #5828 / #5829.
224+
"""
225+
from sagemaker.serve.model_builder import ModelBuilder
226+
227+
if isinstance(model, ModelBuilder):
228+
source_code = getattr(model, "source_code", None)
229+
requirements = getattr(source_code, "requirements", None) if source_code else None
230+
return {
231+
"name": getattr(model, "model_name", None),
232+
"sagemaker_session": model.sagemaker_session,
233+
"role": getattr(model, "role_arn", None),
234+
"model_data": getattr(model, "s3_model_data_url", None),
235+
"entry_point": getattr(model, "entry_point", None),
236+
"source_dir": getattr(model, "source_dir", None),
237+
"requirements": requirements,
238+
"model_kms_key": getattr(model, "model_kms_key", None),
239+
}
240+
# Legacy sagemaker.core.resources.Model path.
241+
return {
242+
"name": getattr(model, "name", None),
243+
"sagemaker_session": getattr(model, "sagemaker_session", None),
244+
"role": getattr(model, "role", None),
245+
"model_data": getattr(model, "model_data", None),
246+
"entry_point": getattr(model, "entry_point", None),
247+
"source_dir": getattr(model, "source_dir", None),
248+
"requirements": getattr(model, "requirements", None),
249+
"model_kms_key": getattr(model, "model_kms_key", None),
250+
}
251+
214252
def _append_repack_model_step(self):
215253
"""Create and append a `_RepackModelStep` for the runtime repack"""
216-
if isinstance(self._model, Model):
254+
from sagemaker.serve.model_builder import ModelBuilder
255+
256+
# ModelBuilder is what ModelBuilder.register()/.build() put into the pipeline
257+
# context. The core ``Model`` arm is legacy/duck-typed: no v3 @runnable_by_pipeline
258+
# path produces one, and sagemaker.core.resources.Model has no sagemaker_session /
259+
# role / entry_point (GH #5829), so it is kept only for objects that happen to
260+
# expose those attributes rather than as a supported v3 entry point.
261+
if isinstance(self._model, (Model, ModelBuilder)):
217262
model_list = [self._model]
218263
else:
219264
logger.warning("No models to repack")
@@ -224,27 +269,39 @@ def _append_repack_model_step(self):
224269
security_group_ids, subnets = self._resolve_repack_model_step_vpc_configs()
225270

226271
for i, model in enumerate(model_list):
272+
# need_runtime_repack holds the id() of the original model/builder object,
273+
# so the membership test must run against ``model`` itself, not a wrapper.
227274
runtime_repack_flg = (
228275
self._need_runtime_repack and id(model) in self._need_runtime_repack
229276
)
230277
if runtime_repack_flg:
231-
name_base = model.name or i
278+
fields = self._repack_inputs_for(model)
279+
name_base = fields["name"] or i
280+
# Send the repacked artifact to the location ModelBuilder computed from the
281+
# user's bucket / code_location, encrypted with their model KMS key. These
282+
# reach ModelTrainer via _RepackModelStep's **kwargs. Without this the
283+
# repacked tarball silently lands in ModelTrainer's default bucket with no
284+
# CMK, which breaks accounts with a mandated bucket or SSE-KMS policy.
285+
# setdefault so an explicit repack_model_step_settings override wins.
286+
if self._runtime_repack_output_prefix or fields["model_kms_key"]:
287+
self._repack_model_step_settings.setdefault(
288+
"output_data_config",
289+
OutputDataConfig(
290+
s3_output_path=self._runtime_repack_output_prefix,
291+
kms_key_id=fields["model_kms_key"],
292+
),
293+
)
232294
repack_model_step = _RepackModelStep(
233295
name="{}-{}-{}".format(self.name, _REPACK_MODEL_NAME_BASE, name_base),
234296
sagemaker_session=(
235297
self._repack_model_step_settings.pop("sagemaker_session", None)
236-
or self._model.sagemaker_session
237-
or model.sagemaker_session
298+
or fields["sagemaker_session"]
238299
),
239-
role=(
240-
self._repack_model_step_settings.pop("role", None)
241-
or self._model.role
242-
or model.role
243-
),
244-
model_data=model.model_data,
245-
entry_point=model.entry_point,
246-
source_dir=model.source_dir,
247-
dependencies=model.dependencies,
300+
role=(self._repack_model_step_settings.pop("role", None) or fields["role"]),
301+
model_data=fields["model_data"],
302+
entry_point=fields["entry_point"],
303+
source_dir=fields["source_dir"],
304+
requirements=fields["requirements"],
248305
subnets=subnets,
249306
security_group_ids=security_group_ids,
250307
description=(
@@ -253,14 +310,6 @@ def _append_repack_model_step(self):
253310
),
254311
depends_on=self.depends_on,
255312
retry_policies=self._repack_model_retry_policies,
256-
output_path=(
257-
self._repack_model_step_settings.pop("output_path", None)
258-
or self._runtime_repack_output_prefix
259-
),
260-
output_kms_key=(
261-
self._repack_model_step_settings.pop("output_kms_key", None)
262-
or model.model_kms_key
263-
),
264313
**self._repack_model_step_settings,
265314
)
266315
self.steps.append(repack_model_step)
@@ -296,9 +345,10 @@ def _resolve_repack_model_step_vpc_configs(self):
296345
subnets = self._repack_model_step_settings.pop("subnets", None)
297346
return security_group_ids, subnets
298347

299-
if self._model.vpc_config:
300-
security_group_ids = self._model.vpc_config.get("SecurityGroupIds", None)
301-
subnets = self._model.vpc_config.get("Subnets", None)
348+
vpc_config = getattr(self._model, "vpc_config", None)
349+
if vpc_config:
350+
security_group_ids = vpc_config.get("SecurityGroupIds", None)
351+
subnets = vpc_config.get("Subnets", None)
302352
return security_group_ids, subnets
303353

304354
return None, None

‎sagemaker-mlops/tests/unit/workflow/test_model_step.py‎

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from __future__ import absolute_import
1616

17-
from unittest.mock import patch
17+
from unittest.mock import Mock, patch
1818

1919

2020
def test_model_step_properties():
@@ -27,3 +27,152 @@ def test_model_step_properties():
2727
step = ModelStep(name="model-step", step_args=step_args)
2828
assert step.name == "model-step"
2929
assert hasattr(step, "properties")
30+
31+
32+
def _pipeline_session():
33+
from sagemaker.core.workflow.pipeline_context import PipelineSession
34+
35+
ps = Mock(spec=PipelineSession)
36+
ps.context = Mock()
37+
ps.boto_region_name = "us-west-2"
38+
return ps
39+
40+
41+
class _FakeModelStepArgs:
42+
"""Mimics the _ModelStepArguments produced by ModelBuilder.register() under a
43+
PipelineSession (a register/create_model_package request that needs a repack)."""
44+
45+
def __init__(self, model, need_runtime_repack):
46+
self.model = model
47+
self.need_runtime_repack = need_runtime_repack
48+
self.runtime_repack_output_prefix = "s3://bucket/prefix"
49+
self.create_model_request = None
50+
self.create_model_package_request = {
51+
"InferenceSpecification": {"Containers": [{"ModelDataUrl": "s3://orig/model.tar.gz"}]}
52+
}
53+
54+
55+
def test_model_builder_register_appends_repack_step():
56+
"""GH #5828/#5829: ModelBuilder.register() in a ModelStep must emit a repack step
57+
(v2 parity) and rewire the container ModelDataUrl to the repacked artifact."""
58+
from sagemaker.serve.model_builder import ModelBuilder
59+
from sagemaker.mlops.workflow import model_step as ms
60+
61+
ps = _pipeline_session()
62+
builder = Mock(spec=ModelBuilder)
63+
builder.sagemaker_session = ps
64+
builder.model_name = "my-model"
65+
builder.role_arn = "arn:aws:iam::111122223333:role/R"
66+
builder.s3_model_data_url = "s3://orig/model.tar.gz"
67+
builder.entry_point = "inference.py"
68+
builder.source_dir = "/code"
69+
builder.source_code = Mock(requirements="requirements.txt")
70+
builder.vpc_config = None
71+
72+
step_args = _FakeModelStepArgs(builder, {id(builder)})
73+
fake_repack = Mock()
74+
fake_repack.properties.ModelArtifacts.S3ModelArtifacts = "s3://repacked/model.tar.gz"
75+
76+
with patch("sagemaker.core.workflow.utilities.validate_step_args_input"):
77+
with patch.object(ms, "_RepackModelStep", return_value=fake_repack) as mock_repack:
78+
step = ms.ModelStep(name="step", step_args=step_args)
79+
80+
# A repack step was generated (the bug: none was on master).
81+
assert len(step.steps) == 1
82+
# ModelBuilder attributes were mapped to the repack step's parameters.
83+
_, kwargs = mock_repack.call_args
84+
assert kwargs["role"] == "arn:aws:iam::111122223333:role/R"
85+
assert kwargs["model_data"] == "s3://orig/model.tar.gz"
86+
assert kwargs["entry_point"] == "inference.py"
87+
assert kwargs["source_dir"] == "/code"
88+
assert kwargs["requirements"] == "requirements.txt"
89+
assert kwargs["sagemaker_session"] is ps
90+
# The container now points at the repacked artifact.
91+
container = step_args.create_model_package_request["InferenceSpecification"]["Containers"][0]
92+
assert container["ModelDataUrl"] == "s3://repacked/model.tar.gz"
93+
94+
95+
def test_repack_step_gets_output_location_and_kms_key():
96+
"""The repacked artifact must go to ModelBuilder's runtime_repack_output_prefix and be
97+
encrypted with the model's KMS key. Both reach ModelTrainer via _RepackModelStep's
98+
**kwargs; dropping them sends the artifact to ModelTrainer's default bucket with no CMK,
99+
which breaks accounts with a mandated bucket or an SSE-KMS bucket policy."""
100+
from sagemaker.serve.model_builder import ModelBuilder
101+
from sagemaker.mlops.workflow import model_step as ms
102+
103+
ps = _pipeline_session()
104+
builder = Mock(spec=ModelBuilder)
105+
builder.sagemaker_session = ps
106+
builder.model_name = "my-model"
107+
builder.role_arn = "arn:aws:iam::111122223333:role/R"
108+
builder.s3_model_data_url = "s3://orig/model.tar.gz"
109+
builder.entry_point = "inference.py"
110+
builder.source_dir = "/code"
111+
builder.source_code = Mock(requirements="requirements.txt")
112+
builder.vpc_config = None
113+
builder.model_kms_key = "arn:aws:kms:us-west-2:111122223333:key/abc"
114+
115+
step_args = _FakeModelStepArgs(builder, {id(builder)})
116+
fake_repack = Mock()
117+
fake_repack.properties.ModelArtifacts.S3ModelArtifacts = "s3://repacked/model.tar.gz"
118+
119+
with patch("sagemaker.core.workflow.utilities.validate_step_args_input"):
120+
with patch.object(ms, "_RepackModelStep", return_value=fake_repack) as mock_repack:
121+
ms.ModelStep(name="step", step_args=step_args)
122+
123+
_, kwargs = mock_repack.call_args
124+
odc = kwargs["output_data_config"]
125+
assert odc.s3_output_path == "s3://bucket/prefix"
126+
assert odc.kms_key_id == "arn:aws:kms:us-west-2:111122223333:key/abc"
127+
128+
129+
def test_repack_step_output_config_respects_user_override():
130+
"""An explicit output_data_config in repack_model_step_settings must win."""
131+
from sagemaker.serve.model_builder import ModelBuilder
132+
from sagemaker.core.shapes import OutputDataConfig
133+
from sagemaker.mlops.workflow import model_step as ms
134+
135+
ps = _pipeline_session()
136+
builder = Mock(spec=ModelBuilder)
137+
builder.sagemaker_session = ps
138+
builder.model_name = "my-model"
139+
builder.role_arn = "arn:aws:iam::111122223333:role/R"
140+
builder.s3_model_data_url = "s3://orig/model.tar.gz"
141+
builder.entry_point = "inference.py"
142+
builder.source_dir = "/code"
143+
builder.source_code = Mock(requirements="requirements.txt")
144+
builder.vpc_config = None
145+
builder.model_kms_key = "arn:aws:kms:us-west-2:111122223333:key/abc"
146+
147+
step_args = _FakeModelStepArgs(builder, {id(builder)})
148+
fake_repack = Mock()
149+
fake_repack.properties.ModelArtifacts.S3ModelArtifacts = "s3://repacked/model.tar.gz"
150+
mine = OutputDataConfig(s3_output_path="s3://mine/out", kms_key_id="my-key")
151+
152+
with patch("sagemaker.core.workflow.utilities.validate_step_args_input"):
153+
with patch.object(ms, "_RepackModelStep", return_value=fake_repack) as mock_repack:
154+
ms.ModelStep(
155+
name="step",
156+
step_args=step_args,
157+
repack_model_step_settings={"output_data_config": mine},
158+
)
159+
160+
_, kwargs = mock_repack.call_args
161+
assert kwargs["output_data_config"] is mine
162+
163+
164+
def test_no_repack_step_for_unrecognized_model_type():
165+
"""An object that is neither a core Model nor a ModelBuilder yields no repack step."""
166+
from sagemaker.mlops.workflow import model_step as ms
167+
168+
ps = _pipeline_session()
169+
unknown = Mock()
170+
unknown.sagemaker_session = ps
171+
step_args = _FakeModelStepArgs(unknown, {id(unknown)})
172+
173+
with patch("sagemaker.core.workflow.utilities.validate_step_args_input"):
174+
with patch.object(ms, "_RepackModelStep") as mock_repack:
175+
step = ms.ModelStep(name="step", step_args=step_args)
176+
177+
assert step.steps == []
178+
mock_repack.assert_not_called()

0 commit comments

Comments
 (0)