Merge lp:~danilo/landscape-charm/actions into lp:landscape-charm

Proposed by Данило Шеган on 2015-06-03
Status: Merged
Approved by: Данило Шеган on 2015-06-05
Approved revision: 310
Merged at revision: 306
Proposed branch: lp:~danilo/landscape-charm/actions
Merge into: lp:landscape-charm
Diff against target: 350 lines (+159/-84)
9 files modified
lib/action.py (+50/-0)
lib/hook.py (+0/-27)
lib/migrate_schema.py (+2/-2)
lib/pause.py (+2/-2)
lib/resume.py (+2/-2)
lib/tests/stubs.py (+8/-4)
lib/tests/test_action.py (+93/-0)
lib/tests/test_hook.py (+0/-45)
lib/upgrade.py (+2/-2)
To merge this branch: bzr merge lp:~danilo/landscape-charm/actions
Reviewer Review Type Date Requested Status
🤖 Landscape Builder test results Approve on 2015-06-05
Free Ekanayaka (community) 2015-06-03 Approve on 2015-06-05
Alberto Donato 2015-06-03 Approve on 2015-06-03
Review via email: mp+260916@code.launchpad.net

Commit Message

Introduce Action and MaintenanceAction classes to replace Hook-usage in actions

At this time, we need this separation to allow action-fail to properly set error messages (otherwise, hook error codes override it with "exited with code X".

This branch now drops MaintenanceHook which is replaced with MaintenanceAction now.

Description of the Change

Introduce Action and MaintenanceAction classes to replace Hook-usage in actions

All our existing actions are using Hook classes for their implementations. While this is not generally a problem other than naming problem, we do want to move away to having different behavior for actions.

Namely, they should not return error codes, because juju interprets their error code as the error message (see bug). Nor would that allow us to set the action return data with action-set.

This branch now drops MaintenanceHook which is replaced with MaintenanceAction now.

To test, use landscape.yaml from https://pastebin.canonical.com/132491/ and deploy with

  juju-deployer -vdW -w180 -c landscape.yaml landscape

and then play around with 'pause', 'resume', 'upgrade' and 'migrate-schema' actions. Check their status with 'juju action fetch $ID' to ensure error messages are properly set.

To post a comment you must log in.

Command: make ci-test
Result: Success
Revno: 301
Branch: lp:~danilo/landscape-charm/actions
Jenkins: https://ci.lscape.net/job/latch-test/1166/

review: Approve (test results)
Alberto Donato (ack) wrote :

LGTM, +1

minor comments inline.

review: Approve
Free Ekanayaka (free.ekanayaka) wrote :

So technically the code triggered by actions can be considered "hook" code, in the sense that any charm script that gets invoked by juju is a "hook", but the reason it gets triggered may vary (a relation changed, and action was issued, the unit was stopped) and hence a the API available to the hook might change slightly (e.g. relation-set defaults to the relation ID being changed, or additional executables like action-set and action-fail are available).

With that in mind, and also considering the error handling issue in the comment, what about:

https://pastebin.canonical.com/132495/

For the maintenance behavior I'd also add a class-level attribute (a bit for sake of composition-over-inheritance, in principle there could be several behavior that you want to mix and match, and that's easier done with class attributes rather than with multiple inheritance).

review: Needs Information
Free Ekanayaka (free.ekanayaka) wrote :

> So technically the code triggered by actions can be considered "hook" code, in
> the sense that any charm script that gets invoked by juju is a "hook", but the
> reason it gets triggered may vary (a relation changed, and action was issued,
> the unit was stopped) and hence a the API available to the hook might change
> slightly (e.g. relation-set defaults to the relation ID being changed, or
> additional executables like action-set and action-fail are available).
>
> With that in mind, and also considering the error handling issue in the
> comment, what about:
>
> https://pastebin.canonical.com/132495/
>
> For the maintenance behavior I'd also add a class-level attribute (a bit for
> sake of composition-over-inheritance, in principle there could be several
> behavior that you want to mix and match, and that's easier done with class
> attributes rather than with multiple inheritance).

FWIW this type of implementation (a single Hook abstraction with little behavioral variations when run in the context of an action) is the same as the one Juju in: juju/worker/uniter/runner/context.go

Free Ekanayaka (free.ekanayaka) wrote :

> So technically the code triggered by actions can be considered "hook" code, in
> the sense that any charm script that gets invoked by juju is a "hook", but the
> reason it gets triggered may vary (a relation changed, and action was issued,
> the unit was stopped) and hence a the API available to the hook might change
> slightly (e.g. relation-set defaults to the relation ID being changed, or
> additional executables like action-set and action-fail are available).
>
> With that in mind, and also considering the error handling issue in the
> comment, what about:
>
> https://pastebin.canonical.com/132495/
>
> For the maintenance behavior I'd also add a class-level attribute (a bit for
> sake of composition-over-inheritance, in principle there could be several
> behavior that you want to mix and match, and that's easier done with class
> attributes rather than with multiple inheritance).

Re automatically detecting if we're in an action hook context, reading the Juju source code it seems we can use the JUJU_ACTION_NAME, JUJU_ACTION_UUID and JUJU_ACTION_TAG environment vars.

Данило Шеган (danilo) wrote :

Free, we basically disagree about what's "easier" in this context. I'd resolve the problem you mention by making Hook class suitable for reuse as a parent for Action class, and then Action class could still have all the action-specific functionality, but would still catch HookError.

Another option, that I like even more, is catching all the exceptions in run() and constructing a nice failure/log message out of those. I generally dislike having a particular exception as the API to signal failure, and would rather have failures automatically propagated and logged.

Free Ekanayaka (free.ekanayaka) wrote :

> Free, we basically disagree about what's "easier" in this context. I'd
> resolve the problem you mention by making Hook class suitable for reuse as a
> parent for Action class, and then Action class could still have all the
> action-specific functionality, but would still catch HookError.

This works for me. As said, I'd rather have just a Hook class and trigger the action-specific behavior automatically (we can know that by look at the env variables mentioned above)

> Another option, that I like even more, is catching all the exceptions in run()
> and constructing a nice failure/log message out of those.

Do you mean a catch-all try/except? E.g.

try:
    self._run()
except Exception, error:
    # do logging

> I generally dislike
> having a particular exception as the API to signal failure, and would rather
> have failures automatically propagated and logged.

I'm not convinced by this. The HookError is an internal way to propagate errors generated by *expected* conditions (and typically will not traceback and will provide to the user information that he can use to resolve the situation). Random errors should be probably considered just bugs and I think it's probably better to traceback (there's no way you can provide insight to the users about what to do).

So in short, HookError is for known failures mode, hard-crash for all the others.

Данило Шеган (danilo) wrote :

Thanks for commentary, Free. Basically, automatic behaviour would mean
that we'd have same classes doing different things based on their
environment: do you really foresee us having a hook code that should
behave differently if it's ran as an action?

Since I assume no, and believe that having a single class act in
multiple roles is more confusing, I am keeping Action* classes. I am
depending on Hook* classes, but couldn't come up with a better way to
extract common functionality without reworking everything, so left them
as they are.

I still have some reservations about using HookError directly: imho,
your example of apt module throwing HookError is a bad one: it should
never do that, but should instead throw errors specific to that module.
Callsite should catch that error, and if it's inside a hook/action, turn
it into a HookError/ActionError. I'll split that into a separate
branch, however.

You are right about not catching general exceptions and letting them
traceback, so I am not doing that.

Данило Шеган (danilo) wrote :

Thanks for commentary, Free. Basically, automatic behaviour would mean that we'd have same classes doing different things based on their environment: do you really foresee us having a hook code that should behave differently if it's ran as an action?

Since I assume no, and believe that having a single class act in multiple roles is more confusing, I am keeping Action* classes. I am depending on Hook* classes, but couldn't come up with a better way to extract common functionality without reworking everything, so left them as they are.

I still have some reservations about using HookError directly: imho, your example of apt module throwing HookError is a bad one: it should never do that, but should instead throw errors specific to that module. Callsite should catch that error, and if it's inside a hook/action, turn it into a HookError/ActionError. I'll split that into a separate branch, however.

You are right about not catching general exceptions and letting them traceback, so I am not doing that.

Данило Шеган (danilo) wrote :

Thanks for commentary, Free. Basically, automatic behaviour would mean that we'd have same classes doing different things based on their environment: do you really foresee us having a hook code that should behave differently if it's ran as an action?

Since I assume no, and believe that having a single class act in multiple roles is more confusing, I am keeping Action* classes. I am depending on Hook* classes, but couldn't come up with a better way to extract common functionality without reworking everything, so left them as they are.

I still have some reservations about using HookError directly: imho, your example of apt module throwing HookError is a bad one: it should never do that, but should instead throw errors specific to that module. Callsite should catch that error, and if it's inside a hook/action, turn it into a HookError/ActionError. I'll split that into a separate branch, however.

You are right about not catching general exceptions and letting them traceback, so I am not doing that.

Данило Шеган (danilo) wrote :

Thanks for commentary, Free. Basically, automatic behaviour would mean that we'd have same classes doing different things based on their environment: do you really foresee us having a hook code that should behave differently if it's ran as an action?

Since I assume no, and believe that having a single class act in multiple roles is more confusing, I am keeping Action* classes. I am depending on Hook* classes, but couldn't come up with a better way to extract common functionality without reworking everything, so left them as they are.

I still have some reservations about using HookError directly: imho, your example of apt module throwing HookError is a bad one: it should never do that, but should instead throw errors specific to that module. Callsite should catch that error, and if it's inside a hook/action, turn it into a HookError/ActionError. I'll split that into a separate branch, however.

You are right about not catching general exceptions and letting them traceback, so I am not doing that.

Данило Шеган (danilo) wrote :

Thanks for commentary, Free. Basically, automatic behaviour would mean that we'd have same classes doing different things based on their environment: do you really foresee us having a hook code that should behave differently if it's ran as an action?

Since I assume no, and believe that having a single class act in multiple roles is more confusing, I am keeping Action* classes. I am depending on Hook* classes, but couldn't come up with a better way to extract common functionality without reworking everything, so left them as they are.

I still have some reservations about using HookError directly: imho, your example of apt module throwing HookError is a bad one: it should never do that, but should instead throw errors specific to that module. Callsite should catch that error, and if it's inside a hook/action, turn it into a HookError/ActionError. I'll split that into a separate branch, however.

You are right about not catching general exceptions and letting them traceback, so I am not doing that.

Данило Шеган (danilo) wrote :

Thanks for commentary, Free. Basically, automatic behaviour would mean that we'd have same classes doing different things based on their environment: do you really foresee us having a hook code that should behave differently if it's ran as an action?

Since I assume no, and believe that having a single class act in multiple roles is more confusing, I am keeping Action* classes. I am depending on Hook* classes, but couldn't come up with a better way to extract common functionality without reworking everything, so left them as they are.

I still have some reservations about using HookError directly: imho, your example of apt module throwing HookError is a bad one: it should never do that, but should instead throw errors specific to that module. Callsite should catch that error, and if it's inside a hook/action, turn it into a HookError/ActionError. I'll split that into a separate branch, however.

You are right about not catching general exceptions and letting them traceback, so I am not doing that.

Данило Шеган (danilo) wrote :

Thanks for commentary, Free. Basically, automatic behaviour would mean that we'd have same classes doing different things based on their environment: do you really foresee us having a hook code that should behave differently if it's ran as an action?

Since I assume no, and believe that having a single class act in multiple roles is more confusing, I am keeping Action* classes. I am depending on Hook* classes, but couldn't come up with a better way to extract common functionality without reworking everything, so left them as they are.

I still have some reservations about using HookError directly: imho, your example of apt module throwing HookError is a bad one: it should never do that, but should instead throw errors specific to that module. Callsite should catch that error, and if it's inside a hook/action, turn it into a HookError/ActionError. I'll split that into a separate branch, however.

You are right about not catching general exceptions and letting them traceback, so I am not doing that.

Command: make ci-test
Result: Success
Revno: 302
Branch: lp:~danilo/landscape-charm/actions
Jenkins: https://ci.lscape.net/job/latch-test/1189/

review: Approve (test results)
Данило Шеган (danilo) wrote :

As discussed on IRC regarding structure of exceptions, we've agreed to meet mid-way: we'll introduce a base CharmError and make ActionError or HookError catch that, and each individual module can throw specific errors they want. If they need actions or hooks to gracefully fail, those exceptions need to inherit CharmError.

Данило Шеган (danilo) wrote :

With charm-errors landed, this is now ready for another round of reviews: it hasn't changed much, so I am not resubmitting (basically, just dropped ActionError and switched to CharmError in Action).

Free Ekanayaka (free.ekanayaka) wrote :

+1 with a couple of additional comments

review: Approve

Command: make ci-test
Result: Success
Revno: 308
Branch: lp:~danilo/landscape-charm/actions
Jenkins: https://ci.lscape.net/job/latch-test/1219/

review: Approve (test results)
lp:~danilo/landscape-charm/actions updated on 2015-06-05
309. By Данило Шеган on 2015-06-05

Drop Action set/fail methods and use hookenv.action_set, action_fail directly.

310. By Данило Шеган on 2015-06-05

Make HookenvStub action_sets and action_fails public for use in tests: addresses review comments by Free.

Данило Шеган (danilo) wrote :

I've addressed your review comments, thanks Free.

Will do another round of testing before landing.

Command: make ci-test
Result: Success
Revno: 310
Branch: lp:~danilo/landscape-charm/actions
Jenkins: https://ci.lscape.net/job/latch-test/1225/

review: Approve (test results)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'lib/action.py'
2--- lib/action.py 1970-01-01 00:00:00 +0000
3+++ lib/action.py 2015-06-05 12:02:41 +0000
4@@ -0,0 +1,50 @@
5+import os.path
6+
7+from charmhelpers.core import hookenv
8+
9+from lib.error import CharmError
10+from lib.hook import Hook
11+from lib.paths import default_paths
12+
13+
14+class Action(Hook):
15+ """Juju action abstraction, providing dependency injection for testing."""
16+
17+ def __call__(self):
18+ """
19+ Invoke the action's run() method.
20+
21+ If _run() returns a value, set it using action_set().
22+ If _run() throws a CharmError, fail using action.fail().
23+ """
24+ self._hookenv.log("Running action %s" % type(self).__name__)
25+ try:
26+ return_values = self._run()
27+ if return_values is not None:
28+ self._hookenv.action_set(return_values)
29+ except CharmError, error:
30+ self._hookenv.action_fail(str(error))
31+
32+
33+class MaintenanceAction(Action):
34+ """Action that only runs when in maintenance mode."""
35+
36+ def __init__(self, hookenv=hookenv, paths=default_paths):
37+ """
38+ @param hookenv: The charm-helpers C{hookenv} module, will be replaced
39+ by tests.
40+ @param paths: The landscape-server default paths class.
41+ """
42+ self._hookenv = hookenv
43+ self._paths = paths
44+
45+ def __call__(self):
46+ """Invoke the action.
47+
48+ @return: An integer with the exit code for the hook.
49+ """
50+ if not os.path.exists(self._paths.maintenance_flag()):
51+ self._hookenv.action_fail(
52+ "This action can only be called on a unit in paused state.")
53+ return
54+ super(MaintenanceAction, self).__call__()
55
56=== modified file 'lib/hook.py'
57--- lib/hook.py 2015-06-05 09:02:17 +0000
58+++ lib/hook.py 2015-06-05 12:02:41 +0000
59@@ -1,10 +1,7 @@
60-import os.path
61-
62 from charmhelpers.core import hookenv
63 from charmhelpers.core.hookenv import ERROR
64
65 from lib.error import CharmError
66-from lib.paths import default_paths
67
68
69 class Hook(object):
70@@ -33,27 +30,3 @@
71 def _run(self):
72 """Do the job."""
73 raise NotImplementedError("Must be implemented by sub-classes")
74-
75-
76-class MaintenanceHook(Hook):
77- """Hook that only runs when in maintenance mode."""
78-
79- def __init__(self, hookenv=hookenv, paths=default_paths):
80- """
81- @param hookenv: The charm-helpers C{hookenv} module, will be replaced
82- by tests.
83- @param paths: The landscape-server default paths class.
84- """
85- self._hookenv = hookenv
86- self._paths = paths
87-
88- def __call__(self):
89- """Invoke the hook.
90-
91- @return: An integer with the exit code for the hook.
92- """
93- if not os.path.exists(self._paths.maintenance_flag()):
94- self._hookenv.action_fail(
95- "This action can only be called on a unit in paused state.")
96- return
97- super(MaintenanceHook, self).__call__()
98
99=== modified file 'lib/migrate_schema.py'
100--- lib/migrate_schema.py 2015-06-01 10:46:39 +0000
101+++ lib/migrate_schema.py 2015-06-05 12:02:41 +0000
102@@ -2,11 +2,11 @@
103
104 from charmhelpers.core import hookenv
105
106-from lib.hook import MaintenanceHook
107+from lib.action import MaintenanceAction
108 from lib.paths import default_paths, SCHEMA_SCRIPT
109
110
111-class MigrateSchemaAction(MaintenanceHook):
112+class MigrateSchemaAction(MaintenanceAction):
113 """Execute schema upgrade action logic."""
114
115 def __init__(self, hookenv=hookenv, paths=default_paths,
116
117=== modified file 'lib/pause.py'
118--- lib/pause.py 2015-05-29 09:25:24 +0000
119+++ lib/pause.py 2015-06-05 12:02:41 +0000
120@@ -2,11 +2,11 @@
121
122 from charmhelpers.core import hookenv
123
124-from lib.hook import Hook
125+from lib.action import Action
126 from lib.paths import LSCTL
127
128
129-class PauseAction(Hook):
130+class PauseAction(Action):
131 """Execute pause action logic."""
132
133 def __init__(self, hookenv=hookenv, subprocess=subprocess):
134
135=== modified file 'lib/resume.py'
136--- lib/resume.py 2015-06-01 10:46:39 +0000
137+++ lib/resume.py 2015-06-05 12:02:41 +0000
138@@ -2,11 +2,11 @@
139
140 from charmhelpers.core import hookenv
141
142-from lib.hook import MaintenanceHook
143+from lib.action import MaintenanceAction
144 from lib.paths import default_paths, LSCTL
145
146
147-class ResumeAction(MaintenanceHook):
148+class ResumeAction(MaintenanceAction):
149 """Resume all Landscape services on the unit."""
150
151 def __init__(self, hookenv=hookenv, paths=default_paths,
152
153=== modified file 'lib/tests/stubs.py'
154--- lib/tests/stubs.py 2015-06-03 12:30:33 +0000
155+++ lib/tests/stubs.py 2015-06-05 12:02:41 +0000
156@@ -17,7 +17,8 @@
157 self.relations = {}
158 self._config = Config()
159 self._charm_dir = charm_dir
160- self._action_fails = []
161+ self.action_fails = []
162+ self.action_sets = []
163
164 def config(self):
165 return self._config
166@@ -66,12 +67,15 @@
167 def charm_dir(self):
168 return self._charm_dir
169
170- def action_fail(self, message):
171- self._action_fails.append(message)
172-
173 def is_leader(self):
174 return self.leader
175
176+ def action_fail(self, message):
177+ self.action_fails.append(message)
178+
179+ def action_set(self, values):
180+ self.action_sets.append(values)
181+
182
183 class FetchStub(object):
184 """Provide a testable stub for C{charmhelpers.fetch}."""
185
186=== added file 'lib/tests/test_action.py'
187--- lib/tests/test_action.py 1970-01-01 00:00:00 +0000
188+++ lib/tests/test_action.py 2015-06-05 12:02:41 +0000
189@@ -0,0 +1,93 @@
190+import os
191+
192+from lib.action import Action, MaintenanceAction
193+from lib.error import CharmError
194+
195+from lib.tests.helpers import HookenvTest
196+from lib.tests.rootdir import RootDir
197+
198+
199+class DummyAction(Action):
200+ executed = False
201+
202+ def _run(self):
203+ self.executed = True
204+
205+
206+class DummyActionWithValues(Action):
207+ def _run(self):
208+ return {"key": "value"}
209+
210+
211+class DummyErrorAction(Action):
212+ def _run(self):
213+ raise CharmError("no go")
214+
215+
216+class ActionTest(HookenvTest):
217+
218+ def setUp(self):
219+ super(ActionTest, self).setUp()
220+ self.root_dir = self.useFixture(RootDir())
221+ self.paths = self.root_dir.paths
222+
223+ def test_run(self):
224+ """Calling an action executes the _run method."""
225+ action = DummyAction(hookenv=self.hookenv)
226+ action()
227+ self.assertTrue(action.executed)
228+
229+ def test_run_with_return_values(self):
230+ """If _run returns values, they are set with action_set()."""
231+ action = DummyActionWithValues(hookenv=self.hookenv)
232+ action()
233+ self.assertEqual([{"key": "value"}], self.hookenv.action_sets)
234+
235+ def test_run_raises_error(self):
236+ """
237+ If action fails with a CharmError, it is set as failed with the
238+ exception message as the error message.
239+ """
240+ action = DummyErrorAction(hookenv=self.hookenv)
241+ action()
242+ self.assertEqual(["no go"], self.hookenv.action_fails)
243+
244+
245+class DummyMaintenanceAction(MaintenanceAction):
246+ executed = False
247+
248+ def _run(self):
249+ self.executed = True
250+
251+
252+class MaintenanceActionTest(HookenvTest):
253+
254+ def setUp(self):
255+ super(MaintenanceActionTest, self).setUp()
256+ self.root_dir = self.useFixture(RootDir())
257+ self.paths = self.root_dir.paths
258+
259+ def test_run(self):
260+ """Calling a dummy hook runs only with maintenance flag set."""
261+
262+ open(self.paths.maintenance_flag(), "w")
263+ self.addCleanup(os.remove, self.paths.maintenance_flag())
264+
265+ action = DummyMaintenanceAction(
266+ hookenv=self.hookenv, paths=self.paths)
267+
268+ action()
269+ self.assertTrue(action.executed)
270+
271+ def test_run_without_maintenance_flag(self):
272+ """
273+ When maintenance flag file is absent, maintenance hooks are no-ops.
274+ """
275+ action = DummyMaintenanceAction(
276+ hookenv=self.hookenv, paths=self.paths)
277+
278+ action()
279+ self.assertFalse(action.executed)
280+ self.assertEqual(
281+ ["This action can only be called on a unit in paused state."],
282+ self.hookenv.action_fails)
283
284=== removed file 'lib/tests/test_hook.py'
285--- lib/tests/test_hook.py 2015-06-02 07:06:30 +0000
286+++ lib/tests/test_hook.py 1970-01-01 00:00:00 +0000
287@@ -1,45 +0,0 @@
288-import os
289-
290-from lib.tests.helpers import HookenvTest
291-from lib.tests.rootdir import RootDir
292-from lib.hook import MaintenanceHook
293-
294-
295-class DummyMaintenanceHook(MaintenanceHook):
296- executed = False
297-
298- def _run(self):
299- self.executed = True
300-
301-
302-class MaintenanceHookTest(HookenvTest):
303-
304- def setUp(self):
305- super(MaintenanceHookTest, self).setUp()
306- self.root_dir = self.useFixture(RootDir())
307- self.paths = self.root_dir.paths
308-
309- def test_run(self):
310- """Calling a dummy hook runs only with maintenance flag set."""
311-
312- open(self.paths.maintenance_flag(), "w")
313- self.addCleanup(os.remove, self.paths.maintenance_flag())
314-
315- action = DummyMaintenanceHook(
316- hookenv=self.hookenv, paths=self.paths)
317-
318- action()
319- self.assertTrue(action.executed)
320-
321- def test_run_without_maintenance_flag(self):
322- """
323- When maintenance flag file is absent, maintenance hooks are no-ops.
324- """
325- action = DummyMaintenanceHook(
326- hookenv=self.hookenv, paths=self.paths)
327-
328- action()
329- self.assertFalse(action.executed)
330- self.assertEqual(
331- ["This action can only be called on a unit in paused state."],
332- self.hookenv._action_fails)
333
334=== modified file 'lib/upgrade.py'
335--- lib/upgrade.py 2015-06-01 10:39:56 +0000
336+++ lib/upgrade.py 2015-06-05 12:02:41 +0000
337@@ -4,11 +4,11 @@
338 from charmhelpers import fetch
339
340 from lib.apt import Apt
341-from lib.hook import MaintenanceHook
342+from lib.action import MaintenanceAction
343 from lib.paths import default_paths
344
345
346-class UpgradeAction(MaintenanceHook):
347+class UpgradeAction(MaintenanceAction):
348 """Execute package upgrade action logic."""
349
350 def __init__(self, hookenv=hookenv, fetch=fetch, paths=default_paths,

Subscribers

People subscribed via source and target branches