Skip to content

Commit 21a6eba

Browse files
authored
Add support for stack and change set level hooks
* Add support to download hook target data for stack-level hooks Stack level hooks will not be provided with invocation payload information, unlike resource level hooks. Instead, Hooks Service will pass in an S3 presigned URL that points to a file that contains the stack-level invocation payload. The base hook handler (before it reaches the customer's handler code), will use that URL to download the data and set it on the target model that is passed to the customer's handler. * Add support to download hook target data for stack-level hooks Stack level hooks will not be provided with invocation payload information, unlike resource level hooks. Instead, Hooks Service will pass in an S3 presigned URL that points to a file that contains the stack-level invocation payload. The base hook handler (before it reaches the customer's handler code), will use that URL to download the data and set it on the target model that is passed to the customer's handler. * Skip stack level hook for stack if prior stack level change set hook succeeded For stack level hooks, customers are able to return a new status that allow stack level hooks that execute against a stack to skip with a successful status. The idea is that if a stack hook invoked against a change set succeeds, there is no need to invoke against the stack once the change set is processed. * testing * testing * testing * testing * Add new operation status translation * WIP * wip * wip * wip * wip * wip
1 parent 9d1ab97 commit 21a6eba

File tree

8 files changed

+160
-4
lines changed

8 files changed

+160
-4
lines changed

python/rpdk/python/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import logging
22

3-
__version__ = "2.1.9"
3+
__version__ = "2.1.10"
44

55
logging.getLogger(__name__).addHandler(logging.NullHandler())

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def find_version(*file_paths):
4040
install_requires=[
4141
"cloudformation-cli>=0.2.26",
4242
"types-dataclasses>=0.1.5",
43+
"setuptools",
4344
],
4445
entry_points={
4546
"rpdk.v1.languages": [

src/cloudformation_cli_python_lib/hook.py

+2
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,8 @@ def _get_hook_status(operation_status: OperationStatus) -> HookStatus:
293293
hook_status = HookStatus.IN_PROGRESS
294294
elif operation_status == OperationStatus.SUCCESS:
295295
hook_status = HookStatus.SUCCESS
296+
elif operation_status == OperationStatus.CHANGE_SET_SUCCESS_SKIP_STACK_HOOK:
297+
hook_status = HookStatus.CHANGE_SET_SUCCESS_SKIP_STACK_HOOK
296298
else:
297299
hook_status = HookStatus.FAILED
298300
return hook_status

src/cloudformation_cli_python_lib/interface.py

+2
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,15 @@ class OperationStatus(str, _AutoName):
4646
PENDING = auto()
4747
IN_PROGRESS = auto()
4848
SUCCESS = auto()
49+
CHANGE_SET_SUCCESS_SKIP_STACK_HOOK = auto()
4950
FAILED = auto()
5051

5152

5253
class HookStatus(str, _AutoName):
5354
PENDING = auto()
5455
IN_PROGRESS = auto()
5556
SUCCESS = auto()
57+
CHANGE_SET_SUCCESS_SKIP_STACK_HOOK = auto()
5658
FAILED = auto()
5759

5860

src/cloudformation_cli_python_lib/utils.py

+43-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
from dataclasses import dataclass, field, fields
33

44
import json
5+
import requests # type: ignore
56
from datetime import date, datetime, time
7+
from requests.adapters import HTTPAdapter # type: ignore
68
from typing import (
79
Any,
810
Callable,
@@ -14,6 +16,7 @@
1416
Type,
1517
Union,
1618
)
19+
from urllib3 import Retry # type: ignore
1720

1821
from .exceptions import InvalidRequest
1922
from .interface import (
@@ -25,6 +28,12 @@
2528
HookInvocationPoint,
2629
)
2730

31+
HOOK_REQUEST_DATA_TARGET_MODEL_FIELD_NAME = "targetModel"
32+
HOOK_REMOTE_PAYLOAD_CONNECT_AND_READ_TIMEOUT_SECONDS = 10
33+
HOOK_REMOTE_PAYLOAD_RETRY_LIMIT = 3
34+
HOOK_REMOTE_PAYLOAD_RETRY_BACKOFF_FACTOR = 1
35+
HOOK_REMOTE_PAYLOAD_RETRY_STATUSES = [500, 502, 503, 504]
36+
2837

2938
class KitchenSinkEncoder(json.JSONEncoder):
3039
def default(self, o): # type: ignore # pylint: disable=method-hidden
@@ -213,7 +222,8 @@ class HookRequestData:
213222
targetName: str
214223
targetType: str
215224
targetLogicalId: str
216-
targetModel: Mapping[str, Any]
225+
targetModel: Optional[Mapping[str, Any]] = None
226+
payload: Optional[str] = None
217227
callerCredentials: Optional[Credentials] = None
218228
providerCredentials: Optional[Credentials] = None
219229
providerLogGroupName: Optional[str] = None
@@ -234,6 +244,30 @@ def deserialize(cls, json_data: MutableMapping[str, Any]) -> "HookRequestData":
234244
if creds:
235245
cred_data = json.loads(creds)
236246
setattr(req_data, key, Credentials(**cred_data))
247+
248+
if req_data.is_hook_invocation_payload_remote():
249+
with requests.Session() as s:
250+
retries = Retry(
251+
total=HOOK_REMOTE_PAYLOAD_RETRY_LIMIT,
252+
backoff_factor=HOOK_REMOTE_PAYLOAD_RETRY_BACKOFF_FACTOR,
253+
status_forcelist=HOOK_REMOTE_PAYLOAD_RETRY_STATUSES,
254+
)
255+
256+
s.mount("http://", HTTPAdapter(max_retries=retries))
257+
s.mount("https://", HTTPAdapter(max_retries=retries))
258+
259+
response = s.get(
260+
req_data.payload,
261+
timeout=HOOK_REMOTE_PAYLOAD_CONNECT_AND_READ_TIMEOUT_SECONDS,
262+
)
263+
264+
if response.status_code == 200:
265+
setattr(
266+
req_data,
267+
HOOK_REQUEST_DATA_TARGET_MODEL_FIELD_NAME,
268+
response.json(),
269+
)
270+
237271
return req_data
238272

239273
def serialize(self) -> Mapping[str, Any]:
@@ -247,6 +281,14 @@ def serialize(self) -> Mapping[str, Any]:
247281
if value is not None
248282
}
249283

284+
def is_hook_invocation_payload_remote(self) -> bool:
285+
if (
286+
not self.targetModel and self.payload
287+
): # pylint: disable=simplifiable-if-statement
288+
return True
289+
290+
return False
291+
250292

251293
@dataclass
252294
class HookInvocationRequest:

src/setup.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
setup(
55
name="cloudformation-cli-python-lib",
6-
version="2.1.18",
6+
version="2.1.19",
77
description=__doc__,
88
author="Amazon Web Services",
99
author_email="[email protected]",
@@ -15,6 +15,9 @@
1515
python_requires=">=3.8",
1616
install_requires=[
1717
"boto3>=1.34.6",
18+
'dataclasses;python_version<"3.7"',
19+
"requests>=2.22",
20+
"setuptools",
1821
],
1922
license="Apache License 2.0",
2023
classifiers=[

tests/lib/hook_test.py

+105-1
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,15 @@
1414
OperationStatus,
1515
ProgressEvent,
1616
)
17-
from cloudformation_cli_python_lib.utils import Credentials, HookInvocationRequest
17+
from cloudformation_cli_python_lib.utils import (
18+
Credentials,
19+
HookInvocationRequest,
20+
HookRequestData,
21+
)
1822

1923
import json
2024
from datetime import datetime
25+
from typing import Any, Mapping
2126
from unittest.mock import Mock, call, patch, sentinel
2227

2328
ENTRYPOINT_PAYLOAD = {
@@ -50,6 +55,34 @@
5055
"hookModel": sentinel.type_configuration,
5156
}
5257

58+
STACK_LEVEL_HOOK_ENTRYPOINT_PAYLOAD = {
59+
"awsAccountId": "123456789012",
60+
"clientRequestToken": "4b90a7e4-b790-456b-a937-0cfdfa211dfe",
61+
"region": "us-east-1",
62+
"actionInvocationPoint": "CREATE_PRE_PROVISION",
63+
"hookTypeName": "AWS::Test::TestHook",
64+
"hookTypeVersion": "1.0",
65+
"requestContext": {
66+
"invocation": 1,
67+
"callbackContext": {},
68+
},
69+
"requestData": {
70+
"callerCredentials": '{"accessKeyId": "IASAYK835GAIFHAHEI23", "secretAccessKey": "66iOGPN5LnpZorcLr8Kh25u8AbjHVllv5poh2O0", "sessionToken": "lameHS2vQOknSHWhdFYTxm2eJc1JMn9YBNI4nV4mXue945KPL6DHfW8EsUQT5zwssYEC1NvYP9yD6Y5s5lKR3chflOHPFsIe6eqg"}', # noqa: B950
71+
"providerCredentials": '{"accessKeyId": "HDI0745692Y45IUTYR78", "secretAccessKey": "4976TUYVI2345GW87ERYG823RF87GY9EIUH452I3", "sessionToken": "842HYOFIQAEUDF78R8T7IU43HSADYGIFHBJSDHFA87SDF9PYvN1CEYASDUYFT5TQ97YASIHUDFAIUEYRISDKJHFAYSUDTFSDFADS"}', # noqa: B950
72+
"providerLogGroupName": "providerLoggingGroupName",
73+
"targetName": "STACK",
74+
"targetType": "STACK",
75+
"targetLogicalId": "myStack",
76+
"hookEncryptionKeyArn": None,
77+
"hookEncryptionKeyRole": None,
78+
"payload": "https://someS3PresignedURL",
79+
"targetModel": {},
80+
},
81+
"stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e"
82+
"722ae60-fe62-11e8-9a0e-0ae8cc519968",
83+
"hookModel": sentinel.type_configuration,
84+
}
85+
5386

5487
TYPE_NAME = "Test::Foo::Bar"
5588

@@ -452,7 +485,78 @@ def test_test_entrypoint_success():
452485
(OperationStatus.IN_PROGRESS, HookStatus.IN_PROGRESS),
453486
(OperationStatus.SUCCESS, HookStatus.SUCCESS),
454487
(OperationStatus.FAILED, HookStatus.FAILED),
488+
(
489+
OperationStatus.CHANGE_SET_SUCCESS_SKIP_STACK_HOOK,
490+
HookStatus.CHANGE_SET_SUCCESS_SKIP_STACK_HOOK,
491+
),
455492
],
456493
)
457494
def test_get_hook_status(operation_status, hook_status):
458495
assert hook_status == Hook._get_hook_status(operation_status)
496+
497+
498+
def test__hook_request_data_remote_payload():
499+
non_remote_input = HookRequestData(
500+
targetName="someTargetName",
501+
targetType="someTargetModel",
502+
targetLogicalId="someTargetLogicalId",
503+
targetModel={"resourceProperties": {"propKeyA": "propValueA"}},
504+
)
505+
assert non_remote_input.is_hook_invocation_payload_remote() is False
506+
507+
non_remote_input_1 = HookRequestData(
508+
targetName="someTargetName",
509+
targetType="someTargetModel",
510+
targetLogicalId="someTargetLogicalId",
511+
targetModel={"resourceProperties": {"propKeyA": "propValueA"}},
512+
payload="https://someUrl",
513+
)
514+
assert non_remote_input_1.is_hook_invocation_payload_remote() is False
515+
516+
remote_input = HookRequestData(
517+
targetName="someTargetName",
518+
targetType="someTargetModel",
519+
targetLogicalId="someTargetLogicalId",
520+
targetModel={},
521+
payload="https://someUrl",
522+
)
523+
assert remote_input.is_hook_invocation_payload_remote() is True
524+
525+
526+
def test__test_stack_level_hook_input(hook):
527+
hook = Hook(TYPE_NAME, Mock())
528+
529+
with patch(
530+
"cloudformation_cli_python_lib.utils.requests.Session.get"
531+
) as mock_requests_lib:
532+
mock_requests_lib.return_value = MockResponse(200, {"foo": "bar"})
533+
_, _, _, req = hook._parse_request(STACK_LEVEL_HOOK_ENTRYPOINT_PAYLOAD)
534+
535+
assert req.requestData.targetName == "STACK"
536+
assert req.requestData.targetType == "STACK"
537+
assert req.requestData.targetLogicalId == "myStack"
538+
assert req.requestData.targetModel == {"foo": "bar"}
539+
540+
541+
def test__test_stack_level_hook_input_failed_s3_download(hook):
542+
hook = Hook(TYPE_NAME, Mock())
543+
544+
with patch(
545+
"cloudformation_cli_python_lib.utils.requests.Session.get"
546+
) as mock_requests_lib:
547+
mock_requests_lib.return_value = MockResponse(404, {"foo": "bar"})
548+
_, _, _, req = hook._parse_request(STACK_LEVEL_HOOK_ENTRYPOINT_PAYLOAD)
549+
550+
assert req.requestData.targetName == "STACK"
551+
assert req.requestData.targetType == "STACK"
552+
assert req.requestData.targetLogicalId == "myStack"
553+
assert req.requestData.targetModel == {}
554+
555+
556+
@dataclass
557+
class MockResponse:
558+
status_code: int
559+
_json: Mapping[str, Any]
560+
561+
def json(self) -> Mapping[str, Any]:
562+
return self._json

tests/lib/interface_test.py

+2
Original file line numberDiff line numberDiff line change
@@ -188,4 +188,6 @@ def test_hook_progress_event_serialize_to_response_with_error_code(message):
188188
def test_operation_status_enum_matches_sdk(client):
189189
sdk = set(client.meta.service_model.shape_for("OperationStatus").enum)
190190
enum = set(OperationStatus.__members__)
191+
# CHANGE_SET_SUCCESS_SKIP_STACK_HOOK is a status specific to Hooks
192+
enum.remove("CHANGE_SET_SUCCESS_SKIP_STACK_HOOK")
191193
assert enum == sdk

0 commit comments

Comments
 (0)