Skip to content

Commit 8c1494e

Browse files
authored
Add support for Hook Annotations. (#277)
1 parent 21a6eba commit 8c1494e

File tree

7 files changed

+252
-4
lines changed

7 files changed

+252
-4
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ repos:
5050
hooks:
5151
- id: bandit
5252
files: ^(src|python)/
53+
additional_dependencies: [pbr]
5354
- repo: https://github.com/pre-commit/mirrors-mypy
5455
rev: v1.7.0
5556
hooks:

src/cloudformation_cli_python_lib/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
BaseHookHandlerRequest,
88
BaseResourceHandlerRequest,
99
HandlerErrorCode,
10+
HookAnnotation,
11+
HookAnnotationSeverityLevel,
12+
HookAnnotationStatus,
1013
HookContext,
1114
HookInvocationPoint,
1215
HookProgressEvent,

src/cloudformation_cli_python_lib/hook.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ def _create_progress_response(
283283
response.errorCode = progress_event.errorCode
284284
if request:
285285
response.clientRequestToken = request.get("clientRequestToken")
286+
response.annotations = progress_event.annotations
286287
return response
287288

288289
@staticmethod

src/cloudformation_cli_python_lib/interface.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,20 @@ class HookStatus(str, _AutoName):
5858
FAILED = auto()
5959

6060

61+
class HookAnnotationStatus(str, _AutoName):
62+
PASSED = auto()
63+
FAILED = auto()
64+
SKIPPED = auto()
65+
66+
67+
class HookAnnotationSeverityLevel(str, _AutoName):
68+
INFORMATIONAL = auto()
69+
LOW = auto()
70+
MEDIUM = auto()
71+
HIGH = auto()
72+
CRITICAL = auto()
73+
74+
6175
class HandlerErrorCode(str, _AutoName):
6276
NotUpdatable = auto()
6377
InvalidRequest = auto()
@@ -105,6 +119,26 @@ def _deserialize(
105119
raise NotImplementedError()
106120

107121

122+
@dataclass
123+
class HookAnnotation:
124+
annotationName: str
125+
status: HookAnnotationStatus
126+
statusMessage: Optional[str] = None
127+
remediationMessage: Optional[str] = None
128+
remediationLink: Optional[str] = None
129+
severityLevel: Optional[HookAnnotationSeverityLevel] = None
130+
131+
def _serialize(self) -> Mapping[str, Any]:
132+
ser = {k: v for k, v in self.__dict__.items() if v is not None}
133+
134+
ser["status"] = ser.pop("status").name
135+
136+
if self.severityLevel:
137+
ser["severityLevel"] = self.severityLevel.name
138+
139+
return ser
140+
141+
108142
# pylint: disable=too-many-instance-attributes
109143
@dataclass
110144
class ProgressEvent:
@@ -118,6 +152,7 @@ class ProgressEvent:
118152
resourceModel: Optional[BaseModel] = None
119153
resourceModels: Optional[List[BaseModel]] = None
120154
nextToken: Optional[str] = None
155+
annotations: Optional[List[HookAnnotation]] = None
121156

122157
def _serialize(self) -> MutableMapping[str, Any]:
123158
# to match Java serialization, which drops `null` values, and the
@@ -126,6 +161,10 @@ def _serialize(self) -> MutableMapping[str, Any]:
126161

127162
# mutate to what's expected in the response
128163

164+
# removing hook response only fields
165+
ser.pop("result", None)
166+
ser.pop("annotations", None)
167+
129168
ser["status"] = ser.pop("status").name
130169

131170
if self.resourceModel:
@@ -184,25 +223,39 @@ class HookProgressEvent:
184223
callbackDelaySeconds: int = 0
185224
result: Optional[str] = None
186225
clientRequestToken: Optional[str] = None
226+
annotations: Optional[List[HookAnnotation]] = None
187227

188228
def _serialize(self) -> MutableMapping[str, Any]:
189229
# to match Java serialization, which drops `null` values, and the
190230
# contract tests currently expect this also
191231
ser = {k: v for k, v in self.__dict__.items() if v is not None}
192232

193233
# mutate to what's expected in the response
194-
195234
ser["hookStatus"] = ser.pop("hookStatus").name
196235

236+
if self.annotations:
237+
ser["annotations"] = [
238+
annotation._serialize() # pylint: disable=protected-access
239+
for annotation in self.annotations
240+
]
197241
if self.errorCode:
198242
ser["errorCode"] = self.errorCode.name
243+
199244
return ser
200245

201246
@classmethod
202247
def failed(
203-
cls: Type["HookProgressEvent"], error_code: HandlerErrorCode, message: str = ""
248+
cls: Type["HookProgressEvent"],
249+
error_code: HandlerErrorCode,
250+
message: str = "",
251+
annotations: Optional[List[HookAnnotation]] = None,
204252
) -> "HookProgressEvent":
205-
return cls(hookStatus=HookStatus.FAILED, errorCode=error_code, message=message)
253+
return cls(
254+
hookStatus=HookStatus.FAILED,
255+
errorCode=error_code,
256+
message=message,
257+
annotations=annotations,
258+
)
206259

207260

208261
@dataclass

src/cloudformation_cli_python_lib/resource.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,8 @@ def print_or_log(message: str) -> None:
235235

236236
if progress.result: # pragma: no cover
237237
progress.result = None
238+
if progress.annotations: # pragma: no cover
239+
progress.annotations = None
238240

239241
# use the raw event_data as a last-ditch attempt to call back if the
240242
# request is invalid

src/setup.py

Lines changed: 1 addition & 1 deletion
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.19",
6+
version="2.1.20",
77
description=__doc__,
88
author="Amazon Web Services",
99
author_email="aws-cloudformation-developers@amazon.com",

tests/lib/interface_test.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@
66
from cloudformation_cli_python_lib.interface import (
77
BaseModel,
88
HandlerErrorCode,
9+
HookAnnotation,
10+
HookAnnotationSeverityLevel,
11+
HookAnnotationStatus,
912
HookProgressEvent,
1013
HookStatus,
1114
OperationStatus,
1215
ProgressEvent,
1316
)
17+
from cloudformation_cli_python_lib.utils import KitchenSinkEncoder
1418

1519
import hypothesis.strategies as s # pylint: disable=C0411
1620
import json
@@ -140,6 +144,80 @@ def test_hook_progress_event_failed_is_json_serializable(error_code, message):
140144
}
141145

142146

147+
@given(
148+
s.sampled_from(HandlerErrorCode),
149+
s.text(ascii_letters),
150+
s.sampled_from(HookAnnotationSeverityLevel),
151+
)
152+
def test_hook_progress_event_failed_with_annotations_is_json_serializable(
153+
error_code,
154+
message,
155+
annotation_severity_level,
156+
):
157+
event = HookProgressEvent(
158+
hookStatus=OperationStatus.FAILED,
159+
message=message,
160+
errorCode=error_code,
161+
annotations=[
162+
HookAnnotation(
163+
annotationName="test_annotation_name_1",
164+
status=HookAnnotationStatus.FAILED,
165+
statusMessage="test_status_message_1",
166+
remediationMessage="test_remediation_message",
167+
remediationLink="https://localhost/test-1",
168+
severityLevel=annotation_severity_level,
169+
),
170+
HookAnnotation(
171+
annotationName="test_annotation_name_2",
172+
status=HookAnnotationStatus.PASSED,
173+
statusMessage="test_status_message_2",
174+
),
175+
],
176+
)
177+
178+
assert event.hookStatus == HookStatus.FAILED
179+
assert event.errorCode == error_code
180+
assert event.message == message
181+
182+
assert event.annotations[0].annotationName == "test_annotation_name_1"
183+
assert event.annotations[0].status == HookAnnotationStatus.FAILED.name
184+
assert event.annotations[0].statusMessage == "test_status_message_1"
185+
assert event.annotations[0].remediationMessage == "test_remediation_message"
186+
assert event.annotations[0].remediationLink == "https://localhost/test-1"
187+
assert event.annotations[0].severityLevel == annotation_severity_level
188+
189+
assert event.annotations[1].annotationName == "test_annotation_name_2"
190+
assert event.annotations[1].status == HookAnnotationStatus.PASSED.name
191+
assert event.annotations[1].statusMessage == "test_status_message_2"
192+
193+
assert json.loads(
194+
json.dumps(
195+
event._serialize(),
196+
cls=KitchenSinkEncoder,
197+
)
198+
) == {
199+
"hookStatus": HookStatus.FAILED.value,
200+
"errorCode": error_code.value,
201+
"message": message,
202+
"callbackDelaySeconds": 0,
203+
"annotations": [
204+
{
205+
"annotationName": "test_annotation_name_1",
206+
"status": "FAILED",
207+
"statusMessage": "test_status_message_1",
208+
"remediationMessage": "test_remediation_message",
209+
"remediationLink": "https://localhost/test-1",
210+
"severityLevel": annotation_severity_level.name,
211+
},
212+
{
213+
"annotationName": "test_annotation_name_2",
214+
"status": "PASSED",
215+
"statusMessage": "test_status_message_2",
216+
},
217+
],
218+
}
219+
220+
143221
@given(s.text(ascii_letters))
144222
def test_hook_progress_event_serialize_to_response_with_context(message):
145223
event = HookProgressEvent(
@@ -154,6 +232,43 @@ def test_hook_progress_event_serialize_to_response_with_context(message):
154232
}
155233

156234

235+
@given(s.text(ascii_letters))
236+
def test_hook_progress_event_serialize_to_response_with_context_with_annotation(
237+
message,
238+
):
239+
event = HookProgressEvent(
240+
hookStatus=HookStatus.SUCCESS,
241+
message=message,
242+
callbackContext={"a": "b"},
243+
annotations=[
244+
HookAnnotation(
245+
annotationName="test_annotation_name",
246+
status=HookAnnotationStatus.PASSED,
247+
statusMessage="test_status_message",
248+
),
249+
],
250+
)
251+
252+
assert json.loads(
253+
json.dumps(
254+
event._serialize(),
255+
cls=KitchenSinkEncoder,
256+
)
257+
) == {
258+
"hookStatus": HookStatus.SUCCESS.name, # pylint: disable=no-member
259+
"message": message,
260+
"callbackContext": {"a": "b"},
261+
"callbackDelaySeconds": 0,
262+
"annotations": [
263+
{
264+
"annotationName": "test_annotation_name",
265+
"status": "PASSED",
266+
"statusMessage": "test_status_message",
267+
},
268+
],
269+
}
270+
271+
157272
@given(s.text(ascii_letters))
158273
def test_hook_progress_event_serialize_to_response_with_data(message):
159274
result = "My hook data"
@@ -169,6 +284,42 @@ def test_hook_progress_event_serialize_to_response_with_data(message):
169284
}
170285

171286

287+
@given(s.text(ascii_letters))
288+
def test_hook_progress_event_serialize_to_response_with_data_with_annotation(message):
289+
result = "My hook data"
290+
event = HookProgressEvent(
291+
hookStatus=HookStatus.SUCCESS,
292+
message=message,
293+
result=result,
294+
annotations=[
295+
HookAnnotation(
296+
annotationName="test_annotation_name",
297+
status=HookAnnotationStatus.PASSED,
298+
statusMessage="test_status_message",
299+
),
300+
],
301+
)
302+
303+
assert json.loads(
304+
json.dumps(
305+
event._serialize(),
306+
cls=KitchenSinkEncoder,
307+
)
308+
) == {
309+
"hookStatus": HookStatus.SUCCESS.name, # pylint: disable=no-member
310+
"message": message,
311+
"callbackDelaySeconds": 0,
312+
"result": result,
313+
"annotations": [
314+
{
315+
"annotationName": "test_annotation_name",
316+
"status": "PASSED",
317+
"statusMessage": "test_status_message",
318+
},
319+
],
320+
}
321+
322+
172323
@given(s.text(ascii_letters))
173324
def test_hook_progress_event_serialize_to_response_with_error_code(message):
174325
event = HookProgressEvent(
@@ -185,6 +336,43 @@ def test_hook_progress_event_serialize_to_response_with_error_code(message):
185336
}
186337

187338

339+
@given(s.text(ascii_letters))
340+
def test_hook_progress_event_serialize_to_response_with_error_code_with_annotation(
341+
message,
342+
):
343+
event = HookProgressEvent(
344+
hookStatus=HookStatus.FAILED,
345+
message=message,
346+
errorCode=HandlerErrorCode.InvalidRequest,
347+
annotations=[
348+
HookAnnotation(
349+
annotationName="test_annotation_name",
350+
status=HookAnnotationStatus.FAILED,
351+
statusMessage="test_status_message",
352+
),
353+
],
354+
)
355+
356+
assert json.loads(
357+
json.dumps(
358+
event._serialize(),
359+
cls=KitchenSinkEncoder,
360+
)
361+
) == {
362+
"hookStatus": HookStatus.FAILED.name, # pylint: disable=no-member
363+
"message": message,
364+
"errorCode": HandlerErrorCode.InvalidRequest.name, # pylint: disable=no-member
365+
"callbackDelaySeconds": 0,
366+
"annotations": [
367+
{
368+
"annotationName": "test_annotation_name",
369+
"status": "FAILED",
370+
"statusMessage": "test_status_message",
371+
},
372+
],
373+
}
374+
375+
188376
def test_operation_status_enum_matches_sdk(client):
189377
sdk = set(client.meta.service_model.shape_for("OperationStatus").enum)
190378
enum = set(OperationStatus.__members__)

0 commit comments

Comments
 (0)