Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/hook plugin support #342

Draft
wants to merge 14 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 54 additions & 12 deletions examples/unit/hooks/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# What do these examples cover?

The two subdirectories here, `resources` and `template`, show the two types of "hooks" that are available for defining common checks.
The `template` and `resources` subdirectories show the two types of "hooks" that are available for defining common checks.

The "hooks" functionality allows you to configure standardised tests once (for example through your `pytest` `conftest.py` file, possibly even using functions from an external library), and then each test stack you create will have your tests applied to them at the point the stack is rendered. In future it is planned to support a plugin system to discover these hooks through installed packages to simplify this configuration.
This "hooks" functionality allows you to configure standardised tests once, and then have them ran each time your load a template or render a stack (depending on the type). These are intended to either be defined at one point in your repository, for example through fixtures in your `pytest` `conftest.py` file, or can be defined through an external library to allow reuse across all your repositories.

Hooks can be suppressed using CloudFormation MetaData (in a similar fashion to other tools like cfn-lint), at either the template or resource level. The expected format is as follows, where the items in the list are the function names of the configured hooks.
Hooks can be suppressed using CloudFormation MetaData (in a similar fashion to other tools like cfn-lint), at either the template or resource level. The expected format is as follows, where the items in the list are the function names of the configured hooks. As the name of your functions are used as the hook name in assertion messages and for the purposes of suppressions, it is recommended to try to keep them unique within your code.

```
Metadata:
Expand All @@ -13,7 +13,9 @@ Hooks can be suppressed using CloudFormation MetaData (in a similar fashion to o
- my_s3_encryption_hook
```

# Template Hooks
# Hook Types

## Template Hooks

Template hooks are evaluated at the point that the CloudFormation template is loaded in to Cloud-Radar by calling `Template.from_yaml` function. These are designed for performing template level checks that do not depend on the template having any processing performed on it for parameter/condition resolution etc.

Expand All @@ -23,7 +25,7 @@ The types of scenarios this could be used for include:

A basic example is included in the [template/test_hooks_template.py](./template/test_hooks_template.py) file.

## Defining a Hook
### Defining a Hook function

Template hooks are functions that take in a single parameter, `template`. These are expected to raise an error if their check does not pass.

Expand All @@ -46,25 +48,23 @@ def _object_prefix_check(items: List[str], expected_prefix: str):
)
```

The name of your function is used as the hook name in assertion messages and for the purposes of suppressions, so you should try to keep them unique within your code.

## Configuring a Hook

This type of hooks are set as a list of functions on the Template object.
When being used at the repository level (and not discovered via a plugin), this type of hook are set as a list of functions on the Template object.

```
Template.Hooks.template = [ my_parameter_prefix_checks ]
```

# Resource Hooks

## Resource Hooks

Resource hooks are evaluated at the point of the stack being rendered by calling `template.create_stack`. These hooks are designed to be able to check aspects of the template *after* items like parameter substitution and conditions have been applied.

These are intended for much more in depth checks, specific to the type of a resource. The initial inception of this feature was to support naming convention checks, but equally can be expanded into the sorts of compliance tests that [CloudFormation Guard](https://docs.aws.amazon.com/cfn-guard/latest/ug/what-is-guard.html) is used for if you prefer to code rules in Python as opposed to the Guard DSL.

A basic example is included in the [resources/test_hooks_resources.py](./resources/test_hooks_resources.py) file.

## Defining a Hook
### Defining a Hook function

Resource hooks are functions that take in a single `context` parameter. This is a `ResourceHookContext` object that contains the following:

Expand All @@ -87,7 +87,7 @@ def my_s3_encryption_hook(context: ResourceHookContext) -> None:
context.resource_definition.assert_has_property("BucketEncryption")
```

This is configured as a hook against the S3 resource type by
When being used at the repository level (and not discovered via a plugin), this type of hook are set as a list of functions on the Template object. In this example, this is configured as a hook against the S3 resource type by

```python
Template.Hooks.resources = {
Expand All @@ -97,3 +97,45 @@ Template.Hooks.resources = {
```

In this setup a dict is set with the AWS Resource type as the key, and a list of functions for the value.


# Plugin Support

As noted in the introduction, Hooks can be brought in as plugins by including a dependency on a module. This allows common hooks to be shared across your organisation (or wider). In this setup, the structure of the individual hook functions remain the same, but there is a bit of additional packaging required.

Cloud Radar discovers plugins using the [Package metadata](https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/#using-package-metadata) approach, looking for the group `cloudradar.unit.plugins.hooks`.

This is defined in the `pyproject.toml` file of your plugin project, for example:
```toml
[project.entry-points.'cloudradar.unit.plugins.hooks']
a = "cloud_radar_hook_plugin_example:ExamplePlugin"
```
Or the following if you are using Poetry as your Python build system like I am.

```toml
[tool.poetry.plugins."cloudradar.unit.plugins.hooks"]
a = "cloud_radar_hook_plugin_example:ExamplePlugin"

```

This points to a class file, which can have two functions it in (Cloud Radar does allow either of these to be optional).

```python
class ExamplePlugin():

def get_template_hooks(self) -> list:
return [
template_mappings_prefix_checks,
template_parameters_prefix_checks,
template_resources_prefix_checks,
template_outputs_prefix_checks,
]

def get_resource_hooks(self) -> dict:

return {
"AWS::S3::Bucket": [my_s3_encryption_hook]
}
```

These two functions return the same structures as we noted in the local examples that would be set on the `Template.Hooks.template` and `Template.Hooks.resources` properties.
38 changes: 36 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ classifiers = [
python = "^3.8.1"
taskcat = "^0.9.41"
cfn-flip = "^1.3.0"
importlib-metadata = "^8.0.0"

[tool.poetry.group.dev.dependencies]
pytest = "^7.0.0"
Expand Down
70 changes: 68 additions & 2 deletions src/cloud_radar/cf/unit/_hooks.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import sys
from dataclasses import dataclass

if sys.version_info < (3, 10):
from importlib_metadata import entry_points
else:
from importlib.metadata import entry_points

# Work around some circular import issue until someone smarter
# can work out the right way to restructure / refactor this
# Solution from https://stackoverflow.com/a/39757388/230449
from typing import TYPE_CHECKING, Callable, Dict, List, Union
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Union

from ._resource import Resource
from ._stack import Stack
Expand Down Expand Up @@ -96,7 +102,23 @@ def __init__(self) -> None:
self._template: TemplateHookCollection = TemplateHookCollection(
plugin=[], local=[]
)
# TODO: Add support for loading plugin hooks

# Property we will use to only attempt plugin loading once.
# We can't load plugins on creation of this class as
# we end up with a circular dependency
self._has_loaded_plugins = False
# Keep counts of the number of plugins we load that provide
# a type of hook, for informational purposes later on.
self._template_hook_plugins = 0
self._resource_hook_plugins = 0

@property
def template_hook_plugins(self):
return self._template_hook_plugins

@property
def resource_hook_plugins(self):
return self._resource_hook_plugins

@property
def template(self):
Expand All @@ -120,6 +142,50 @@ def _is_hook_suppressed_in_dict(self, hook_name: str, metadata: Dict) -> bool:

return hook_name in ignored_hooks

def _load_hooks_from_single_plugin(self, loaded_plugin: Any):
plugin_instance = loaded_plugin()

# Get the Template hooks
if callable(getattr(plugin_instance, "get_template_hooks", None)):
# Plugin has a function for template hooks, call it
template_hooks = plugin_instance.get_template_hooks()

# Merge in these hooks with any existing values (from other plugins)
self._template.plugin += template_hooks

# Increment our plugin count
self._template_hook_plugins += 1

# Get the Resource hooks
if callable(getattr(plugin_instance, "get_resource_hooks", None)):
# Plugin has a function for resource hooks, call it
resource_hooks = plugin_instance.get_resource_hooks()

# Merge in these hooks with any existing values (from other plugins)
for resource_type, hooks in resource_hooks.items():
existing_hooks = self._resources.plugin.get(resource_type, [])
existing_hooks += hooks
self._resources.plugin[resource_type] = existing_hooks

# Increment our plugin count
self._resource_hook_plugins += 1

def load_plugins(self):
if not self._has_loaded_plugins:
self._load_plugins()

def _load_plugins(self):
discovered_plugins = entry_points(group="cloudradar.unit.plugins.hooks")

for single_plugin in discovered_plugins:
# Load the module
loaded_plugin = single_plugin.load()

self._load_hooks_from_single_plugin(loaded_plugin)

# Set the marker that we have installed plugins
self._has_loaded_plugins = True

def _is_hook_suppressed(
self, hook_name, template: "Template", resource: Union[Resource, None] = None
) -> bool:
Expand Down
3 changes: 3 additions & 0 deletions src/cloud_radar/cf/unit/_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ def __init__(
"Transform", None
)

# Load any plugins, if we have not already loaded them
self.Hooks.load_plugins()

# All loaded, validate against any template level hooks
# that have been configured
self.Hooks.evaluate_template_hooks(self)
Expand Down
89 changes: 89 additions & 0 deletions tests/test_cf/test_unit/test_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from cloud_radar.cf.unit._hooks import HookProcessor


# Test the pieces of _hooks.py that can be tested independently in a unit test.
# Most of the testing of this functionality is covered through the
# examples/unit/hooks tests.
class MyFirstPlugin:
def get_template_hooks(self):
return [template_hook_one]

def get_resource_hooks(self):
return {
"AWS::S3::Bucket": [s3_hook_one],
"AWS::SES::ReceiptRule": [ses_hook_one],
}


class MySecondPlugin:
def get_template_hooks(self):
return [template_hook_two]


class MyThirdPlugin:
def get_resource_hooks(self):
return {"AWS::S3::Bucket": [s3_hook_two]}


# Tests that when multiple plugins are loaded their results are combined.
# This contains an assortment of fake plugins that implement one or both
# of the methods to provide hooks.
def test_load_hooks_from_single_plugin():
# Define the plugins
plugin_one = MyFirstPlugin
plugin_two = MySecondPlugin
plugin_three = MyThirdPlugin

# Create the hook processor and load each plugin, performing assertions along the way
hooks = HookProcessor()
hooks._load_hooks_from_single_plugin(plugin_one)

assert len(hooks._template.plugin) == 1
assert hooks.template_hook_plugins == 1
assert hooks.resource_hook_plugins == 1
# raise ValueError(type(hooks._template.plugin[0][0]))
assert template_hook_one in hooks._template.plugin

assert len(hooks._resources.plugin.get("AWS::S3::Bucket")) == 1
assert s3_hook_one in hooks._resources.plugin.get("AWS::S3::Bucket")

assert len(hooks._resources.plugin.get("AWS::SES::ReceiptRule")) == 1
assert ses_hook_one in hooks._resources.plugin.get("AWS::SES::ReceiptRule")

hooks._load_hooks_from_single_plugin(plugin_two)
assert len(hooks._template.plugin) == 2
assert hooks.template_hook_plugins == 2
assert hooks.resource_hook_plugins == 1
assert template_hook_one in hooks._template.plugin
assert template_hook_two in hooks._template.plugin

hooks._load_hooks_from_single_plugin(plugin_three)
assert len(hooks._template.plugin) == 2
assert hooks.template_hook_plugins == 2
assert hooks.resource_hook_plugins == 2
assert len(hooks._resources.plugin.get("AWS::S3::Bucket")) == 2
assert s3_hook_one in hooks._resources.plugin.get("AWS::S3::Bucket")
assert s3_hook_two in hooks._resources.plugin.get("AWS::S3::Bucket")
assert len(hooks._resources.plugin.get("AWS::SES::ReceiptRule")) == 1
assert ses_hook_one in hooks._resources.plugin.get("AWS::SES::ReceiptRule")


# Below here is functions to allow the test to work
def template_hook_one():
print("template_hook_one")


def template_hook_two():
print("template_hook_two")


def s3_hook_one():
print("s3_hook_one")


def s3_hook_two():
print("s3_hook_two")


def ses_hook_one():
print("ses_hook_one")