From 090ffb5dc11e0cb50ffddf8c6b977f3f074195e3 Mon Sep 17 00:00:00 2001 From: Eli Davidson Date: Thu, 16 Jul 2026 17:24:20 +0000 Subject: [PATCH 1/6] fix(serve): private hub deploys attach HubAccessConfig and support aliased content names Two remaining defects after PR #5985: 1. HubAccessConfig missing from CreateModel. _build_for_jumpstart resolved model_reference_arn via get_init_kwargs but never copied it onto the builder, so _prepare_container_def_base passed None to container_def() and the S3DataSource had no HubAccessConfig. The execution role was forced to need s3:GetObject on the public jumpstart-cache-prod bucket. Fix: propagate init_kwargs.model_reference_arn to self.model_reference_arn. 2. Hub content references named differently from the public model_id could not be resolved (ResourceNotFound). Fix: add hub_content_name to JumpStartConfig and use it as the hub lookup identifier in the build path when set. Verified E2E against a live private hub with an execution role that has zero S3 permissions: both matching-name and aliased-name deploys now succeed, with SageMaker brokering artifact access through the hub content reference. Pattern follows master-v2, where Model.prepare_container_def passes model_reference_arn into sagemaker.container_def(). --- X-AI-Prompt: create a new workspace, pull the pysdk, reproduce in our workspace and fix both bugs, the name and that it's not configured correctly. Look at master-v2 to fix this. X-AI-Tool: meshclaw --- .../src/sagemaker/core/jumpstart/configs.py | 4 ++++ sagemaker-serve/src/sagemaker/serve/model_builder.py | 1 + .../src/sagemaker/serve/model_builder_servers.py | 12 ++++++++++++ 3 files changed, 17 insertions(+) diff --git a/sagemaker-core/src/sagemaker/core/jumpstart/configs.py b/sagemaker-core/src/sagemaker/core/jumpstart/configs.py index d5852f0bed..9e0a53ddc6 100644 --- a/sagemaker-core/src/sagemaker/core/jumpstart/configs.py +++ b/sagemaker-core/src/sagemaker/core/jumpstart/configs.py @@ -35,6 +35,9 @@ class JumpStartConfig(BaseConfig): model_version (Optional[str]): The version of the JumpStart model. Defaults to None. hub_name (Optional[str]): The name of the JumpStart hub. Defaults to None. + hub_content_name (Optional[str]): The name of the hub content reference in the + private hub, when it differs from the public model_id. Defaults to None, + which means the hub content is expected to be named after the model_id. accept_eula (Optional[bool]): Whether to accept the EULA. Defaults to None. training_config_name (Optional[str]): The name of the training configuration. Defaults to None. @@ -45,6 +48,7 @@ class JumpStartConfig(BaseConfig): model_id: str model_version: Optional[str] = None hub_name: Optional[str] = None + hub_content_name: Optional[str] = None accept_eula: Optional[bool] = False training_config_name: Optional[str] = None inference_config_name: Optional[str] = None diff --git a/sagemaker-serve/src/sagemaker/serve/model_builder.py b/sagemaker-serve/src/sagemaker/serve/model_builder.py index 275b09707e..59c1fd2aa9 100644 --- a/sagemaker-serve/src/sagemaker/serve/model_builder.py +++ b/sagemaker-serve/src/sagemaker/serve/model_builder.py @@ -3910,6 +3910,7 @@ def from_jumpstart_config( mb_instance.resource_requirements = resource_requirements mb_instance.model_kms_key = model_kms_key mb_instance.hub_name = jumpstart_config.hub_name + mb_instance.hub_content_name = getattr(jumpstart_config, "hub_content_name", None) if mb_instance.hub_name and not getattr(mb_instance, "hub_arn", None): from sagemaker.core.jumpstart.hub.utils import ( generate_hub_arn_for_init_kwargs, diff --git a/sagemaker-serve/src/sagemaker/serve/model_builder_servers.py b/sagemaker-serve/src/sagemaker/serve/model_builder_servers.py index 3ca6b40f6d..90839ff8e8 100644 --- a/sagemaker-serve/src/sagemaker/serve/model_builder_servers.py +++ b/sagemaker-serve/src/sagemaker/serve/model_builder_servers.py @@ -988,8 +988,20 @@ def _build_for_jumpstart(self) -> Model: hub_arn = getattr(self, "hub_arn", None) if hub_arn: init_kwargs_params["hub_arn"] = hub_arn + # When the private hub content reference is named differently from + # the public model_id, resolve hub content by its actual name. + hub_content_name = getattr(self, "hub_content_name", None) + if hub_content_name: + init_kwargs_params["model_id"] = hub_content_name init_kwargs = get_init_kwargs(**init_kwargs_params) + # Propagate the hub model reference ARN so the container definition + # includes S3DataSource.HubAccessConfig.HubContentArn. Without this, + # CreateModel requires the execution role to have s3:GetObject on the + # public JumpStart cache bucket, defeating private hub brokered access. + if getattr(init_kwargs, "model_reference_arn", None): + self.model_reference_arn = init_kwargs.model_reference_arn + # Configure image URI and environment variables self.image_uri = self.image_uri or init_kwargs.image_uri From 232157c821fd8edc5ef29122d1c1ff191eb7c144 Mon Sep 17 00:00:00 2001 From: Eli Davidson Date: Thu, 16 Jul 2026 22:34:39 +0000 Subject: [PATCH 2/6] test(serve): regression tests for HubAccessConfig propagation and aliased hub content names Add mock-based unit tests covering the two private hub defects fixed in the previous commit. Verified each discriminating test fails on the v3.16.0 baseline and passes with the fix: - test_model_reference_arn_propagated_from_init_kwargs: the ARN resolved by get_init_kwargs must land on the builder so container_def attaches S3DataSource.HubAccessConfig.HubContentArn (fails on baseline with model_reference_arn=None) - test_hub_content_name_used_as_lookup_model_id: aliased references resolve by hub_content_name (fails on baseline) - test_from_jumpstart_config_threads_hub_content_name: config threading (fails on baseline, JumpStartConfig rejects the field) Plus negative tests (public catalog path unchanged, no HubAccessConfig without a model_reference_arn) and pure-function coverage of container_def HubAccessConfig injection. No AWS calls. --- X-AI-Prompt: Write the mock unit tests, verify they fail without fix + pass with fix, push to PR X-AI-Tool: meshclaw --- .../test_private_hub_artifact_resolution.py | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/sagemaker-serve/tests/unit/test_private_hub_artifact_resolution.py b/sagemaker-serve/tests/unit/test_private_hub_artifact_resolution.py index 4fdee7e6ef..f96c46db7f 100644 --- a/sagemaker-serve/tests/unit/test_private_hub_artifact_resolution.py +++ b/sagemaker-serve/tests/unit/test_private_hub_artifact_resolution.py @@ -367,5 +367,206 @@ def test_from_jumpstart_config_then_build_uses_private_hub( self.assertNotIn("jumpstart-cache-prod", mb.s3_model_data_url) +MOCK_MODEL_REFERENCE_ARN = ( + "arn:aws:sagemaker:us-east-1:123456789012:hub-content/" + "my-private-hub/ModelReference/huggingface-llm-phi-4-mini-instruct/1.1.0" +) +MOCK_HUB_CONTENT_NAME = "my-team-phi4-mini" +MOCK_IMAGE_URI = ( + "763104351884.dkr.ecr.us-east-1.amazonaws.com/djl-inference:0.27.0-lmi10.0.0-cu124" +) + + +def _init_kwargs_mock(model_reference_arn): + """Build a get_init_kwargs return value with an explicit model_reference_arn. + + Uses an explicit value (string or None) rather than relying on Mock + auto-attributes, which are always truthy. + """ + mock_init_kwargs = Mock() + mock_init_kwargs.image_uri = MOCK_IMAGE_URI + mock_init_kwargs.env = {} + mock_init_kwargs.model_data = { + "S3DataSource": { + "S3Uri": "s3://jumpstart-cache-prod-us-east-1/artifacts/model/", + "S3DataType": "S3Prefix", + "CompressionType": "None", + } + } + mock_init_kwargs.enable_network_isolation = None + mock_init_kwargs.model_reference_arn = model_reference_arn + return mock_init_kwargs + + +class TestModelReferenceArnPropagation(unittest.TestCase): + """Regression tests: model_reference_arn resolved by get_init_kwargs must be + propagated onto the builder so the container definition attaches + S3DataSource.HubAccessConfig.HubContentArn in the CreateModel call. + + Without this propagation, the execution role is forced to have + s3:GetObject on the public jumpstart-cache-prod bucket, defeating + private hub brokered access. (Missed by PR #5985.) + """ + + def setUp(self): + self.mock_session = _mock_session() + + def _build(self, model_reference_arn, hub_arn=MOCK_HUB_ARN, hub_content_name=None): + with _PATCH_IS_JS, patch( + "sagemaker.core.jumpstart.utils.validate_model_id_and_get_type", + return_value=None, + ), patch( + "sagemaker.core.jumpstart.factory.utils.get_init_kwargs" + ) as mock_get_kwargs, patch( + "sagemaker.serve.model_builder.ModelBuilder._create_model" + ) as mock_create, patch( + "sagemaker.serve.model_builder.ModelBuilder._prepare_for_mode" + ): + mock_get_kwargs.return_value = _init_kwargs_mock(model_reference_arn) + mock_create.return_value = Mock() + + builder = ModelBuilder( + model=MOCK_MODEL_ID, + role_arn=MOCK_ROLE_ARN, + sagemaker_session=self.mock_session, + mode=Mode.SAGEMAKER_ENDPOINT, + ) + builder._optimizing = False + builder.model_version = MOCK_MODEL_VERSION + if hub_arn: + builder.hub_name = MOCK_HUB_NAME + builder.hub_arn = hub_arn + if hub_content_name: + builder.hub_content_name = hub_content_name + + builder._build_for_jumpstart() + return builder, mock_get_kwargs + + def test_model_reference_arn_propagated_from_init_kwargs(self): + """The ARN resolved by the factory must land on the builder instance. + + This is the test that would have caught the v3.15.1-v3.16.0 regression: + hub_arn was forwarded to get_init_kwargs (PR #5985), but the resulting + model_reference_arn was dropped, so CreateModel never got HubAccessConfig. + """ + builder, _ = self._build(model_reference_arn=MOCK_MODEL_REFERENCE_ARN) + + self.assertEqual( + getattr(builder, "model_reference_arn", None), + MOCK_MODEL_REFERENCE_ARN, + "model_reference_arn from get_init_kwargs was not propagated to the " + "builder; the container definition will be missing " + "S3DataSource.HubAccessConfig.HubContentArn", + ) + + def test_model_reference_arn_absent_for_public_catalog(self): + """No private hub: model_reference_arn must remain unset (public path unchanged).""" + builder, _ = self._build(model_reference_arn=None, hub_arn=None) + + self.assertIsNone(getattr(builder, "model_reference_arn", None)) + + +class TestContainerDefAttachesHubAccessConfig(unittest.TestCase): + """The downstream attachment point: container_def() must inject + HubAccessConfig.HubContentArn into the S3DataSource when a + model_reference_arn is provided. Pure function, no AWS calls. + """ + + def test_hub_access_config_attached(self): + from sagemaker.core.helper.session_helper import container_def + + c_def = container_def( + MOCK_IMAGE_URI, + model_data_url={ + "S3DataSource": { + "S3Uri": "s3://jumpstart-cache-prod-us-east-1/artifacts/model/", + "S3DataType": "S3Prefix", + "CompressionType": "None", + } + }, + model_reference_arn=MOCK_MODEL_REFERENCE_ARN, + ) + + self.assertEqual( + c_def["ModelDataSource"]["S3DataSource"]["HubAccessConfig"], + {"HubContentArn": MOCK_MODEL_REFERENCE_ARN}, + ) + + def test_no_hub_access_config_without_model_reference_arn(self): + from sagemaker.core.helper.session_helper import container_def + + c_def = container_def( + MOCK_IMAGE_URI, + model_data_url={ + "S3DataSource": { + "S3Uri": "s3://jumpstart-cache-prod-us-east-1/artifacts/model/", + "S3DataType": "S3Prefix", + "CompressionType": "None", + } + }, + ) + + self.assertNotIn("HubAccessConfig", c_def["ModelDataSource"]["S3DataSource"]) + + +class TestHubContentNameSupport(unittest.TestCase): + """Aliased hub content references: when the private hub content reference + is named differently from the public model_id, the SDK must resolve hub + content by its actual name (hub_content_name), not the model_id. + """ + + def setUp(self): + self.mock_session = _mock_session() + + def test_hub_content_name_used_as_lookup_model_id(self): + """When hub_content_name is set, get_init_kwargs must receive it as model_id.""" + helper = TestModelReferenceArnPropagation() + helper.mock_session = self.mock_session + _, mock_get_kwargs = helper._build( + model_reference_arn=MOCK_MODEL_REFERENCE_ARN, + hub_content_name=MOCK_HUB_CONTENT_NAME, + ) + + call_kwargs = mock_get_kwargs.call_args.kwargs + self.assertEqual(call_kwargs.get("model_id"), MOCK_HUB_CONTENT_NAME) + + def test_model_id_used_when_no_hub_content_name(self): + """Without hub_content_name, the model_id is used for the hub lookup.""" + helper = TestModelReferenceArnPropagation() + helper.mock_session = self.mock_session + _, mock_get_kwargs = helper._build(model_reference_arn=MOCK_MODEL_REFERENCE_ARN) + + call_kwargs = mock_get_kwargs.call_args.kwargs + self.assertEqual(call_kwargs.get("model_id"), MOCK_MODEL_ID) + + @_PATCH_IS_JS + @patch("sagemaker.serve.model_builder._retrieve_model_deploy_kwargs", return_value={}) + @patch("sagemaker.core.jumpstart.utils.validate_model_id_and_get_type", return_value=None) + @patch( + "sagemaker.core.jumpstart.hub.utils.generate_hub_arn_for_init_kwargs", + return_value=MOCK_HUB_ARN, + ) + def test_from_jumpstart_config_threads_hub_content_name( + self, mock_generate_arn, mock_validate, mock_deploy_kwargs, mock_is_js + ): + """JumpStartConfig.hub_content_name must be threaded onto the builder.""" + js_config = JumpStartConfig( + model_id=MOCK_MODEL_ID, + model_version=MOCK_MODEL_VERSION, + hub_name=MOCK_HUB_NAME, + hub_content_name=MOCK_HUB_CONTENT_NAME, + ) + + mb = ModelBuilder.from_jumpstart_config( + jumpstart_config=js_config, + role_arn=MOCK_ROLE_ARN, + sagemaker_session=_mock_session(), + ) + + self.assertEqual( + getattr(mb, "hub_content_name", None), MOCK_HUB_CONTENT_NAME + ) + + if __name__ == "__main__": unittest.main() From 8147bf19c1f98e75efcbde08f0483bc946305fa1 Mon Sep 17 00:00:00 2001 From: Eli Davidson Date: Thu, 16 Jul 2026 22:41:12 +0000 Subject: [PATCH 3/6] test(serve): assert CreateModel container definition carries HubAccessConfig Close the gap between the propagation test and the pure container_def test: drive the real unmocked path from _build_for_jumpstart through _prepare_container_def_base and assert the exact container definition dict sent to the CreateModel API contains S3DataSource.HubAccessConfig.HubContentArn for private hub builds, and omits it for public catalog builds. This is the payload-shape assertion that encodes the customer-visible contract: with HubAccessConfig present, SageMaker brokers model data access through the hub content reference and the execution role needs no s3:GetObject on the public JumpStart cache bucket. Verified: fails on the v3.16.0 baseline (HubAccessConfig absent), passes with the fix. No AWS calls. --- X-AI-Prompt: Did we make sure there are no calls to s3 when using a model reference? what about that we're passing in the intended call to sagemaker create model? X-AI-Tool: meshclaw --- .../test_private_hub_artifact_resolution.py | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/sagemaker-serve/tests/unit/test_private_hub_artifact_resolution.py b/sagemaker-serve/tests/unit/test_private_hub_artifact_resolution.py index f96c46db7f..6b882c74ed 100644 --- a/sagemaker-serve/tests/unit/test_private_hub_artifact_resolution.py +++ b/sagemaker-serve/tests/unit/test_private_hub_artifact_resolution.py @@ -568,5 +568,69 @@ def test_from_jumpstart_config_threads_hub_content_name( ) +class TestCreateModelContainerDefinition(unittest.TestCase): + """Full-chain test: after a private hub build, the container definition + that the SDK sends to the CreateModel API must carry + S3DataSource.HubAccessConfig.HubContentArn. + + This exercises the real path end to end at the unit level: + _build_for_jumpstart -> self.model_reference_arn -> + _prepare_container_def_base -> container_def -> CreateModel payload. + No individual link is mocked between the builder attribute and the + final dict. This is the payload-shape assertion that, had it existed, + would have caught the regression that shipped in v3.15.1-v3.16.0. + """ + + def setUp(self): + self.mock_session = _mock_session() + # _prepare_container_def_base consults session config lookups that + # iterate the config object; a bare Mock is not iterable. + self.mock_session.config = None + + def _container_def_after_build(self, model_reference_arn, hub_arn=MOCK_HUB_ARN): + helper = TestModelReferenceArnPropagation() + helper.mock_session = self.mock_session + builder, _ = helper._build( + model_reference_arn=model_reference_arn, hub_arn=hub_arn + ) + # A JumpStart hub deploy has no custom inference code to upload; + # ensure the code-upload branch (which would call S3) is not taken. + builder.source_dir = None + builder.dependencies = None + builder.entry_point = None + builder.git_config = None + # _prepare_container_def_base builds the exact container definition + # dict passed to the CreateModel API as PrimaryContainer/Containers. + return builder._prepare_container_def_base() + + def test_create_model_container_def_includes_hub_access_config(self): + """Private hub build: CreateModel payload must include HubAccessConfig.""" + c_def = self._container_def_after_build( + model_reference_arn=MOCK_MODEL_REFERENCE_ARN + ) + + self.assertIn("ModelDataSource", c_def) + s3_data_source = c_def["ModelDataSource"]["S3DataSource"] + self.assertEqual( + s3_data_source.get("HubAccessConfig"), + {"HubContentArn": MOCK_MODEL_REFERENCE_ARN}, + "The container definition sent to CreateModel is missing " + "HubAccessConfig; the execution role would need s3:GetObject on " + "the public JumpStart cache bucket, defeating private hub " + "brokered access.", + ) + + def test_create_model_container_def_no_hub_access_config_for_public(self): + """Public catalog build: CreateModel payload must NOT include HubAccessConfig.""" + c_def = self._container_def_after_build( + model_reference_arn=None, hub_arn=None + ) + + self.assertIn("ModelDataSource", c_def) + self.assertNotIn( + "HubAccessConfig", c_def["ModelDataSource"]["S3DataSource"] + ) + + if __name__ == "__main__": unittest.main() From d179364c12090ef2a86230eb1e7ec8d04c455439 Mon Sep 17 00:00:00 2001 From: Eli Davidson Date: Thu, 16 Jul 2026 22:46:47 +0000 Subject: [PATCH 4/6] refactor(test): hoist shared builder setup to module-level helper Replace cross-TestCase borrowing (instantiating TestModelReferenceArnPropagation inside other test classes to reuse its _build method) with a module-level _build_jumpstart_builder() helper, matching the package convention for shared builder setup (see _create_mock_builder in test_model_builder_servers_hf_model_id.py and _builder in test_bedrock_model_builder.py). No behavior change. Re-verified both directions: 4 discriminating tests fail on the v3.16.0 baseline, all 17 pass with the fix. --- X-AI-Prompt: Refactor to module-level helper, re-verify both directions, push X-AI-Tool: meshclaw --- .../test_private_hub_artifact_resolution.py | 96 ++++++++++--------- 1 file changed, 53 insertions(+), 43 deletions(-) diff --git a/sagemaker-serve/tests/unit/test_private_hub_artifact_resolution.py b/sagemaker-serve/tests/unit/test_private_hub_artifact_resolution.py index 6b882c74ed..02d5856dda 100644 --- a/sagemaker-serve/tests/unit/test_private_hub_artifact_resolution.py +++ b/sagemaker-serve/tests/unit/test_private_hub_artifact_resolution.py @@ -398,6 +398,46 @@ def _init_kwargs_mock(model_reference_arn): return mock_init_kwargs +def _build_jumpstart_builder( + sagemaker_session, model_reference_arn, hub_arn=MOCK_HUB_ARN, hub_content_name=None +): + """Run _build_for_jumpstart on a ModelBuilder with get_init_kwargs mocked. + + Returns (builder, mock_get_kwargs). Only the factory resolution and + model creation are mocked; the builder attribute plumbing under test + runs for real. + """ + with _PATCH_IS_JS, patch( + "sagemaker.core.jumpstart.utils.validate_model_id_and_get_type", + return_value=None, + ), patch( + "sagemaker.core.jumpstart.factory.utils.get_init_kwargs" + ) as mock_get_kwargs, patch( + "sagemaker.serve.model_builder.ModelBuilder._create_model" + ) as mock_create, patch( + "sagemaker.serve.model_builder.ModelBuilder._prepare_for_mode" + ): + mock_get_kwargs.return_value = _init_kwargs_mock(model_reference_arn) + mock_create.return_value = Mock() + + builder = ModelBuilder( + model=MOCK_MODEL_ID, + role_arn=MOCK_ROLE_ARN, + sagemaker_session=sagemaker_session, + mode=Mode.SAGEMAKER_ENDPOINT, + ) + builder._optimizing = False + builder.model_version = MOCK_MODEL_VERSION + if hub_arn: + builder.hub_name = MOCK_HUB_NAME + builder.hub_arn = hub_arn + if hub_content_name: + builder.hub_content_name = hub_content_name + + builder._build_for_jumpstart() + return builder, mock_get_kwargs + + class TestModelReferenceArnPropagation(unittest.TestCase): """Regression tests: model_reference_arn resolved by get_init_kwargs must be propagated onto the builder so the container definition attaches @@ -411,37 +451,6 @@ class TestModelReferenceArnPropagation(unittest.TestCase): def setUp(self): self.mock_session = _mock_session() - def _build(self, model_reference_arn, hub_arn=MOCK_HUB_ARN, hub_content_name=None): - with _PATCH_IS_JS, patch( - "sagemaker.core.jumpstart.utils.validate_model_id_and_get_type", - return_value=None, - ), patch( - "sagemaker.core.jumpstart.factory.utils.get_init_kwargs" - ) as mock_get_kwargs, patch( - "sagemaker.serve.model_builder.ModelBuilder._create_model" - ) as mock_create, patch( - "sagemaker.serve.model_builder.ModelBuilder._prepare_for_mode" - ): - mock_get_kwargs.return_value = _init_kwargs_mock(model_reference_arn) - mock_create.return_value = Mock() - - builder = ModelBuilder( - model=MOCK_MODEL_ID, - role_arn=MOCK_ROLE_ARN, - sagemaker_session=self.mock_session, - mode=Mode.SAGEMAKER_ENDPOINT, - ) - builder._optimizing = False - builder.model_version = MOCK_MODEL_VERSION - if hub_arn: - builder.hub_name = MOCK_HUB_NAME - builder.hub_arn = hub_arn - if hub_content_name: - builder.hub_content_name = hub_content_name - - builder._build_for_jumpstart() - return builder, mock_get_kwargs - def test_model_reference_arn_propagated_from_init_kwargs(self): """The ARN resolved by the factory must land on the builder instance. @@ -449,7 +458,9 @@ def test_model_reference_arn_propagated_from_init_kwargs(self): hub_arn was forwarded to get_init_kwargs (PR #5985), but the resulting model_reference_arn was dropped, so CreateModel never got HubAccessConfig. """ - builder, _ = self._build(model_reference_arn=MOCK_MODEL_REFERENCE_ARN) + builder, _ = _build_jumpstart_builder( + self.mock_session, model_reference_arn=MOCK_MODEL_REFERENCE_ARN + ) self.assertEqual( getattr(builder, "model_reference_arn", None), @@ -461,7 +472,9 @@ def test_model_reference_arn_propagated_from_init_kwargs(self): def test_model_reference_arn_absent_for_public_catalog(self): """No private hub: model_reference_arn must remain unset (public path unchanged).""" - builder, _ = self._build(model_reference_arn=None, hub_arn=None) + builder, _ = _build_jumpstart_builder( + self.mock_session, model_reference_arn=None, hub_arn=None + ) self.assertIsNone(getattr(builder, "model_reference_arn", None)) @@ -520,9 +533,8 @@ def setUp(self): def test_hub_content_name_used_as_lookup_model_id(self): """When hub_content_name is set, get_init_kwargs must receive it as model_id.""" - helper = TestModelReferenceArnPropagation() - helper.mock_session = self.mock_session - _, mock_get_kwargs = helper._build( + _, mock_get_kwargs = _build_jumpstart_builder( + self.mock_session, model_reference_arn=MOCK_MODEL_REFERENCE_ARN, hub_content_name=MOCK_HUB_CONTENT_NAME, ) @@ -532,9 +544,9 @@ def test_hub_content_name_used_as_lookup_model_id(self): def test_model_id_used_when_no_hub_content_name(self): """Without hub_content_name, the model_id is used for the hub lookup.""" - helper = TestModelReferenceArnPropagation() - helper.mock_session = self.mock_session - _, mock_get_kwargs = helper._build(model_reference_arn=MOCK_MODEL_REFERENCE_ARN) + _, mock_get_kwargs = _build_jumpstart_builder( + self.mock_session, model_reference_arn=MOCK_MODEL_REFERENCE_ARN + ) call_kwargs = mock_get_kwargs.call_args.kwargs self.assertEqual(call_kwargs.get("model_id"), MOCK_MODEL_ID) @@ -588,10 +600,8 @@ def setUp(self): self.mock_session.config = None def _container_def_after_build(self, model_reference_arn, hub_arn=MOCK_HUB_ARN): - helper = TestModelReferenceArnPropagation() - helper.mock_session = self.mock_session - builder, _ = helper._build( - model_reference_arn=model_reference_arn, hub_arn=hub_arn + builder, _ = _build_jumpstart_builder( + self.mock_session, model_reference_arn=model_reference_arn, hub_arn=hub_arn ) # A JumpStart hub deploy has no custom inference code to upload; # ensure the code-upload branch (which would call S3) is not taken. From 1ae54901453da19cb7c40161ca2198db75fdfd8f Mon Sep 17 00:00:00 2001 From: Eli Davidson Date: Thu, 16 Jul 2026 22:59:57 +0000 Subject: [PATCH 5/6] test(integ): E2E private hub deploys with zero-S3 role and aliased content name Add two slow_test integration tests encoding the service-side contract the unit tests cannot cover: - test_deploy_with_no_s3_execution_role: creates an execution role with NO S3 permissions (ECR + CloudWatch Logs + hub read only), deploys from the private hub, and asserts the created Model resource carries S3DataSource.HubAccessConfig. On v3.16.0 this fails at CreateModel with 'Could not access model data at s3://jumpstart-cache-prod-...'. - test_deploy_with_aliased_hub_content_name: deploys a ModelReference whose HubContentName differs from the public model_id via the new JumpStartConfig.hub_content_name. On v3.16.0 this fails with ResourceNotFound. Both reuse the existing self-provisioning private_hub fixture (create hub + reference, tear down after) and clean up endpoints, endpoint configs, and the IAM role. Mirrors the live E2E verification performed against this branch (both scenarios deployed successfully with the zero-S3 role in us-east-1). --- X-AI-Prompt: Now where's the e2e test? X-AI-Tool: meshclaw --- .../test_private_hub_artifact_resolution.py | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) diff --git a/sagemaker-serve/tests/integ/test_private_hub_artifact_resolution.py b/sagemaker-serve/tests/integ/test_private_hub_artifact_resolution.py index 4292b6b560..059f55ecad 100644 --- a/sagemaker-serve/tests/integ/test_private_hub_artifact_resolution.py +++ b/sagemaker-serve/tests/integ/test_private_hub_artifact_resolution.py @@ -204,3 +204,226 @@ def test_build_resolves_artifacts_via_private_hub(private_hub, execution_role, s f"{model_data_str}. Expected private hub artifact resolution." ) logger.info("Model data resolved to: %s", model_data_str) + + +ALIASED_CONTENT_NAME = "sdk-integ-aliased-phi4-mini" +NO_S3_ROLE_PREFIX = "sdk-integ-no-s3-role" + +_NO_S3_ROLE_POLICY = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ecr:GetAuthorizationToken", + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + ], + "Resource": "*", + }, + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + ], + "Resource": "*", + }, + { + "Effect": "Allow", + "Action": [ + "sagemaker:DescribeHub", + "sagemaker:DescribeHubContent", + "sagemaker:ListHubContents", + "sagemaker:ListHubContentVersions", + ], + "Resource": "*", + }, + ], +} + + +@pytest.fixture(scope="module") +def no_s3_execution_role(): + """Create an execution role with NO S3 permissions whatsoever. + + This encodes the customer-visible contract of private hub brokered + access: with HubAccessConfig in the CreateModel call, SageMaker + brokers model data access through the hub content reference, so the + execution role needs no s3:GetObject on the public JumpStart cache. + """ + import json + + iam = boto3.client("iam", region_name=TEST_REGION) + role_name = f"{NO_S3_ROLE_PREFIX}-{uuid.uuid4().hex[:8]}" + trust = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "sagemaker.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + try: + resp = iam.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=json.dumps(trust), + Description="SDK integ test: private hub deploy with zero S3 access", + ) + iam.put_role_policy( + RoleName=role_name, + PolicyName="minimal-no-s3", + PolicyDocument=json.dumps(_NO_S3_ROLE_POLICY), + ) + except ClientError as e: + pytest.skip(f"Cannot create IAM role (likely missing permissions): {e}") + + time.sleep(15) # IAM propagation + yield resp["Role"]["Arn"] + + try: + iam.delete_role_policy(RoleName=role_name, PolicyName="minimal-no-s3") + iam.delete_role(RoleName=role_name) + except Exception as e: + logger.warning("Role cleanup failed: %s", e) + + +@pytest.fixture(scope="module") +def aliased_model_reference(private_hub): + """Add a ModelReference whose HubContentName differs from the model_id.""" + sm = boto3.client("sagemaker", region_name=TEST_REGION) + public_arn = ( + f"arn:aws:sagemaker:{TEST_REGION}:aws:hub-content/" + f"SageMakerPublicHub/Model/{TEST_MODEL_ID}" + ) + try: + sm.create_hub_content_reference( + HubName=private_hub, + SageMakerPublicHubContentArn=public_arn, + HubContentName=ALIASED_CONTENT_NAME, + ) + except ClientError as e: + pytest.skip(f"Cannot create aliased hub content reference: {e}") + + for _ in range(60): + try: + contents = sm.list_hub_contents( + HubName=private_hub, HubContentType="ModelReference" + ) + if any( + s["HubContentName"] == ALIASED_CONTENT_NAME + and s.get("HubContentStatus") == "Available" + for s in contents.get("HubContentSummaries", []) + ): + break + except Exception: + pass + time.sleep(3) + else: + pytest.skip(f"Aliased reference {ALIASED_CONTENT_NAME} not available") + + yield ALIASED_CONTENT_NAME + # Teardown handled by the private_hub fixture (deletes all references). + + +def _deploy_and_assert_hub_access_config( + hub_name, role_arn, sagemaker_session, hub_content_name=None +): + """Build + deploy with the given role; assert HubAccessConfig on the model. + + Returns after cleaning up the endpoint, endpoint config, and model. + """ + suffix = uuid.uuid4().hex[:8] + endpoint_name = f"sdk-integ-private-hub-{suffix}" + from sagemaker.train.configs import Compute + + config_kwargs = dict( + model_id=TEST_MODEL_ID, + model_version=TEST_MODEL_VERSION, + hub_name=hub_name, + accept_eula=True, + ) + if hub_content_name: + config_kwargs["hub_content_name"] = hub_content_name + + mb = ModelBuilder.from_jumpstart_config( + jumpstart_config=JumpStartConfig(**config_kwargs), + compute=Compute(instance_type=TEST_INSTANCE_TYPE), + sagemaker_session=sagemaker_session, + role_arn=role_arn, + ) + + sm = boto3.client("sagemaker", region_name=TEST_REGION) + try: + mb.build(sagemaker_session=sagemaker_session) + # deploy(wait=False): CreateModel is the call under test. It fails + # synchronously without HubAccessConfig when the role has no S3 access. + mb.deploy(endpoint_name=endpoint_name, wait=False) + + # Assert the created Model resource carries HubAccessConfig + endpoint = sm.describe_endpoint(EndpointName=endpoint_name) + ep_config = sm.describe_endpoint_config( + EndpointConfigName=endpoint["EndpointConfigName"] + ) + model_name = ep_config["ProductionVariants"][0]["ModelName"] + model = sm.describe_model(ModelName=model_name) + container = model.get("PrimaryContainer") or model["Containers"][0] + hub_access = ( + container.get("ModelDataSource", {}) + .get("S3DataSource", {}) + .get("HubAccessConfig") + ) + assert hub_access is not None, ( + "CreateModel succeeded but the model has no " + "S3DataSource.HubAccessConfig; private hub access was not brokered" + ) + assert hub_name in hub_access["HubContentArn"] + finally: + for op, kwargs in ( + (sm.delete_endpoint, {"EndpointName": endpoint_name}), + (sm.delete_endpoint_config, {"EndpointConfigName": endpoint_name}), + ): + try: + op(**kwargs) + except Exception: + pass + + +@pytest.mark.slow_test +def test_deploy_with_no_s3_execution_role( + private_hub, no_s3_execution_role, sagemaker_session +): + """E2E: deploy from a private hub with an execution role that has ZERO + S3 permissions. Passes only when the SDK attaches HubAccessConfig to + the CreateModel call (SageMaker brokers artifact access via the hub). + + On v3.16.0 this fails at CreateModel with a ValidationException: + 'Could not access model data at s3://jumpstart-cache-prod-...'. + """ + _deploy_and_assert_hub_access_config( + hub_name=private_hub, + role_arn=no_s3_execution_role, + sagemaker_session=sagemaker_session, + ) + + +@pytest.mark.slow_test +def test_deploy_with_aliased_hub_content_name( + private_hub, aliased_model_reference, no_s3_execution_role, sagemaker_session +): + """E2E: deploy a ModelReference whose HubContentName differs from the + public model_id, using JumpStartConfig.hub_content_name. + + On v3.16.0 this fails at hub content resolution with ResourceNotFound + because the SDK only looks up hub content by model_id. + """ + _deploy_and_assert_hub_access_config( + hub_name=private_hub, + role_arn=no_s3_execution_role, + sagemaker_session=sagemaker_session, + hub_content_name=aliased_model_reference, + ) From ebc2a56b69b17c6ba36422ca91c576c23947ecb7 Mon Sep 17 00:00:00 2001 From: Eli Davidson Date: Thu, 16 Jul 2026 23:14:09 +0000 Subject: [PATCH 6/6] test(integ): correct baseline failure docstring, log cleanup failures Live verification of both integ tests in both directions: - With fix: 2 passed (38s) - Baseline v3.16.0: 2 failed as intended. no_s3 test failed at the HubAccessConfig assertion (CreateModel accepted the gated gemma-2b request without HubAccessConfig; the endpoint then failed asynchronously). Aliased test failed at JumpStartConfig validation (hub_content_name field does not exist on baseline). Corrections from the live run: the no_s3 docstring claimed a synchronous CreateModel ValidationException, but the deterministic failure signal is the HubAccessConfig assertion. Cleanup except block now logs failures instead of silently swallowing them (three Failed-status endpoints leaked during verification runs and were deleted manually). --- X-AI-Prompt: Run both directions live now X-AI-Tool: meshclaw --- .../integ/test_private_hub_artifact_resolution.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/sagemaker-serve/tests/integ/test_private_hub_artifact_resolution.py b/sagemaker-serve/tests/integ/test_private_hub_artifact_resolution.py index 059f55ecad..13ff401bcd 100644 --- a/sagemaker-serve/tests/integ/test_private_hub_artifact_resolution.py +++ b/sagemaker-serve/tests/integ/test_private_hub_artifact_resolution.py @@ -389,8 +389,8 @@ def _deploy_and_assert_hub_access_config( ): try: op(**kwargs) - except Exception: - pass + except Exception as e: + logger.warning("Cleanup failed for %s: %s", kwargs, e) @pytest.mark.slow_test @@ -401,8 +401,10 @@ def test_deploy_with_no_s3_execution_role( S3 permissions. Passes only when the SDK attaches HubAccessConfig to the CreateModel call (SageMaker brokers artifact access via the hub). - On v3.16.0 this fails at CreateModel with a ValidationException: - 'Could not access model data at s3://jumpstart-cache-prod-...'. + Verified against the v3.16.0 baseline: fails at the HubAccessConfig + assertion (the created Model resource has no + S3DataSource.HubAccessConfig, so artifact access is not brokered and + the endpoint cannot serve without public-bucket S3 permissions). """ _deploy_and_assert_hub_access_config( hub_name=private_hub,