diff --git a/CHANGELOG.md b/CHANGELOG.md index 0024c839d..33857cff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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] diff --git a/docs/config.rst b/docs/config.rst index a6804926b..f2c97d02b 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -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:: @@ -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 [@]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 ---- diff --git a/stacker/actions/build.py b/stacker/actions/build.py index 55a0da9e7..a5d7a224b 100644 --- a/stacker/actions/build.py +++ b/stacker/actions/build.py @@ -8,6 +8,7 @@ from ..providers.base import Template from stacker.hooks import utils + from ..exceptions import ( MissingParameterException, StackDidNotChange, diff --git a/stacker/actions/destroy.py b/stacker/actions/destroy.py index cb3baf627..b69efca18 100644 --- a/stacker/actions/destroy.py +++ b/stacker/actions/destroy.py @@ -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, diff --git a/stacker/hooks/utils.py b/stacker/hooks/utils.py index 718fda3a5..ab7fcb638 100644 --- a/stacker/hooks/utils.py +++ b/stacker/hooks/utils.py @@ -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__) @@ -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): + 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) diff --git a/stacker/tests/hooks/test_utils.py b/stacker/tests/hooks/test_utils.py new file mode 100644 index 000000000..05f71a11a --- /dev/null +++ b/stacker/tests/hooks/test_utils.py @@ -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" + ) diff --git a/stacker/tests/test_util.py b/stacker/tests/test_util.py index 0163ed4c8..843f795e9 100644 --- a/stacker/tests/test_util.py +++ b/stacker/tests/test_util.py @@ -8,13 +8,12 @@ import string import os -import queue import mock import boto3 -from stacker.config import Hook, GitPackageSource +from stacker.config import GitPackageSource from stacker.util import ( cf_safe_name, load_object_from_string, @@ -32,13 +31,6 @@ SourceProcessor ) -from stacker.hooks.utils import handle_hooks - -from .factories import ( - mock_context, - mock_provider, -) - regions = ["us-east-1", "cn-north-1", "ap-northeast-1", "eu-west-1", "ap-southeast-1", "ap-southeast-2", "us-west-2", "us-gov-west-1", "us-west-1", "eu-central-1", "sa-east-1"] @@ -275,148 +267,6 @@ def test_SourceProcessor_helpers(self): ) -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"} - - -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.test_util.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.test_util.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.test_util.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.test_util.context_hook", - "required": True})] - handle_hooks("missing", hooks, "us-east-1", self.context) - - def test_hook_failure(self): - hooks = [ - Hook({"path": "stacker.tests.test_util.fail_hook", - "required": True})] - with self.assertRaises(SystemExit): - handle_hooks("fail", hooks, self.provider, self.context) - hooks = [{"path": "stacker.tests.test_util.exception_hook", - "required": True}] - with self.assertRaises(Exception): - handle_hooks("fail", hooks, self.provider, self.context) - hooks = [ - Hook({"path": "stacker.tests.test_util.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.test_util.result_hook", - "data_key": "my_hook_results" - }), - # Shouldn't return data - Hook({ - "path": "stacker.tests.test_util.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.test_util.result_hook", - "data_key": "my_hook_results" - }), - Hook({ - "path": "stacker.tests.test_util.result_hook", - "data_key": "my_hook_results" - }) - ] - - with self.assertRaises(KeyError): - handle_hooks("result", hooks, "us-east-1", self.context) - - class TestException1(Exception): pass