Merge lp:~jimbaker/pyjuju/hook-command-error-cases into lp:pyjuju

Proposed by Jim Baker
Status: Merged
Approved by: Clint Byrum
Approved revision: 584
Merged at revision: 587
Proposed branch: lp:~jimbaker/pyjuju/hook-command-error-cases
Merge into: lp:pyjuju
Diff against target: 456 lines (+292/-33)
4 files modified
juju/hooks/commands.py (+36/-10)
juju/hooks/invoker.py (+6/-2)
juju/hooks/protocol.py (+11/-6)
juju/hooks/tests/test_invoker.py (+239/-15)
To merge this branch: bzr merge lp:~jimbaker/pyjuju/hook-command-error-cases
Reviewer Review Type Date Requested Status
Clint Byrum (community) Approve
Review via email: mp+127188@code.launchpad.net

Description of the change

Exceptions for hook commands using relation id

AMP requires that all exceptions that are passed between the hook command (client) and unit agent (server) are registered, otherwise it will raise "twisted ERROR: Unhandled Error". This branch fixes this issue for exceptions introduced by the use of -r to specify a relation id. In addition for completeness, other tests were added around potential error cases.

This branch also resolves bug #1050592, which saw this issue in the use of relation-list with -r.

Note that these fixes continue the current practice that an error report is written to stderr ("Relation not found"), but the exit code of the hook command is still 0.

I have tested the branch with the the debug-charm attached to the bug, lp:~mew/+junk/debug-charm

https://codereview.appspot.com/6589056/

To post a comment you must log in.
Revision history for this message
Clint Byrum (clint-fewbar) wrote :

Hi Jim. This is a pretty big merge, so perhaps next time use lbox propose -cr so we can review w/ rietveld?

Anyway, I think it all LGTM .. one bit of feedback:

99 - raise RelationStateNotFound()
100 + parts = relation_ident.split(":")
101 + if len(parts) != 2 or not parts[1].isdigit():
102 + raise InvalidRelationIdentity(relation_ident)
103 + else:
104 + raise RelationStateNotFound()

I don't really understand checking the format at this point.. we already know its not found, and thats what a user trying to debug their charm would want to know. The format problem would likely be *more* confusing. So I think this check, and the extra test for it, can just be dropped.

review: Needs Fixing
Revision history for this message
Jim Baker (jimbaker) wrote :

Reviewers: mp+127188_code.launchpad.net,

Message:
Please take a look.

Description:
Exceptions for hook commands using relation id

AMP requires that all exceptions that are passed between the hook
command (client) and unit agent (server) are registered, otherwise it
will raise "twisted ERROR: Unhandled Error". This branch fixes this
issue for exceptions introduced by the use of -r to specify a relation
id. In addition for completeness, other tests were added around
potential error cases.

This branch also resolves bug #1050592, which saw this issue in the use
of relation-list with -r.

Note that these fixes continue the current practice that an error report
is written to stderr ("Relation not found"), but the exit code of the
hook command is still 0.

I have tested the branch with the the debug-charm attached to the bug,
lp:~mew/+junk/debug-charm

https://code.launchpad.net/~jimbaker/juju/hook-command-error-cases/+merge/127188

(do not edit description out of merge proposal)

Please review this at https://codereview.appspot.com/6589056/

Affected files:
   A [revision details]
   M juju/hooks/commands.py
   M juju/hooks/invoker.py
   M juju/hooks/protocol.py
   M juju/hooks/tests/test_invoker.py

Revision history for this message
Jim Baker (jimbaker) wrote :

I'm fine with reporting "Relation not found", however, according to the spec, it should report "Not a valid relation id".

Note that the format check was being done on the other get_relation_hook_context (unfortunate naming) in HookContext; however, the invoker always caches associated hook contexts, then looks them up, so this check was never actually being made at a user visible level.

Revision history for this message
Clint Byrum (clint-fewbar) wrote :

Ok, well if the spec has it, then I see no reason to delay merging.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'juju/hooks/commands.py'
2--- juju/hooks/commands.py 2012-05-03 16:46:40 +0000
3+++ juju/hooks/commands.py 2012-10-01 03:59:18 +0000
4@@ -9,6 +9,7 @@
5 from juju.hooks.cli import (
6 CommandLineClient, parse_log_level, parse_port_protocol)
7 from juju.hooks.protocol import MustSpecifyRelationName
8+from juju.state.errors import InvalidRelationIdentity
9
10
11 BAD_CHARS = re.compile("[\-\./:()<>|?*]|(\\\)")
12@@ -27,13 +28,22 @@
13
14 @inlineCallbacks
15 def run(self):
16- # handle settings_name being explictly skipped on the cli
17 if self.options.settings_name == "-":
18 self.options.settings_name = ""
19- result = yield self.client.relation_get(self.options.client_id,
20- self.options.relation_id,
21- self.options.unit_name,
22- self.options.settings_name)
23+ if self.options.unit_name is None:
24+ print >>sys.stderr, "Unit name is not defined"
25+ return
26+ result = None
27+ try:
28+ result = yield self.client.relation_get(self.options.client_id,
29+ self.options.relation_id,
30+ self.options.unit_name,
31+ self.options.settings_name)
32+ except InvalidRelationIdentity, e:
33+ # This prevents the exception from getting wrapped by AMP
34+ print >>sys.stderr, e.relation_ident
35+ except Exception, e:
36+ print >>sys.stderr, str(e)
37 returnValue(result)
38
39 def format_shell(self, result, stream):
40@@ -83,10 +93,17 @@
41 self.parser.add_argument(
42 "-r", dest="relation_id", default="", metavar="RELATION ID")
43
44+ @inlineCallbacks
45 def run(self):
46- return self.client.relation_set(self.options.client_id,
47- self.options.relation_id,
48- self.options.keyvalue_pairs)
49+ try:
50+ yield self.client.relation_set(self.options.client_id,
51+ self.options.relation_id,
52+ self.options.keyvalue_pairs)
53+ except InvalidRelationIdentity, e:
54+ # This prevents the exception from getting wrapped by AMP
55+ print >>sys.stderr, e.relation_ident
56+ except Exception, e:
57+ print >>sys.stderr, str(e)
58
59
60 def relation_set():
61@@ -135,9 +152,18 @@
62 self.parser.add_argument(
63 "-r", dest="relation_id", default="", metavar="RELATION ID")
64
65+ @inlineCallbacks
66 def run(self):
67- return self.client.list_relations(self.options.client_id,
68- self.options.relation_id)
69+ result = None
70+ try:
71+ result = yield self.client.list_relations(self.options.client_id,
72+ self.options.relation_id)
73+ except InvalidRelationIdentity, e:
74+ # This prevents the exception from getting wrapped by AMP
75+ print >>sys.stderr, e.relation_ident
76+ except Exception, e:
77+ print >>sys.stderr, str(e)
78+ returnValue(result)
79
80 def format_eval(self, result, stream):
81 """ eval `juju-list` """
82
83=== modified file 'juju/hooks/invoker.py'
84--- juju/hooks/invoker.py 2012-07-01 18:38:54 +0000
85+++ juju/hooks/invoker.py 2012-10-01 03:59:18 +0000
86@@ -6,7 +6,7 @@
87 from twisted.python.failure import Failure
88
89 from juju import errors
90-from juju.state.errors import RelationStateNotFound
91+from juju.state.errors import RelationStateNotFound, InvalidRelationIdentity
92 from juju.state.hook import RelationHookContext
93
94
95@@ -239,7 +239,11 @@
96 try:
97 return self._relation_contexts[relation_ident]
98 except KeyError:
99- raise RelationStateNotFound()
100+ parts = relation_ident.split(":")
101+ if len(parts) != 2 or not parts[1].isdigit():
102+ raise InvalidRelationIdentity(relation_ident)
103+ else:
104+ raise RelationStateNotFound()
105
106 def get_relation_idents(self, relation_name):
107 return self._context.get_relation_idents(relation_name)
108
109=== modified file 'juju/hooks/protocol.py'
110--- juju/hooks/protocol.py 2012-09-15 14:13:27 +0000
111+++ juju/hooks/protocol.py 2012-10-01 03:59:18 +0000
112@@ -53,7 +53,8 @@
113 from juju.errors import JujuError
114 from juju.lib import serializer
115 from juju.lib.format import get_charm_formatter, get_charm_formatter_from_env
116-from juju.state.errors import UnitRelationStateNotFound
117+from juju.state.errors import (
118+ InvalidRelationIdentity, RelationStateNotFound, UnitRelationStateNotFound)
119 from juju.state.hook import RelationHookContext
120
121
122@@ -84,11 +85,15 @@
123
124
125 class BaseCommand(amp.Command):
126- errors = {NoSuchUnit: "NoSuchUnit",
127- NoSuchKey: "NoSuchKey",
128- NotRelationContext: "NotRelationContext",
129- UnitRelationStateNotFound: "UnitRelationStateNotFound",
130- MustSpecifyRelationName: "MustSpecifyRelationName"}
131+ errors = {
132+ NoSuchUnit: "NoSuchUnit",
133+ NoSuchKey: "NoSuchKey",
134+ NotRelationContext: "NotRelationContext",
135+ UnitRelationStateNotFound: "UnitRelationStateNotFound",
136+ MustSpecifyRelationName: "MustSpecifyRelationName",
137+ InvalidRelationIdentity: "InvalidRelationIdentity",
138+ RelationStateNotFound: "RelationStateNotFound"
139+ }
140
141
142 # All the commands below this point should be documented in the
143
144=== modified file 'juju/hooks/tests/test_invoker.py'
145--- juju/hooks/tests/test_invoker.py 2012-09-15 14:13:27 +0000
146+++ juju/hooks/tests/test_invoker.py 2012-10-01 03:59:18 +0000
147@@ -123,6 +123,21 @@
148 self.socket_path, self.server_factory)
149
150
151+def capture_separate_log(name, level):
152+ """Support the separate capture of logging at different log levels.
153+
154+ TestCase.capture_logging only allows one level to be captured at
155+ any given time. Given that the hook log captures both data AND
156+ traditional logging, it's useful to separate.
157+ """
158+ logger = logging.getLogger(name)
159+ output = StringIO()
160+ handler = logging.StreamHandler(output)
161+ handler.setLevel(level)
162+ logger.addHandler(handler)
163+ return output
164+
165+
166 def get_cli_environ_path(*search_path):
167 """Construct a path environment variable.
168
169@@ -198,6 +213,31 @@
170 os.chmod(fn, stat.S_IEXEC | stat.S_IREAD)
171 return fn
172
173+ def create_capturing_hook(self, hook, files=()):
174+ """Create a hook to enable capturing of results into files.
175+
176+ This method helps test scenarios of bash scripts that depend
177+ on exact captures of stdout and stderr as well as set -eu
178+ (Juju hook commands do not return nonzero exit codes, except
179+ in the case of parse failures).
180+
181+ bin-path (the path to Juju commands) is always defined, along
182+ with paths to temporary files, as keyed to the list of
183+ `files`.
184+ """
185+ args = {"bin-path": os.path.normpath(
186+ os.path.join(get_module_directory(juju), "..", "bin"))}
187+ for f in files:
188+ args[f] = self.makeFile()
189+ hook_fn = self.makeFile(hook.format(**args))
190+ os.chmod(hook_fn, stat.S_IEXEC | stat.S_IREAD)
191+ return hook_fn, args
192+
193+ def assert_file(self, path, data):
194+ """Assert file reached by `path` contains exactly this `data`."""
195+ with open(path) as f:
196+ self.assertEqual(f.read(), data)
197+
198
199 class TestCompleteInvoker(InvokerTestBase, StatusTestBase):
200
201@@ -1211,6 +1251,29 @@
202 hook_log.getvalue())
203
204 @defer.inlineCallbacks
205+ def test_relation_ids_nonexistent_relation_name(self):
206+ """Verify an empty listing is returned if name does not exist"""
207+ hook_log = self.capture_logging("hook", level=logging.DEBUG)
208+ yield self.add_a_blog("wordpress2")
209+ exe = yield self.ua.get_invoker(
210+ "db:0", "add", "mysql/0", self.relation, client_id="client_id")
211+ hook, args = self.create_capturing_hook(
212+ "#!/bin/bash\n"
213+ "set -eu\n"
214+ "data=$({bin-path}/relation-ids does-not-exist 2> {stderr})\n"
215+ "echo -n $data > {stdout}\n",
216+ files=["stdout", "stderr"])
217+ result = yield exe(hook)
218+ self.assertEqual(result, 0)
219+ yield exe.ended
220+ self.assert_file(args["stdout"], "")
221+ self.assert_file(args["stderr"], "")
222+ self.assertEqual(
223+ hook_log.getvalue(),
224+ "Cached relation hook contexts on 'db:0': ['db:1']\n"
225+ "hook {} exited, exit code 0.\n".format(os.path.basename(hook)))
226+
227+ @defer.inlineCallbacks
228 def test_relation_set_with_relation_id_option(self):
229 """Verify `relation-set` works with -r option."""
230 # Invoker will be in the context of the db:0 relation
231@@ -1238,6 +1301,57 @@
232 " Setting changed: u'b'=u'xyz' (was unset) on 'db:1'"])
233
234 @defer.inlineCallbacks
235+ def test_relation_set_with_nonexistent_relation_id(self):
236+ """Verify error put on stderr when using nonexistent relation id."""
237+ hook_log = self.capture_logging("hook", level=logging.DEBUG)
238+ yield self.add_a_blog("wordpress2")
239+ exe = yield self.ua.get_invoker(
240+ "db:0", "add", "mysql/0", self.relation,
241+ client_id="client_id")
242+ hook, args = self.create_capturing_hook(
243+ "#!/bin/bash\n"
244+ "set -eu\n"
245+ "data=$({bin-path}/relation-set -r db:12345 "
246+ "k1=v1 k2=v2 2> {stderr})\n"
247+ "echo -n $data > {stdout}\n",
248+ files=["stdout", "stderr"])
249+ result = yield exe(hook)
250+ self.assertEqual(result, 0)
251+ yield exe.ended
252+ self.assert_file(args["stdout"], "")
253+ self.assert_file(args["stderr"], "Relation not found\n")
254+ self.assertEqual(
255+ hook_log.getvalue(),
256+ "Cached relation hook contexts on 'db:0': ['db:1']\n"
257+ "hook {} exited, exit code 0.\n".format(os.path.basename(hook)))
258+
259+ @defer.inlineCallbacks
260+ def test_relation_set_with_invalid_relation_id(self):
261+ """Verify message is written to stderr for invalid formatted id."""
262+ hook_log = self.capture_logging("hook", level=logging.DEBUG)
263+ yield self.add_a_blog("wordpress2")
264+ exe = yield self.ua.get_invoker(
265+ "db:0", "add", "mysql/0", self.relation, client_id="client_id")
266+ exe.environment["JUJU_REMOTE_UNIT"] = "wordpress/0"
267+ hook, args = self.create_capturing_hook(
268+ "#!/bin/bash\n"
269+ "set -eu\n"
270+ "data=$({bin-path}/relation-set -r invalid-id:forty-two "
271+ "k1=v1 k2=v2 2> {stderr})\n"
272+ "echo -n $data > {stdout}\n",
273+ files=["stderr", "stdout"])
274+ result = yield exe(hook)
275+ self.assertEqual(result, 0)
276+ yield exe.ended
277+ self.assert_file(args["stdout"], "")
278+ self.assert_file(args["stderr"],
279+ "Not a valid relation id: 'invalid-id:forty-two'\n")
280+ self.assertEqual(
281+ hook_log.getvalue(),
282+ "Cached relation hook contexts on 'db:0': ['db:1']\n"
283+ "hook {} exited, exit code 0.\n".format(os.path.basename(hook)))
284+
285+ @defer.inlineCallbacks
286 def test_relation_get_with_relation_id_option(self):
287 """Verify `relation-get` works with -r option."""
288 yield self.add_a_blog("wordpress2")
289@@ -1264,6 +1378,81 @@
290 "name": "whiterabbit"})
291
292 @defer.inlineCallbacks
293+ def test_relation_get_with_nonexistent_relation_id(self):
294+ """Verify error put on stderr when using nonexistent relation id."""
295+ hook_log = self.capture_logging("hook", level=logging.DEBUG)
296+ yield self.add_a_blog("wordpress2")
297+ exe = yield self.ua.get_invoker(
298+ "db:0", "add", "mysql/0", self.relation,
299+ client_id="client_id")
300+ hook, args = self.create_capturing_hook(
301+ "#!/bin/bash\n"
302+ "set -eu\n"
303+ "settings=$({bin-path}/relation-get -r db:12345 - mysql/0 "
304+ "2> {stderr})\n"
305+ "echo -n $settings > {settings}\n",
306+ files=["settings", "stderr"])
307+ result = yield exe(hook)
308+ self.assertEqual(result, 0)
309+ yield exe.ended
310+ self.assert_file(args["settings"], "")
311+ self.assert_file(args["stderr"], "Relation not found\n")
312+ self.assertEqual(
313+ hook_log.getvalue(),
314+ "Cached relation hook contexts on 'db:0': ['db:1']\n"
315+ "hook {} exited, exit code 0.\n".format(os.path.basename(hook)))
316+
317+ @defer.inlineCallbacks
318+ def test_relation_get_with_invalid_relation_id(self):
319+ """Verify message is written to stderr for invalid formatted id."""
320+ hook_log = self.capture_logging("hook", level=logging.DEBUG)
321+ yield self.add_a_blog("wordpress2")
322+ exe = yield self.ua.get_invoker(
323+ "db:0", "add", "mysql/0", self.relation, client_id="client_id")
324+ exe.environment["JUJU_REMOTE_UNIT"] = "wordpress/0"
325+ hook, args = self.create_capturing_hook(
326+ "#!/bin/bash\n"
327+ "set -eu\n"
328+ "data=$({bin-path}/relation-get -r invalid-id:forty-two - "
329+ "2> {stderr})\n"
330+ "echo -n $data > {stdout}\n",
331+ files=["stderr", "stdout"])
332+ result = yield exe(hook)
333+ self.assertEqual(result, 0)
334+ yield exe.ended
335+ self.assert_file(args["stderr"],
336+ "Not a valid relation id: 'invalid-id:forty-two'\n")
337+ self.assert_file(args["stdout"], "")
338+ self.assertEqual(
339+ hook_log.getvalue(),
340+ "Cached relation hook contexts on 'db:0': ['db:1']\n"
341+ "hook {} exited, exit code 0.\n".format(os.path.basename(hook)))
342+
343+ @defer.inlineCallbacks
344+ def test_relation_get_unset_remote_unit_in_env(self):
345+ """Verify error is reported if JUJU_REMOTE_UNIT is not defined."""
346+ hook_log = self.capture_logging("hook", level=logging.DEBUG)
347+ yield self.add_a_blog("wordpress2")
348+ exe = yield self.ua.get_invoker(
349+ "db:0", "add", "mysql/0", self.relation, client_id="client_id")
350+ self.assertNotIn("JUJU_REMOTE_UNIT", exe.environment)
351+ hook, args = self.create_capturing_hook(
352+ "#!/bin/bash\n"
353+ "set -eu\n"
354+ "data=$({bin-path}/relation-get -r db:0 - 2> {stderr})\n"
355+ "echo -n $data > {stdout}\n",
356+ files=["stderr", "stdout"])
357+ result = yield exe(hook)
358+ self.assertEqual(result, 0)
359+ yield exe.ended
360+ self.assert_file(args["stdout"], "")
361+ self.assert_file(args["stderr"], "Unit name is not defined\n")
362+ self.assertEqual(
363+ hook_log.getvalue(),
364+ "Cached relation hook contexts on 'db:0': ['db:1']\n"
365+ "hook {} exited, exit code 0.\n".format(os.path.basename(hook)))
366+
367+ @defer.inlineCallbacks
368 def test_relation_list_with_relation_id_option(self):
369 """Verify `relation-list` works with -r option."""
370 yield self.add_a_blog("wordpress2")
371@@ -1283,6 +1472,55 @@
372 json.loads(hook_log.getvalue()),
373 ["wordpress2/0"])
374
375+ @defer.inlineCallbacks
376+ def test_relation_list_with_nonexistent_relation_id(self):
377+ """Verify a nonexistent relation id writes message to stderr."""
378+ hook_log = self.capture_logging("hook", level=logging.DEBUG)
379+ yield self.add_a_blog("wordpress2")
380+ exe = yield self.ua.get_invoker(
381+ "db:0", "add", "mysql/0", self.relation, client_id="client_id")
382+ hook, args = self.create_capturing_hook(
383+ "#!/bin/bash\n"
384+ "set -eu\n"
385+ "data=$({bin-path}/relation-list -r db:12345 2> {stderr})\n"
386+ "echo -n $data > {stdout}\n",
387+ files=["stdout", "stderr"])
388+ result = yield exe(hook)
389+ self.assertEqual(result, 0)
390+ yield exe.ended
391+ self.assert_file(args["stdout"], "")
392+ self.assert_file(args["stderr"], "Relation not found\n")
393+ self.assertEqual(
394+ hook_log.getvalue(),
395+ "Cached relation hook contexts on 'db:0': ['db:1']\n"
396+ "hook {} exited, exit code 0.\n".format(os.path.basename(hook)))
397+
398+ @defer.inlineCallbacks
399+ def test_relation_list_with_invalid_relation_id(self):
400+ """Verify message is written to stderr for invalid formatted id."""
401+ hook_log = self.capture_logging("hook", level=logging.DEBUG)
402+ yield self.add_a_blog("wordpress2")
403+ exe = yield self.ua.get_invoker(
404+ "db:0", "add", "mysql/0", self.relation, client_id="client_id")
405+ exe.environment["JUJU_REMOTE_UNIT"] = "wordpress/0"
406+ hook, args = self.create_capturing_hook(
407+ "#!/bin/bash\n"
408+ "set -eu\n"
409+ "data=$({bin-path}/relation-list -r invalid-id:forty-two "
410+ "2> {stderr})\n"
411+ "echo -n $data > {stdout}\n",
412+ files=["stderr", "stdout"])
413+ result = yield exe(hook)
414+ self.assertEqual(result, 0)
415+ yield exe.ended
416+ self.assert_file(args["stdout"], "")
417+ self.assert_file(args["stderr"],
418+ "Not a valid relation id: 'invalid-id:forty-two'\n")
419+ self.assertEqual(
420+ hook_log.getvalue(),
421+ "Cached relation hook contexts on 'db:0': ['db:1']\n"
422+ "hook {} exited, exit code 0.\n".format(os.path.basename(hook)))
423+
424
425 class PortCommandsTest(RelationInvokerTestBase):
426
427@@ -1564,21 +1802,6 @@
428 "closed 80/tcp"])
429
430
431-def capture_separate_log(name, level):
432- """Support the separate capture of logging at different log levels.
433-
434- TestCase.capture_logging only allows one level to be captured at
435- any given time. Given that the hook log captures both data AND
436- traditional logging, it's useful to separate.
437- """
438- logger = logging.getLogger(name)
439- output = StringIO()
440- handler = logging.StreamHandler(output)
441- handler.setLevel(level)
442- logger.addHandler(handler)
443- return output
444-
445-
446 class TestCharmFormatV1(RelationInvokerTestBase):
447
448 @defer.inlineCallbacks
449@@ -1641,6 +1864,7 @@
450 yield exe(self.create_hook(
451 "relation-set", "d=@%s" % data_file))
452 result = yield exe(self.create_hook("relation-get", "d mysql/0"))
453+ self.assertEqual(result, 1)
454 self.assertIn(
455 "Error: \'utf8\' codec can\'t decode byte 0xca in position 30: "
456 "invalid continuation byte",

Subscribers

People subscribed via source and target branches

to status/vote changes: