cloud-radar 0.11.1a4__py3-none-any.whl → 0.12.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,6 @@
1
+ from ._hooks import ResourceHookContext
2
+ from ._resource import Resource
1
3
  from ._stack import Stack
2
4
  from ._template import Template
3
5
 
4
- __all__ = ["Template", "Stack"]
6
+ __all__ = ["Template", "Stack", "Resource", "ResourceHookContext"]
@@ -0,0 +1,199 @@
1
+ from dataclasses import dataclass
2
+
3
+ # Work around some circular import issue until someone smarter
4
+ # can work out the right way to restructure / refactor this
5
+ # Solution from https://stackoverflow.com/a/39757388/230449
6
+ from typing import TYPE_CHECKING, Callable, Dict, List, Union
7
+
8
+ from ._resource import Resource
9
+ from ._stack import Stack
10
+
11
+ if TYPE_CHECKING:
12
+ from ._template import Template
13
+
14
+
15
+ @dataclass
16
+ class ResourceHookContext:
17
+ """Class that contains the context for a resource hook to evaluate.
18
+
19
+ Attributes:
20
+ logical_id (str): The logical ID for the resource in the CloudFormation
21
+ template which is to be evaluated.
22
+ resource_definition (Resource): The definition of the resource to be evaluated.
23
+ stack (Stack): the rendered stack that the resource is part of
24
+ template (Template): the template that is being rendered to produce the stack
25
+ """
26
+
27
+ logical_id: str
28
+ resource_definition: Resource
29
+ stack: Stack
30
+ template: "Template"
31
+
32
+
33
+ @dataclass
34
+ class ResourceHookCollection:
35
+ """
36
+ Class that holds the two collections of hooks that we can evaluate against
37
+ individual Resource items in a rendered stack.
38
+
39
+ Each Callable is expected to take in a single parameter - an instance of
40
+ ResourceHookContext.
41
+
42
+ Attributes:
43
+ plugin (Dict[str, List[Callable]]): The dict of Resource Type to
44
+ list of hooks which were loaded from plugins.
45
+ local (Dict[str, List[Callable]]): The dict of Resource Type to
46
+ list of hooks which were defined with the template.
47
+ """
48
+
49
+ plugin: Dict[str, List[Callable]]
50
+ local: Dict[str, List[Callable]]
51
+
52
+
53
+ @dataclass
54
+ class TemplateHookCollection:
55
+ """
56
+ Class that holds the two collections of hooks that we can evaluate
57
+ against a loaded Template.
58
+
59
+ Each Callable is expected to take in a single parameter - an
60
+ instance of Template.
61
+
62
+ Attributes:
63
+ plugin (List[Callable]): The list of hooks which were loaded from plugins.
64
+ local (List[Callable]): The list of hooks which were defined with the template.
65
+ """
66
+
67
+ plugin: List[Callable]
68
+ local: List[Callable]
69
+
70
+
71
+ class HookProcessor:
72
+ """
73
+ Class that handles holding and evaluating the collections of hooks that we run
74
+ against Templates and Resources.
75
+
76
+ In future this will include loading hooks from plugins, but that has not been
77
+ implemented yet.
78
+
79
+ This supports suppressing rules based on Metadata at either the Template or
80
+ Resource level, using something like this:
81
+
82
+ Metadata:
83
+ Cloud-Radar:
84
+ ignore-hooks:
85
+ - s3_check_bucket_name_region
86
+
87
+ To allow this to work, you should ensure that the function names that you implement
88
+ for hooks have unique and descriptive names.
89
+
90
+ """
91
+
92
+ def __init__(self) -> None:
93
+ self._resources: ResourceHookCollection = ResourceHookCollection(
94
+ plugin={}, local={}
95
+ )
96
+ self._template: TemplateHookCollection = TemplateHookCollection(
97
+ plugin=[], local=[]
98
+ )
99
+ # TODO: Add support for loading plugin hooks
100
+
101
+ @property
102
+ def template(self):
103
+ return self._template.local
104
+
105
+ @template.setter
106
+ def template(self, value: List[Callable]):
107
+ self._template.local = value
108
+
109
+ @property
110
+ def resources(self):
111
+ return self._resources.local
112
+
113
+ @resources.setter
114
+ def resources(self, value: Dict[str, List[Callable]]):
115
+ self._resources.local = value
116
+
117
+ def _is_hook_suppressed_in_dict(self, hook_name: str, metadata: Dict) -> bool:
118
+ cloud_radar_metadata = metadata.get("Cloud-Radar", {})
119
+ ignored_hooks = cloud_radar_metadata.get("ignore-hooks", {})
120
+
121
+ return hook_name in ignored_hooks
122
+
123
+ def _is_hook_suppressed(
124
+ self, hook_name, template: "Template", resource: Union[Resource, None] = None
125
+ ) -> bool:
126
+ # First check if a suppression exists in the template,
127
+ # as that will take precedence.
128
+ hook_suppressed = self._is_hook_suppressed_in_dict(
129
+ hook_name, template.template.get("Metadata", {})
130
+ )
131
+
132
+ if not hook_suppressed and resource:
133
+ # if not suppression was found at the template level, check the
134
+ # resource level
135
+ hook_suppressed = self._is_hook_suppressed_in_dict(
136
+ hook_name, resource.get("Metadata", {})
137
+ )
138
+
139
+ return hook_suppressed
140
+
141
+ def _evaluate_template_hooks(
142
+ self, hook_type: str, hooks: List[Callable], template: "Template"
143
+ ) -> None:
144
+ for single_hook in hooks:
145
+ # Only process the hook if it has not been marked as to be
146
+ # ignored
147
+ if not self._is_hook_suppressed(single_hook.__name__, template, None):
148
+ print(f"Processing {hook_type} hook {single_hook.__name__}")
149
+
150
+ single_hook(template=template)
151
+
152
+ def _evaluate_resource_hooks(
153
+ self,
154
+ hook_type: str,
155
+ hooks: Dict[str, List[Callable]],
156
+ stack: Stack,
157
+ template: "Template",
158
+ ) -> None:
159
+ # Iterate through the resources in the rendered stack
160
+ for logical_id in stack.data.get("Resources", {}):
161
+ print("Got resource " + logical_id)
162
+ resource_definition = stack.get_resource(logical_id)
163
+
164
+ resource_type = resource_definition.get("Type")
165
+
166
+ # Get the hooks that have been defined for this type of resource
167
+ type_hooks = hooks.get(resource_type, [])
168
+
169
+ hook_context = ResourceHookContext(
170
+ logical_id=logical_id,
171
+ resource_definition=resource_definition,
172
+ stack=stack,
173
+ template=template,
174
+ )
175
+
176
+ # Iterate through each defined hook and call them.
177
+ for single_hook in type_hooks:
178
+ # Only process the hook if it has not been marked as to be
179
+ # ignored
180
+ if not self._is_hook_suppressed(
181
+ single_hook.__name__, template, resource_definition
182
+ ):
183
+ print(f"Processing {hook_type} hook {single_hook.__name__}")
184
+
185
+ single_hook(context=hook_context)
186
+
187
+ def evaluate_resource_hooks(self, stack: Stack, template: "Template") -> None:
188
+ # Evaluate the global hooks first, then the local ones
189
+ self._evaluate_resource_hooks("plugin", self._resources.plugin, stack, template)
190
+ self._evaluate_resource_hooks("local", self._resources.local, stack, template)
191
+
192
+ def evaluate_template_hooks(self, template: "Template") -> None:
193
+ print(template)
194
+ # raise ValueError(type(template))
195
+ # raise ValueError(template.template)
196
+
197
+ # Evaluate the global hooks first, then the local ones
198
+ self._evaluate_template_hooks("plugin", self._template.plugin, template)
199
+ self._evaluate_template_hooks("local", self._template.local, template)
@@ -9,6 +9,7 @@ import yaml # noqa: I100
9
9
  from cfn_tools import dump_yaml, load_yaml # type: ignore # noqa: I100, I201
10
10
 
11
11
  from . import functions
12
+ from ._hooks import HookProcessor
12
13
  from ._stack import Stack
13
14
 
14
15
  IntrinsicFunc = Callable[["Template", Any], Any]
@@ -20,6 +21,7 @@ class Template:
20
21
  """
21
22
 
22
23
  AccountId: str = "5" * 12
24
+ Hooks = HookProcessor()
23
25
  NotificationARNs: list = []
24
26
  NoValue: str = "" # Not yet implemented
25
27
  Partition: str = "aws" # Other regions not implemented
@@ -79,6 +81,10 @@ class Template:
79
81
  "Transform", None
80
82
  )
81
83
 
84
+ # All loaded, validate against any template level hooks
85
+ # that have been configured
86
+ self.Hooks.evaluate_template_hooks(self)
87
+
82
88
  @classmethod
83
89
  def from_yaml(
84
90
  cls,
@@ -317,6 +323,9 @@ class Template:
317
323
 
318
324
  stack = Stack(self.template)
319
325
 
326
+ # Evaluate any hooks prior to returning this stack
327
+ self.Hooks.evaluate_resource_hooks(stack, self)
328
+
320
329
  return stack
321
330
 
322
331
  def resolve_values( # noqa: max-complexity: 13
@@ -596,7 +605,12 @@ def validate_aws_parameter_constraints(
596
605
 
597
606
  # There are a few variants of SSM parameters, but they all have the
598
607
  # same regex pattern
599
- ssm_parameter_value_regex = r"^(/{0,1}(?!/))[A-Za-z0-9/-_]+(.([a-zA-Z]+))?$"
608
+ #
609
+ # This is based on the documentation for the PutParameter API operation
610
+ # https://docs.aws.amazon.com/systems-manager/latest/APIReference/
611
+ # API_PutParameter.html#systemsmanager-PutParameter-request-Name
612
+ #
613
+ ssm_parameter_value_regex = r"^([/]{0,1}[a-zA-Z0-9_.-]*){1,15}$"
600
614
 
601
615
  if parameter_type.startswith("AWS::SSM::Parameter::Value<"):
602
616
  # SSM parameter, need to validate that the type in the angle brackets
@@ -640,7 +654,7 @@ def validate_aws_parameter_constraints(
640
654
  raise ValueError(
641
655
  (
642
656
  f"Value {parameter_value} does not match the expected pattern "
643
- f"for SSM parameter {parameter_name}"
657
+ f"for SSM parameter {parameter_name}."
644
658
  )
645
659
  )
646
660
 
@@ -784,7 +798,7 @@ def validate_string_parameter_constraints(
784
798
 
785
799
  def add_metadata(template: Dict, region: str) -> None:
786
800
  """This functions adds the current region to the template
787
- as metadate because we can't treat Region like a normal pseduo
801
+ as metadata because we can't treat Region like a normal pseudo
788
802
  variables because we don't want to update the class var for every run.
789
803
 
790
804
  Args:
@@ -792,12 +806,16 @@ def add_metadata(template: Dict, region: str) -> None:
792
806
  region (str): The region that template will be tested with.
793
807
  """
794
808
 
795
- metadata = {"Cloud-Radar": {"Region": region}}
796
-
797
809
  if "Metadata" not in template:
798
810
  template["Metadata"] = {}
799
811
 
800
- template["Metadata"].update(metadata)
812
+ # Get the existing metadata (so we do not overwrite any
813
+ # hook suppressions), then set the region into it before
814
+ # updating the template
815
+ cloud_radar_metadata = template["Metadata"].get("Cloud-Radar", {})
816
+ cloud_radar_metadata["Region"] = region
817
+
818
+ template["Metadata"]["Cloud-Radar"] = cloud_radar_metadata
801
819
 
802
820
 
803
821
  # All the other Cloudformation intrinsic functions start with `Fn:` but for some reason
@@ -830,17 +830,21 @@ def ref(template: "Template", var_name: str) -> Any:
830
830
 
831
831
  if "Parameters" in template.template:
832
832
  if var_name in template.template["Parameters"]:
833
+ # This is a reference to a parameter
834
+
833
835
  param_def = template.template["Parameters"][var_name]
834
- if "Type" in param_def and param_def["Type"].startswith(
835
- "AWS::SSM::Parameter::Value<"
836
- ):
836
+ param_type = param_def.get("Type", "")
837
+ param_value = param_def["Value"]
838
+
839
+ if param_type.startswith("AWS::SSM::Parameter::Value<"):
837
840
  # This is an SSM parameter value, look it up from our dynamic references
838
- return template._get_dynamic_reference_value(
839
- "ssm", template.template["Parameters"][var_name]["Value"]
840
- )
841
+ return template._get_dynamic_reference_value("ssm", param_value)
842
+ if param_type == "CommaDelimitedList" or param_type.startswith("List<"):
843
+ # Return the value split into a list of strings
844
+ return param_value.split(",")
841
845
 
842
846
  # If we get this far, regular parameter value to lookup & return
843
- return template.template["Parameters"][var_name]["Value"]
847
+ return param_value
844
848
 
845
849
  if var_name in template.template["Resources"]:
846
850
  return var_name
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cloud-radar
3
- Version: 0.11.1a4
3
+ Version: 0.12.1
4
4
  Summary: Run functional tests on cloudformation stacks.
5
5
  Home-page: https://github.com/DontShaveTheYak/cloud-radar
6
6
  License: Apache-2.0
@@ -114,6 +114,8 @@ You can Test:
114
114
 
115
115
  You can test all this locally without worrying about AWS Credentials.
116
116
 
117
+ A number of these tests can be configured in a common way to apply to all templates through the use of the [hooks](./examples/unit/hooks/README.md) functionality.
118
+
117
119
  ### Functional Testing
118
120
 
119
121
  This project is a wrapper around Taskcat. Taskcat is a great tool for ensuring your Cloudformation Template can be deployed in multiple AWS Regions. Cloud-Radar enhances Taskcat by making it easier to write more complete functional tests.
@@ -197,7 +199,7 @@ The default values for pseudo parameters:
197
199
  | **StackId** | "" |
198
200
  | **StackName** | "" |
199
201
  | **URLSuffix** | "amazonaws.com" |
200
- _Note: Bold variables are not fully impletmented yet see the [Roadmap](#roadmap)_
202
+ _Note: Bold variables are not fully implemented yet see the [Roadmap](#roadmap)_
201
203
 
202
204
  At the point of creating the `Template` instance additional configuration is required to be provided if you are using certain approaches to resolving values.
203
205
 
@@ -2,16 +2,17 @@ cloud_radar/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  cloud_radar/cf/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  cloud_radar/cf/e2e/__init__.py,sha256=83Bf_ftCxMimG1mxQH0xsKFCmsrQ7YJEAQkeZtJIxL0,47
4
4
  cloud_radar/cf/e2e/_stack.py,sha256=Qm5vQsUSUluHirTFTs_n40yI6Z8-HMZ837OvSJcyWqM,1676
5
- cloud_radar/cf/unit/__init__.py,sha256=51z7nRi_PhTkagVzJOgXth1bm3zvWoBlT9D0I3VGxdY,91
5
+ cloud_radar/cf/unit/__init__.py,sha256=H9iQw4PSZEW-u0Ey-Z8eZfzQ6GJ7wdxmkKE8Z09U7pU,198
6
6
  cloud_radar/cf/unit/_condition.py,sha256=e7gwcz87yF6dKl2quxmOmjzHku8OW6EWAsgbCeDMARg,711
7
+ cloud_radar/cf/unit/_hooks.py,sha256=3_uMDzIs6rt_TZfP5-Qz5gIBmJo8rxd2GBBXxTVZsa0,6914
7
8
  cloud_radar/cf/unit/_output.py,sha256=rhQQ4aJu06bxAoG7GOXkuOPzAmLCrTA2Ytb8rNXA4Vg,1822
8
9
  cloud_radar/cf/unit/_parameter.py,sha256=AdPIqc2ggSW1VR0USF30zjphX3z1HHbGKQt8n-Kzhfo,3199
9
10
  cloud_radar/cf/unit/_resource.py,sha256=dWaR-5s6ea5mIu6Dhf1hY31Wd4WLHbHsbyxnu2Tz6QI,8512
10
11
  cloud_radar/cf/unit/_stack.py,sha256=_S0L9O7Lw-QAJDKubClp2b6UYtYfyzg272_7WQkUdo8,5785
11
- cloud_radar/cf/unit/_template.py,sha256=geGy8fiO6esi6a3iwwpUTEJy04WivLYzsVSBCjCO7aE,31463
12
- cloud_radar/cf/unit/functions.py,sha256=KPPPNLrXzYMi5dtsXkvi0RvaaeAnvBC_2MhqHPs1lHo,28329
12
+ cloud_radar/cf/unit/_template.py,sha256=u1r1RkUPI0t8SGolWEjb3doavFwUQRaE0wOKCi_ijiI,32242
13
+ cloud_radar/cf/unit/functions.py,sha256=k1i12FDChgLwGsG-3q9kdYbuEOgnMEs0qPynp-YcZAM,28494
13
14
  cloud_radar/cf/unit/test__template.py,sha256=jVPMJTn6Q0sSZ8BjRGyutuR9-NjdHdwDTVsd2kvjQbs,1491
14
- cloud_radar-0.11.1a4.dist-info/LICENSE.txt,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
15
- cloud_radar-0.11.1a4.dist-info/METADATA,sha256=AVbK7qWXsXhGZUSGt4Ww0x7TZjVB8qFnmwg9vCPL4F8,15366
16
- cloud_radar-0.11.1a4.dist-info/WHEEL,sha256=7Z8_27uaHI_UZAc4Uox4PpBhQ9Y5_modZXWMxtUi4NU,88
17
- cloud_radar-0.11.1a4.dist-info/RECORD,,
15
+ cloud_radar-0.12.1.dist-info/LICENSE.txt,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
16
+ cloud_radar-0.12.1.dist-info/METADATA,sha256=vwvpMBogg8baN7ninA1rMU_jFP7lpotb_xbhkYY9tu8,15527
17
+ cloud_radar-0.12.1.dist-info/WHEEL,sha256=7Z8_27uaHI_UZAc4Uox4PpBhQ9Y5_modZXWMxtUi4NU,88
18
+ cloud_radar-0.12.1.dist-info/RECORD,,