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

Resolve lookups in hook args #708

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## Upcoming release

- add the ability to resolve native lookups in hook args

## 1.7.0 (2019-04-07)

- Additional ECS unit tests [GH-696]
Expand Down
28 changes: 27 additions & 1 deletion docs/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,9 @@ The keyword is a list of dictionaries with the following keys:
that grants you the ability to execute a hook per environment when combined
with a variable pulled from an environment file.
**args:**
a dictionary of arguments to pass to the hook
a dictionary of arguments to pass to the hook with support for lookups.
Note that lookups that change the order of execution, like ``output``, can
only be used in a `post` hook.

An example using the *create_domain* hook for creating a route53 domain before
the build action::
Expand All @@ -258,6 +260,30 @@ should run in the environment stacker is running against::
args:
domain: mydomain.com

An example of a custom hooks using various lookups in it's arguments,
shown using a dictionary to define the hooks::

pre_build:
custom_hook1:
path: path.to.hook1.entry_point
args:
ami: ${ami [<region>@]owners:self,888888888888,amazon name_regex:server[0-9]+ architecture:i386}
user_data: ${file parameterized-64:file://some/path}
db_endpoint: ${rxref some-stack::Endpoint}
db_creds: ${ssmstore us-east-1@MyDBUser}
custom_hook2:
path: path.to.hook.entry_point
args:
bucket_name: ${dynamodb us-east-1:TestTable@TestKey:TestVal.BucketName}
files:
- ${file plain:file://some/path}

post_build:
custom_hook3:
path: path.to.hook3.entry_point
args:
nlb: ${output nlb-stack::Nlb}

Tags
----

Expand Down
1 change: 1 addition & 0 deletions stacker/actions/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from ..providers.base import Template
from stacker.hooks import utils

from ..exceptions import (
MissingParameterException,
StackDidNotChange,
Expand Down
1 change: 1 addition & 0 deletions stacker/actions/destroy.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .base import STACK_POLL_TIME
from ..exceptions import StackDoesNotExist
from stacker.hooks.utils import handle_hooks

from ..status import (
CompleteStatus,
SubmittedStatus,
Expand Down
24 changes: 23 additions & 1 deletion stacker/hooks/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
import collections
import logging

from ..exceptions import FailedVariableLookup
from ..variables import Variable, resolve_variables
from stacker.util import load_object_from_string


logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -45,8 +48,27 @@ def handle_hooks(stage, hooks, provider, context):
for hook in hooks:
data_key = hook.data_key
required = hook.required
kwargs = hook.args or {}
enabled = hook.enabled

if isinstance(hook.args, dict):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any idea how this would work with the output lookup, which changes the order of execution? I think it will likely break- especially if it's a pre_build hook that runs before the graph gets executed. Even with that, I think this is still good - we just might want to handle that case, and explicitly indicate somehow that lookups that change the order of execution can only run as post_build hooks.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested output with a post_build hook, it works as expected. However, you are right that it does not work in pre_build. It raises FailedVariableLookup and uses the closest key in args as the variable in the exception.

I added a log output that explains the use of output and similar lookups in args. With the condition used it won't trigger for failed lookups that include another exception like StackDoesNotExist. If you have a better idea, please let me know. I avoided checking the lookups provided here before trying to resolve them here to not add time. could maybe pass stage to resolve_variables/Variable.resolve() if we do want to add validation logic around it?

args = [Variable(k, v) for k, v in hook.args.items()]
try: # handling for output or similar being used in pre_build
resolve_variables(args, context, provider)
except FailedVariableLookup as err:
# pylint: disable=no-member
if 'pre' in stage and \
"NoneType" in err.message: # excludes detailed errors
logger.error("Lookups that change the order of "
"execution, like 'output', can only be "
"used in 'post_*' hooks. Please "
"ensure that the hook being used does "
"not rely on a stack, hook_data, or "
"context that does not exist yet.")
raise err
kwargs = {v.name: v.value for v in args}
else:
kwargs = hook.args or {}

if not enabled:
logger.debug("hook with method %s is disabled, skipping",
hook.path)
Expand Down
179 changes: 179 additions & 0 deletions stacker/tests/hooks/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
from __future__ import print_function
from __future__ import division
from __future__ import absolute_import
from future import standard_library
standard_library.install_aliases()

import unittest

import queue

from stacker.config import Hook
from stacker.hooks.utils import handle_hooks

from ..factories import (
mock_context,
mock_provider,
)

hook_queue = queue.Queue()


def mock_hook(*args, **kwargs):
hook_queue.put(kwargs)
return True


def fail_hook(*args, **kwargs):
return None


def exception_hook(*args, **kwargs):
raise Exception


def context_hook(*args, **kwargs):
return "context" in kwargs


def result_hook(*args, **kwargs):
return {"foo": "bar"}


def kwargs_hook(*args, **kwargs):
return kwargs


class TestHooks(unittest.TestCase):

def setUp(self):
self.context = mock_context(namespace="namespace")
self.provider = mock_provider(region="us-east-1")

def test_empty_hook_stage(self):
hooks = []
handle_hooks("fake", hooks, self.provider, self.context)
self.assertTrue(hook_queue.empty())

def test_missing_required_hook(self):
hooks = [Hook({"path": "not.a.real.path", "required": True})]
with self.assertRaises(ImportError):
handle_hooks("missing", hooks, self.provider, self.context)

def test_missing_required_hook_method(self):
hooks = [{"path": "stacker.hooks.blah", "required": True}]
with self.assertRaises(AttributeError):
handle_hooks("missing", hooks, self.provider, self.context)

def test_missing_non_required_hook_method(self):
hooks = [Hook({"path": "stacker.hooks.blah", "required": False})]
handle_hooks("missing", hooks, self.provider, self.context)
self.assertTrue(hook_queue.empty())

def test_default_required_hook(self):
hooks = [Hook({"path": "stacker.hooks.blah"})]
with self.assertRaises(AttributeError):
handle_hooks("missing", hooks, self.provider, self.context)

def test_valid_hook(self):
hooks = [
Hook({"path": "stacker.tests.hooks.test_utils.mock_hook",
"required": True})]
handle_hooks("missing", hooks, self.provider, self.context)
good = hook_queue.get_nowait()
self.assertEqual(good["provider"].region, "us-east-1")
with self.assertRaises(queue.Empty):
hook_queue.get_nowait()

def test_valid_enabled_hook(self):
hooks = [
Hook({"path": "stacker.tests.hooks.test_utils.mock_hook",
"required": True, "enabled": True})]
handle_hooks("missing", hooks, self.provider, self.context)
good = hook_queue.get_nowait()
self.assertEqual(good["provider"].region, "us-east-1")
with self.assertRaises(queue.Empty):
hook_queue.get_nowait()

def test_valid_enabled_false_hook(self):
hooks = [
Hook({"path": "stacker.tests.hooks.test_utils.mock_hook",
"required": True, "enabled": False})]
handle_hooks("missing", hooks, self.provider, self.context)
self.assertTrue(hook_queue.empty())

def test_context_provided_to_hook(self):
hooks = [
Hook({"path": "stacker.tests.hooks.test_utils.context_hook",
"required": True})]
handle_hooks("missing", hooks, "us-east-1", self.context)

def test_hook_failure(self):
hooks = [
Hook({"path": "stacker.tests.hooks.test_utils.fail_hook",
"required": True})]
with self.assertRaises(SystemExit):
handle_hooks("fail", hooks, self.provider, self.context)
hooks = [{"path": "stacker.tests.hooks.test_utils.exception_hook",
"required": True}]
with self.assertRaises(Exception):
handle_hooks("fail", hooks, self.provider, self.context)
hooks = [
Hook({"path": "stacker.tests.hooks.test_utils.exception_hook",
"required": False})]
# Should pass
handle_hooks("ignore_exception", hooks, self.provider, self.context)

def test_return_data_hook(self):
hooks = [
Hook({
"path": "stacker.tests.hooks.test_utils.result_hook",
"data_key": "my_hook_results"
}),
# Shouldn't return data
Hook({
"path": "stacker.tests.hooks.test_utils.context_hook"
})
]
handle_hooks("result", hooks, "us-east-1", self.context)

self.assertEqual(
self.context.hook_data["my_hook_results"]["foo"],
"bar"
)
# Verify only the first hook resulted in stored data
self.assertEqual(
list(self.context.hook_data.keys()), ["my_hook_results"]
)

def test_return_data_hook_duplicate_key(self):
hooks = [
Hook({
"path": "stacker.tests.hooks.test_utils.result_hook",
"data_key": "my_hook_results"
}),
Hook({
"path": "stacker.tests.hooks.test_utils.result_hook",
"data_key": "my_hook_results"
})
]

with self.assertRaises(KeyError):
handle_hooks("result", hooks, "us-east-1", self.context)

def test_resolve_lookups_in_args(self):
hooks = [
Hook({
"path": "stacker.tests.hooks.test_utils.kwargs_hook",
"data_key": "my_hook_results",
"args": {
"default_lookup": "${default env_var::default_value}"
}
})
]
handle_hooks("lookups", hooks, "us-east-1", self.context)

self.assertEqual(
self.context.hook_data["my_hook_results"]["default_lookup"],
"default_value"
)
Loading