|
14 | 14 | from sagemaker.core.inference_config import ( |
15 | 15 | ResourceRequirements, |
16 | 16 | ) |
| 17 | +from sagemaker.core.shapes import ( |
| 18 | + ContainerDefinition, |
| 19 | + ImageConfig, |
| 20 | + ModelDataSource, |
| 21 | + S3ModelDataSource, |
| 22 | +) |
| 23 | +from sagemaker.core.workflow.pipeline_context import PipelineSession |
17 | 24 |
|
18 | 25 |
|
19 | 26 | class TestModelBuilderContainerDef(unittest.TestCase): |
@@ -104,27 +111,242 @@ def test_prepare_container_def_base_invalid_pipeline_models(self): |
104 | 111 |
|
105 | 112 | self.assertIn("must be sagemaker.core.resources.Model instances", str(context.exception)) |
106 | 113 |
|
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): |
109 | 115 | """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 | + ) |
117 | 121 |
|
118 | 122 | builder = ModelBuilder( |
119 | 123 | model=Mock(), |
120 | 124 | role_arn="arn:aws:iam::123456789012:role/TestRole", |
121 | 125 | sagemaker_session=self.mock_session, |
122 | 126 | ) |
123 | 127 |
|
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 | + ) |
126 | 347 |
|
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()) |
128 | 350 |
|
129 | 351 |
|
130 | 352 | class TestModelBuilderDeploy(unittest.TestCase): |
|
0 commit comments