@@ -204,3 +204,226 @@ def test_build_resolves_artifacts_via_private_hub(private_hub, execution_role, s
204204 f"{ model_data_str } . Expected private hub artifact resolution."
205205 )
206206 logger .info ("Model data resolved to: %s" , model_data_str )
207+
208+
209+ ALIASED_CONTENT_NAME = "sdk-integ-aliased-phi4-mini"
210+ NO_S3_ROLE_PREFIX = "sdk-integ-no-s3-role"
211+
212+ _NO_S3_ROLE_POLICY = {
213+ "Version" : "2012-10-17" ,
214+ "Statement" : [
215+ {
216+ "Effect" : "Allow" ,
217+ "Action" : [
218+ "ecr:GetAuthorizationToken" ,
219+ "ecr:BatchCheckLayerAvailability" ,
220+ "ecr:GetDownloadUrlForLayer" ,
221+ "ecr:BatchGetImage" ,
222+ ],
223+ "Resource" : "*" ,
224+ },
225+ {
226+ "Effect" : "Allow" ,
227+ "Action" : [
228+ "logs:CreateLogGroup" ,
229+ "logs:CreateLogStream" ,
230+ "logs:PutLogEvents" ,
231+ ],
232+ "Resource" : "*" ,
233+ },
234+ {
235+ "Effect" : "Allow" ,
236+ "Action" : [
237+ "sagemaker:DescribeHub" ,
238+ "sagemaker:DescribeHubContent" ,
239+ "sagemaker:ListHubContents" ,
240+ "sagemaker:ListHubContentVersions" ,
241+ ],
242+ "Resource" : "*" ,
243+ },
244+ ],
245+ }
246+
247+
248+ @pytest .fixture (scope = "module" )
249+ def no_s3_execution_role ():
250+ """Create an execution role with NO S3 permissions whatsoever.
251+
252+ This encodes the customer-visible contract of private hub brokered
253+ access: with HubAccessConfig in the CreateModel call, SageMaker
254+ brokers model data access through the hub content reference, so the
255+ execution role needs no s3:GetObject on the public JumpStart cache.
256+ """
257+ import json
258+
259+ iam = boto3 .client ("iam" , region_name = TEST_REGION )
260+ role_name = f"{ NO_S3_ROLE_PREFIX } -{ uuid .uuid4 ().hex [:8 ]} "
261+ trust = {
262+ "Version" : "2012-10-17" ,
263+ "Statement" : [
264+ {
265+ "Effect" : "Allow" ,
266+ "Principal" : {"Service" : "sagemaker.amazonaws.com" },
267+ "Action" : "sts:AssumeRole" ,
268+ }
269+ ],
270+ }
271+ try :
272+ resp = iam .create_role (
273+ RoleName = role_name ,
274+ AssumeRolePolicyDocument = json .dumps (trust ),
275+ Description = "SDK integ test: private hub deploy with zero S3 access" ,
276+ )
277+ iam .put_role_policy (
278+ RoleName = role_name ,
279+ PolicyName = "minimal-no-s3" ,
280+ PolicyDocument = json .dumps (_NO_S3_ROLE_POLICY ),
281+ )
282+ except ClientError as e :
283+ pytest .skip (f"Cannot create IAM role (likely missing permissions): { e } " )
284+
285+ time .sleep (15 ) # IAM propagation
286+ yield resp ["Role" ]["Arn" ]
287+
288+ try :
289+ iam .delete_role_policy (RoleName = role_name , PolicyName = "minimal-no-s3" )
290+ iam .delete_role (RoleName = role_name )
291+ except Exception as e :
292+ logger .warning ("Role cleanup failed: %s" , e )
293+
294+
295+ @pytest .fixture (scope = "module" )
296+ def aliased_model_reference (private_hub ):
297+ """Add a ModelReference whose HubContentName differs from the model_id."""
298+ sm = boto3 .client ("sagemaker" , region_name = TEST_REGION )
299+ public_arn = (
300+ f"arn:aws:sagemaker:{ TEST_REGION } :aws:hub-content/"
301+ f"SageMakerPublicHub/Model/{ TEST_MODEL_ID } "
302+ )
303+ try :
304+ sm .create_hub_content_reference (
305+ HubName = private_hub ,
306+ SageMakerPublicHubContentArn = public_arn ,
307+ HubContentName = ALIASED_CONTENT_NAME ,
308+ )
309+ except ClientError as e :
310+ pytest .skip (f"Cannot create aliased hub content reference: { e } " )
311+
312+ for _ in range (60 ):
313+ try :
314+ contents = sm .list_hub_contents (
315+ HubName = private_hub , HubContentType = "ModelReference"
316+ )
317+ if any (
318+ s ["HubContentName" ] == ALIASED_CONTENT_NAME
319+ and s .get ("HubContentStatus" ) == "Available"
320+ for s in contents .get ("HubContentSummaries" , [])
321+ ):
322+ break
323+ except Exception :
324+ pass
325+ time .sleep (3 )
326+ else :
327+ pytest .skip (f"Aliased reference { ALIASED_CONTENT_NAME } not available" )
328+
329+ yield ALIASED_CONTENT_NAME
330+ # Teardown handled by the private_hub fixture (deletes all references).
331+
332+
333+ def _deploy_and_assert_hub_access_config (
334+ hub_name , role_arn , sagemaker_session , hub_content_name = None
335+ ):
336+ """Build + deploy with the given role; assert HubAccessConfig on the model.
337+
338+ Returns after cleaning up the endpoint, endpoint config, and model.
339+ """
340+ suffix = uuid .uuid4 ().hex [:8 ]
341+ endpoint_name = f"sdk-integ-private-hub-{ suffix } "
342+ from sagemaker .train .configs import Compute
343+
344+ config_kwargs = dict (
345+ model_id = TEST_MODEL_ID ,
346+ model_version = TEST_MODEL_VERSION ,
347+ hub_name = hub_name ,
348+ accept_eula = True ,
349+ )
350+ if hub_content_name :
351+ config_kwargs ["hub_content_name" ] = hub_content_name
352+
353+ mb = ModelBuilder .from_jumpstart_config (
354+ jumpstart_config = JumpStartConfig (** config_kwargs ),
355+ compute = Compute (instance_type = TEST_INSTANCE_TYPE ),
356+ sagemaker_session = sagemaker_session ,
357+ role_arn = role_arn ,
358+ )
359+
360+ sm = boto3 .client ("sagemaker" , region_name = TEST_REGION )
361+ try :
362+ mb .build (sagemaker_session = sagemaker_session )
363+ # deploy(wait=False): CreateModel is the call under test. It fails
364+ # synchronously without HubAccessConfig when the role has no S3 access.
365+ mb .deploy (endpoint_name = endpoint_name , wait = False )
366+
367+ # Assert the created Model resource carries HubAccessConfig
368+ endpoint = sm .describe_endpoint (EndpointName = endpoint_name )
369+ ep_config = sm .describe_endpoint_config (
370+ EndpointConfigName = endpoint ["EndpointConfigName" ]
371+ )
372+ model_name = ep_config ["ProductionVariants" ][0 ]["ModelName" ]
373+ model = sm .describe_model (ModelName = model_name )
374+ container = model .get ("PrimaryContainer" ) or model ["Containers" ][0 ]
375+ hub_access = (
376+ container .get ("ModelDataSource" , {})
377+ .get ("S3DataSource" , {})
378+ .get ("HubAccessConfig" )
379+ )
380+ assert hub_access is not None , (
381+ "CreateModel succeeded but the model has no "
382+ "S3DataSource.HubAccessConfig; private hub access was not brokered"
383+ )
384+ assert hub_name in hub_access ["HubContentArn" ]
385+ finally :
386+ for op , kwargs in (
387+ (sm .delete_endpoint , {"EndpointName" : endpoint_name }),
388+ (sm .delete_endpoint_config , {"EndpointConfigName" : endpoint_name }),
389+ ):
390+ try :
391+ op (** kwargs )
392+ except Exception :
393+ pass
394+
395+
396+ @pytest .mark .slow_test
397+ def test_deploy_with_no_s3_execution_role (
398+ private_hub , no_s3_execution_role , sagemaker_session
399+ ):
400+ """E2E: deploy from a private hub with an execution role that has ZERO
401+ S3 permissions. Passes only when the SDK attaches HubAccessConfig to
402+ the CreateModel call (SageMaker brokers artifact access via the hub).
403+
404+ On v3.16.0 this fails at CreateModel with a ValidationException:
405+ 'Could not access model data at s3://jumpstart-cache-prod-...'.
406+ """
407+ _deploy_and_assert_hub_access_config (
408+ hub_name = private_hub ,
409+ role_arn = no_s3_execution_role ,
410+ sagemaker_session = sagemaker_session ,
411+ )
412+
413+
414+ @pytest .mark .slow_test
415+ def test_deploy_with_aliased_hub_content_name (
416+ private_hub , aliased_model_reference , no_s3_execution_role , sagemaker_session
417+ ):
418+ """E2E: deploy a ModelReference whose HubContentName differs from the
419+ public model_id, using JumpStartConfig.hub_content_name.
420+
421+ On v3.16.0 this fails at hub content resolution with ResourceNotFound
422+ because the SDK only looks up hub content by model_id.
423+ """
424+ _deploy_and_assert_hub_access_config (
425+ hub_name = private_hub ,
426+ role_arn = no_s3_execution_role ,
427+ sagemaker_session = sagemaker_session ,
428+ hub_content_name = aliased_model_reference ,
429+ )
0 commit comments