Skip to content

Commit 39a2f82

Browse files
committed
hooks: treat lookup dependencies specially to handle destroy
When running the destroy action, we reverse the order of execution compared to build, such that steps that required others are now required by them instead. Unfortunately that is not enough to express the execution requirements of hooks: they might be `required_by` a stack to run some cleanup before destroying that stack, but still need it to be deployed to lookup outputs from. Handle that by manually checking for the dependencies of lookups before executing hooks, skipping them if a required stack is not deployed (or has already been destroyed).
1 parent e11a905 commit 39a2f82

File tree

6 files changed

+257
-170
lines changed

6 files changed

+257
-170
lines changed

Diff for: stacker/actions/base.py

+5-14
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
1313

1414
import botocore.exceptions
1515
from stacker.session_cache import get_session
16-
from stacker.exceptions import HookExecutionFailed, PlanFailed
17-
from stacker.status import COMPLETE, SKIPPED, FailedStatus
16+
from stacker.exceptions import PlanFailed
17+
from stacker.status import COMPLETE
1818
from stacker.util import ensure_s3_bucket, get_s3_endpoint
1919

20+
2021
logger = logging.getLogger(__name__)
2122

2223
# After submitting a stack update/create, this controls how long we'll wait
@@ -142,18 +143,8 @@ def target_fn(*args, **kwargs):
142143
return COMPLETE
143144

144145
def hook_fn(hook, *args, **kwargs):
145-
provider = self.provider_builder.build(profile=hook.profile,
146-
region=hook.region)
147-
148-
try:
149-
result = hook.run(provider, self.context)
150-
except HookExecutionFailed as e:
151-
return FailedStatus(reason=str(e))
152-
153-
if result is None:
154-
return SKIPPED
155-
156-
return COMPLETE
146+
return hook.run_step(provider_builder=self.provider_builder,
147+
context=self.context)
157148

158149
pre_hooks_target = Target(
159150
name="pre_{}_hooks".format(action_name))

Diff for: stacker/exceptions.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ def __init__(self, stack_name, *args, **kwargs):
126126
message = ("Stack: \"%s\" does not exist in outputs or the lookup is "
127127
"not available in this stacker run") % (stack_name,)
128128
super(StackDoesNotExist, self).__init__(message, *args, **kwargs)
129+
self.stack_name = stack_name
129130

130131

131132
class MissingParameterException(Exception):
@@ -278,14 +279,14 @@ def __init__(self, exception, stack, dependency):
278279
class HookExecutionFailed(Exception):
279280
"""Raised when running a required hook fails"""
280281

281-
def __init__(self, hook, result=None, exception=None):
282+
def __init__(self, hook, result=None, cause=None):
282283
self.hook = hook
283284
self.result = result
284-
self.exception = exception
285+
self.cause = cause
285286

286-
if self.exception:
287+
if self.cause:
287288
message = ("Hook '{}' threw exception: {}".format(
288-
hook.name, exception))
289+
hook.name, cause))
289290
else:
290291
message = ("Hook '{}' failed (result: {})".format(
291292
hook.name, result))

Diff for: stacker/hooks/__init__.py

+77-21
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
import logging
66
from collections import Mapping, namedtuple
77

8-
from stacker.exceptions import HookExecutionFailed
8+
from stacker.exceptions import HookExecutionFailed, StackDoesNotExist
99
from stacker.util import load_object_from_string
10+
from stacker.status import (
11+
COMPLETE, SKIPPED, FailedStatus, NotSubmittedStatus, SkippedStatus
12+
)
1013
from stacker.variables import Variable
1114

1215
logger = logging.getLogger(__name__)
@@ -54,11 +57,55 @@ def __init__(self, name, path, required=True, enabled=True,
5457
self.region = region
5558

5659
self._args = {}
60+
self._args, deps = self.parse_args(args)
61+
self.requires.update(deps)
62+
63+
self._callable = self.resolve_path()
64+
65+
def parse_args(self, args):
66+
arg_vars = {}
67+
deps = set()
68+
5769
if args:
5870
for key, value in args.items():
59-
var = self._args[key] = \
71+
var = arg_vars[key] = \
6072
Variable('{}.args.{}'.format(self.name, key), value)
61-
self.requires.update(var.dependencies())
73+
deps.update(var.dependencies())
74+
75+
return arg_vars, deps
76+
77+
def resolve_path(self):
78+
try:
79+
return load_object_from_string(self.path)
80+
except (AttributeError, ImportError) as e:
81+
raise ValueError("Unable to load method at %s for hook %s: %s",
82+
self.path, self.name, str(e))
83+
84+
def check_args_dependencies(self, provider, context):
85+
# When running hooks for destruction, we might rely on outputs of
86+
# stacks that we assume have been deployed. Unfortunately, since
87+
# destruction must happen in the reverse order of creation, those stack
88+
# dependencies will not be present on `requires`, but in `required_by`,
89+
# meaning the execution engine won't stop the hook from running early.
90+
91+
# To deal with that, manually find the dependencies coming from
92+
# lookups in the hook arguments, select those that represent stacks,
93+
# and check if they are actually available.
94+
95+
dependencies = set()
96+
for value in self._args.values():
97+
dependencies.update(value.dependencies())
98+
99+
for dep in dependencies:
100+
# We assume all dependency names are valid here. Hence, if we can't
101+
# find a stack with that same name, it must be a target or a hook,
102+
# and hence we don't need to check it
103+
stack = context.get_stack(dep)
104+
if stack is None:
105+
continue
106+
107+
# This will raise if the stack is missing
108+
provider.get_stack(stack.fqn)
62109

63110
def resolve_args(self, provider, context):
64111
for key, value in self._args.items():
@@ -85,29 +132,15 @@ def run(self, provider, context):
85132
"""
86133

87134
logger.info("Executing hook %s", self)
88-
89-
if not self.enabled:
90-
logger.debug("Hook %s is disabled, skipping", self.name)
91-
return
92-
93-
try:
94-
method = load_object_from_string(self.path)
95-
except (AttributeError, ImportError) as e:
96-
logger.exception("Unable to load method at %s for hook %s:",
97-
self.path, self.name)
98-
if self.required:
99-
raise HookExecutionFailed(self, exception=e)
100-
101-
return
102-
103135
kwargs = dict(self.resolve_args(provider, context))
104136
try:
105-
result = method(context=context, provider=provider, **kwargs)
137+
result = self._callable(context=context, provider=provider,
138+
**kwargs)
106139
except Exception as e:
107140
if self.required:
108-
raise HookExecutionFailed(self, exception=e)
141+
raise HookExecutionFailed(self, cause=e)
109142

110-
return
143+
return None
111144

112145
if not result:
113146
if self.required:
@@ -125,6 +158,29 @@ def run(self, provider, context):
125158

126159
return result
127160

161+
def run_step(self, provider_builder, context):
162+
if not self.enabled:
163+
return NotSubmittedStatus()
164+
165+
provider = provider_builder.build(profile=self.profile,
166+
region=self.region)
167+
168+
try:
169+
self.check_args_dependencies(provider, context)
170+
except StackDoesNotExist as e:
171+
reason = "required stack not deployed: {}".format(e.stack_name)
172+
return SkippedStatus(reason=reason)
173+
174+
try:
175+
result = self.run(provider, context)
176+
except HookExecutionFailed as e:
177+
return FailedStatus(reason=str(e))
178+
179+
if not result:
180+
return SKIPPED
181+
182+
return COMPLETE
183+
128184
def __str__(self):
129185
return 'Hook(name={}, path={}, profile={}, region={})'.format(
130186
self.name, self.path, self.profile, self.region)

Diff for: stacker/tests/test_context.py

+23-17
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
from stacker.config import load, Config
1010

1111

12+
FAKE_HOOK_PATH = "stacker.tests.fixtures.mock_hooks.mock_hook"
13+
14+
1215
class TestContext(unittest.TestCase):
1316

1417
def setUp(self):
@@ -119,7 +122,7 @@ def test_hook_with_sys_path(self):
119122
"pre_build": [
120123
{
121124
"data_key": "myHook",
122-
"path": "fixtures.mock_hooks.mock_hook",
125+
"path": FAKE_HOOK_PATH.replace('stacker.tests.', ''),
123126
"required": True,
124127
"args": {
125128
"value": "mockResult"}}]})
@@ -134,43 +137,46 @@ def test_hook_with_sys_path(self):
134137
self.assertEqual("mockResult", context.hook_data["myHook"]["result"])
135138

136139
def test_get_hooks_for_action(self):
140+
137141
config = Config({
138142
"pre_build": [
139-
{"path": "fake.hook"},
140-
{"name": "pre_build_test", "path": "fake.hook"},
141-
{"path": "fake.hook"}
143+
{"path": FAKE_HOOK_PATH},
144+
{"name": "pre_build_test", "path": FAKE_HOOK_PATH},
145+
{"path": FAKE_HOOK_PATH}
142146
],
143147
"post_build": [
144-
{"path": "fake.hook"},
145-
{"name": "post_build_test", "path": "fake.hook"},
146-
{"path": "fake.hook"}
148+
{"path": FAKE_HOOK_PATH},
149+
{"name": "post_build_test", "path": FAKE_HOOK_PATH},
150+
{"path": FAKE_HOOK_PATH}
147151
],
148152
"build_hooks": [
149-
{"path": "fake.hook"},
150-
{"name": "build_test", "path": "fake.hook"},
151-
{"path": "fake.hook"}
153+
{"path": FAKE_HOOK_PATH},
154+
{"name": "build_test", "path": FAKE_HOOK_PATH},
155+
{"path": FAKE_HOOK_PATH}
152156
]
153157
})
154158

155159
context = Context(config=config)
156160
hooks = context.get_hooks_for_action('build')
157161

158-
assert hooks.pre[0].name == "pre_build_1_fake.hook"
162+
assert hooks.pre[0].name == "pre_build_1_{}".format(FAKE_HOOK_PATH)
159163
assert hooks.pre[1].name == "pre_build_test"
160-
assert hooks.pre[2].name == "pre_build_3_fake.hook"
164+
assert hooks.pre[2].name == "pre_build_3_{}".format(FAKE_HOOK_PATH)
161165

162-
assert hooks.post[0].name == "post_build_1_fake.hook"
166+
assert hooks.post[0].name == "post_build_1_{}".format(FAKE_HOOK_PATH)
163167
assert hooks.post[1].name == "post_build_test"
164-
assert hooks.post[2].name == "post_build_3_fake.hook"
168+
assert hooks.post[2].name == "post_build_3_{}".format(FAKE_HOOK_PATH)
165169

166-
assert hooks.custom[0].name == "build_hooks_1_fake.hook"
170+
assert hooks.custom[0].name == \
171+
"build_hooks_1_{}".format(FAKE_HOOK_PATH)
167172
assert hooks.custom[1].name == "build_test"
168-
assert hooks.custom[2].name == "build_hooks_3_fake.hook"
173+
assert hooks.custom[2].name == \
174+
"build_hooks_3_{}".format(FAKE_HOOK_PATH)
169175

170176
def test_hook_data_key_fallback(self):
171177
config = Config({
172178
"build_hooks": [
173-
{"name": "my-hook", "path": "fake.hook"}
179+
{"name": "my-hook", "path": FAKE_HOOK_PATH}
174180
]
175181
})
176182
context = Context(config=config)

0 commit comments

Comments
 (0)