Skip to content

Commit 1425224

Browse files
authored
Merge branch 'main' into fix/s3-grant-imported-bucket-race-condition
2 parents 5de096b + cc14571 commit 1425224

4 files changed

Lines changed: 58 additions & 127 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
### **Changed**
1515

1616
- fixed `sagemaker-templates` Model Deploy seed code S3 permission race condition where `grant_read_write()` on an imported bucket created a `DefaultPolicy` with no CloudFormation dependency from the SageMaker Model, causing intermittent `s3:GetObject` access denied errors
17+
- consolidated redundant `DevStage`/`PreProdStage`/`ProdStage` classes into a single `DeployStage` in `sagemaker-templates` model deploy seed code, fixing redundant CF stack names (e.g. `dev-dev-endpoint``dev-{project}-endpoint`) and adding project uniqueness to prevent cross-project collisions
1718

1819
## v3.2.3
1920

modules/sagemaker/sagemaker-templates/templates/model_deploy/seed_code/deploy_app/deploy_app/deploy_endpoint_stack.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ def __init__(
7979
self,
8080
scope: constructs.Construct,
8181
id: str,
82+
*,
83+
stage_name: str,
8284
vpc_id: str,
8385
subnet_ids: List[str],
8486
security_group_ids: List[str],
@@ -199,7 +201,7 @@ def __init__(
199201
latest_approved_model_package = get_approved_package()
200202

201203
# Sagemaker Model
202-
model_name = f"-{id}-{timestamp}"
204+
model_name = f"-{stage_name}-{timestamp}"
203205
model_name = MODEL_PACKAGE_GROUP_NAME[: MAX_NAME_LENGTH - len(model_name)] + model_name
204206

205207
vpc_config = None
@@ -227,7 +229,7 @@ def __init__(
227229
)
228230

229231
# Sagemaker Endpoint Config
230-
endpoint_config_name = f"-{id}-ec-{timestamp}"
232+
endpoint_config_name = f"-{stage_name}-ec-{timestamp}"
231233
endpoint_config_name = (
232234
MODEL_PACKAGE_GROUP_NAME[: MAX_NAME_LENGTH - len(endpoint_config_name)] + endpoint_config_name
233235
)
@@ -311,7 +313,7 @@ def __init__(
311313
endpoint_config.add_dependency(model)
312314

313315
# Sagemaker Endpoint
314-
endpoint_name = f"-{id}-endpoint"
316+
endpoint_name = f"-{stage_name}-ep"
315317
endpoint_name = MODEL_PACKAGE_GROUP_NAME[: MAX_NAME_LENGTH - len(endpoint_name)] + endpoint_name
316318

317319
endpoint = sagemaker.CfnEndpoint(

modules/sagemaker/sagemaker-templates/templates/model_deploy/seed_code/deploy_app/deploy_app/pipeline_stack.py

Lines changed: 32 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -44,42 +44,27 @@
4444
}
4545

4646

47-
class DevStage(cdk.Stage):
48-
def __init__(self, scope: Construct, construct_id: str, **kwargs: Any) -> None:
49-
super().__init__(scope, construct_id, **kwargs)
50-
51-
DeployEndpointStack(
52-
self,
53-
"dev-endpoint",
54-
vpc_id=constants.DEV_VPC_ID,
55-
subnet_ids=constants.DEV_SUBNET_IDS,
56-
security_group_ids=constants.DEV_SECURITY_GROUP_IDS,
57-
)
58-
59-
60-
class PreProdStage(cdk.Stage):
61-
def __init__(self, scope: Construct, construct_id: str, **kwargs: Any) -> None:
62-
super().__init__(scope, construct_id, **kwargs)
63-
64-
DeployEndpointStack(
65-
self,
66-
"preprod-endpoint",
67-
vpc_id=constants.PRE_PROD_VPC_ID,
68-
subnet_ids=constants.PRE_PROD_SUBNET_IDS,
69-
security_group_ids=constants.PRE_PROD_SECURITY_GROUP_IDS,
70-
)
71-
72-
73-
class ProdStage(cdk.Stage):
74-
def __init__(self, scope: Construct, construct_id: str, **kwargs: Any) -> None:
47+
class DeployStage(cdk.Stage):
48+
def __init__(
49+
self,
50+
scope: Construct,
51+
construct_id: str,
52+
*,
53+
stage_name: str,
54+
vpc_id: str,
55+
subnet_ids: list[str],
56+
security_group_ids: list[str],
57+
**kwargs: Any,
58+
) -> None:
7559
super().__init__(scope, construct_id, **kwargs)
7660

7761
DeployEndpointStack(
7862
self,
79-
"prod-endpoint",
80-
vpc_id=constants.PROD_VPC_ID,
81-
subnet_ids=constants.PROD_SUBNET_IDS,
82-
security_group_ids=constants.PROD_SECURITY_GROUP_IDS,
63+
f"{constants.PROJECT_NAME}-endpoint",
64+
stage_name=stage_name,
65+
vpc_id=vpc_id,
66+
subnet_ids=subnet_ids,
67+
security_group_ids=security_group_ids,
8368
)
8469

8570

@@ -188,17 +173,25 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs: Any) -> None:
188173
)
189174

190175
pipeline.add_stage(
191-
DevStage(
176+
DeployStage(
192177
self,
193178
"dev",
179+
stage_name="dev",
180+
vpc_id=constants.DEV_VPC_ID,
181+
subnet_ids=constants.DEV_SUBNET_IDS,
182+
security_group_ids=constants.DEV_SECURITY_GROUP_IDS,
194183
env=cdk.Environment(account=constants.DEV_ACCOUNT_ID, region=constants.DEV_REGION),
195184
)
196185
)
197186

198187
pipeline.add_stage(
199-
PreProdStage(
188+
DeployStage(
200189
self,
201190
"preprod",
191+
stage_name="preprod",
192+
vpc_id=constants.PRE_PROD_VPC_ID,
193+
subnet_ids=constants.PRE_PROD_SUBNET_IDS,
194+
security_group_ids=constants.PRE_PROD_SECURITY_GROUP_IDS,
202195
env=cdk.Environment(account=constants.PRE_PROD_ACCOUNT_ID, region=constants.PRE_PROD_REGION),
203196
),
204197
pre=[ManualApprovalStep("ApprovePreProd", comment="Approve deployment to Pre-Production")]
@@ -207,9 +200,13 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs: Any) -> None:
207200
)
208201

209202
pipeline.add_stage(
210-
ProdStage(
203+
DeployStage(
211204
self,
212205
"prod",
206+
stage_name="prod",
207+
vpc_id=constants.PROD_VPC_ID,
208+
subnet_ids=constants.PROD_SUBNET_IDS,
209+
security_group_ids=constants.PROD_SECURITY_GROUP_IDS,
213210
env=cdk.Environment(account=constants.PROD_ACCOUNT_ID, region=constants.PROD_REGION),
214211
),
215212
pre=[ManualApprovalStep("ApproveProd", comment="Approve deployment to Production")]

modules/sagemaker/sagemaker-templates/templates/model_deploy/seed_code/deploy_app/tests/test_synth_s3_permissions.py

Lines changed: 20 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
"""Verify that cdk synth produces the expected S3 permission statements.
1+
"""Verify that cdk synth produces correct SageMaker resource names.
22
33
This test mocks the SageMaker API call (get_approved_package) and all
44
required environment variables, then synthesizes the DeployEndpointStack
5-
and asserts the ManagedPolicy contains the correct S3 actions.
5+
and asserts that stage_name drives unique SageMaker resource names.
66
"""
77

88
import json
@@ -53,7 +53,7 @@
5353
from deploy_app.deploy_endpoint_stack import DeployEndpointStack # noqa: E402
5454

5555

56-
def _synth_deploy_endpoint_stack() -> assertions.Template:
56+
def _synth_deploy_endpoint_stack(stage_name: str = "dev") -> assertions.Template:
5757
"""Synthesize a standalone DeployEndpointStack and return its Template."""
5858
# Reset the mock so each test gets a fresh call count
5959
_fake_get_approved.reset_mock()
@@ -67,7 +67,8 @@ def _synth_deploy_endpoint_stack() -> assertions.Template:
6767
)
6868
stack = DeployEndpointStack(
6969
stage,
70-
"test-endpoint",
70+
"test-project-endpoint",
71+
stage_name=stage_name,
7172
vpc_id="vpc-abc123",
7273
subnet_ids=["subnet-aaa"],
7374
security_group_ids=["sg-aaa"],
@@ -76,95 +77,25 @@ def _synth_deploy_endpoint_stack() -> assertions.Template:
7677
return assertions.Template.from_stack(stack)
7778

7879

79-
def test_managed_policy_has_s3_read_write_actions():
80-
"""The ModelExecutionPolicy must contain S3 read/write actions on the model bucket."""
81-
template = _synth_deploy_endpoint_stack()
82-
83-
template.has_resource_properties(
84-
"AWS::IAM::ManagedPolicy",
85-
assertions.Match.object_like(
86-
{
87-
"PolicyDocument": {
88-
"Statement": assertions.Match.array_with(
89-
[
90-
assertions.Match.object_like(
91-
{
92-
"Action": assertions.Match.array_with(
93-
[
94-
"s3:GetObject*",
95-
"s3:GetBucket*",
96-
"s3:List*",
97-
"s3:PutObject",
98-
]
99-
),
100-
"Effect": "Allow",
101-
"Resource": [
102-
"arn:aws:s3:::test-model-bucket",
103-
"arn:aws:s3:::test-model-bucket/*",
104-
],
105-
}
106-
),
107-
]
108-
),
109-
}
110-
}
111-
),
112-
)
80+
def _get_endpoint_name(template: assertions.Template) -> str:
81+
"""Extract the SageMaker endpoint name from a synthesized template."""
82+
endpoints = template.find_resources("AWS::SageMaker::Endpoint")
83+
assert len(endpoints) == 1, f"Expected 1 endpoint, found {len(endpoints)}"
84+
props = next(iter(endpoints.values()))["Properties"]
85+
return props["EndpointName"]
11386

11487

115-
def test_managed_policy_has_data_capture_write_actions():
116-
"""The ModelExecutionPolicy must contain S3 write actions for data capture."""
117-
template = _synth_deploy_endpoint_stack()
88+
def test_stage_name_produces_unique_endpoint_names():
89+
"""Different stage_name values must produce different SageMaker endpoint names."""
90+
dev_template = _synth_deploy_endpoint_stack(stage_name="dev")
91+
prod_template = _synth_deploy_endpoint_stack(stage_name="prod")
11892

119-
template.has_resource_properties(
120-
"AWS::IAM::ManagedPolicy",
121-
assertions.Match.object_like(
122-
{
123-
"PolicyDocument": {
124-
"Statement": assertions.Match.array_with(
125-
[
126-
assertions.Match.object_like(
127-
{
128-
"Action": assertions.Match.array_with(
129-
[
130-
"s3:PutObject",
131-
]
132-
),
133-
"Effect": "Allow",
134-
"Resource": "arn:aws:s3:::test-model-bucket/endpoint-data-capture/*",
135-
}
136-
),
137-
]
138-
),
139-
}
140-
}
141-
),
142-
)
143-
144-
145-
def test_no_separate_default_policy():
146-
"""There must be NO DefaultPolicy (inline AWS::IAM::Policy) for S3 permissions.
147-
148-
S3 permissions must be in the ManagedPolicy, not a separate inline policy,
149-
to preserve the CloudFormation dependency chain.
150-
"""
151-
template = _synth_deploy_endpoint_stack()
93+
dev_ep = _get_endpoint_name(dev_template)
94+
prod_ep = _get_endpoint_name(prod_template)
15295

153-
# Count all IAM::Policy resources -- there should be none with S3 actions
154-
resources = template.find_resources("AWS::IAM::Policy")
155-
for logical_id, resource in resources.items():
156-
policy_doc = resource.get("Properties", {}).get("PolicyDocument", {})
157-
statements = policy_doc.get("Statement", [])
158-
for stmt in statements:
159-
actions = stmt.get("Action", [])
160-
if isinstance(actions, str):
161-
actions = [actions]
162-
s3_actions = [a for a in actions if a.startswith("s3:")]
163-
assert not s3_actions, (
164-
f"Found S3 actions {s3_actions} in inline IAM::Policy '{logical_id}'. "
165-
f"S3 permissions must be in the ManagedPolicy to ensure correct "
166-
f"CloudFormation dependency ordering."
167-
)
96+
assert dev_ep != prod_ep, f"dev and prod endpoint names should differ, both are '{dev_ep}'"
97+
assert "-dev-" in dev_ep, f"Expected '-dev-' in endpoint name, got '{dev_ep}'"
98+
assert "-prod-" in prod_ep, f"Expected '-prod-' in endpoint name, got '{prod_ep}'"
16899

169100

170101
def test_role_references_managed_policy():

0 commit comments

Comments
 (0)