Merge lp:~frankban/juju-deployer/unit-errors into lp:juju-deployer

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 97
Proposed branch: lp:~frankban/juju-deployer/unit-errors
Merge into: lp:juju-deployer
Diff against target: 903 lines (+462/-170)
12 files modified
deployer/action/importer.py (+23/-16)
deployer/cli.py (+4/-0)
deployer/deployment.py (+4/-0)
deployer/env/go.py (+44/-56)
deployer/env/mem.py (+1/-2)
deployer/env/py.py (+20/-14)
deployer/env/watchers.py (+110/-0)
deployer/guiserver.py (+8/-0)
deployer/tests/test_deployment.py (+8/-0)
deployer/tests/test_guiserver.py (+48/-32)
deployer/tests/test_pyenv.py (+46/-50)
deployer/tests/test_watchers.py (+146/-0)
To merge this branch: bzr merge lp:~frankban/juju-deployer/unit-errors
Reviewer Review Type Date Requested Status
Brad Crittenden (community) code Approve
juju-deployers Pending
Review via email: mp+206967@code.launchpad.net

Commit message

Proceed with bundle deployments on unit errors.

Description of the change

Proceed with bundle deployments on unit errors.

This branch introduces a new watcher implementation
which allows for 1) watching the deployment process for
a subset of all the environment units, e.g. those
belonging to the bundle services and 2) react to unit
errors in different ways (e.g., a flag can be used to
make the importer proceed even if there are errors).

The current default behavior is maintained. The guiserver
code path instead configures the importer to complete the
deployment even if units are in an error state.

To post a comment you must log in.
Revision history for this message
Brad Crittenden (bac) wrote :

Might be nice to mention in the help for '--ignore-errors' the relationship to retry_count.

Otherwise this branch looks really good. I'm -0 on changes like 'options = self.options' in existing code due to the noise for no real benefit, but "meh" as you say. :)

review: Approve (code)
97. By Francesco Banconi

Changes as per review.

Revision history for this message
Francesco Banconi (frankban) wrote :

> Might be nice to mention in the help for '--ignore-errors' the relationship to
> retry_count.

Done.

> Otherwise this branch looks really good. I'm -0 on changes like 'options =
> self.options' in existing code due to the noise for no real benefit, but "meh"
> as you say. :)

Yeah, those kind of changes a far more readable e.g. in rietveld, where word-based diff helps reducing the noise.
Thanks for the review Brad.

Revision history for this message
Kapil Thangavelu (hazmat) wrote :

the watchers.log_on/warn_on/error_on seem extraneous given the feedback, and also at the wrong layer (part of the deployment via feedback api vs part of the environment interaction api).

98. By Francesco Banconi

Merge trunk and resolve conflicts.

99. By Francesco Banconi

Fix tests.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'deployer/action/importer.py'
2--- deployer/action/importer.py 2014-01-23 14:14:28 +0000
3+++ deployer/action/importer.py 2014-02-20 12:33:52 +0000
4@@ -2,6 +2,7 @@
5 import time
6
7 from .base import BaseAction
8+from ..env import watchers
9 from ..errors import UnitErrors
10 from ..utils import ErrorExit
11
12@@ -158,27 +159,32 @@
13 return True
14 return False
15
16- def wait_for_units(self, ignore_error=False):
17+ def wait_for_units(self, ignore_errors=False):
18 timeout = self.options.timeout - (time.time() - self.start_time)
19 if timeout < 0:
20 self.log.error("Reached deployment timeout.. exiting")
21 raise ErrorExit()
22- try:
23- self.env.wait_for_units(
24- int(timeout), watch=self.options.watch, no_exit=ignore_error)
25- except UnitErrors:
26- if not ignore_error:
27- raise
28+ # Set up the callback to be called in case of unit errors: if
29+ # ignore_errors is True errors are just logged, otherwise we exit the
30+ # program.
31+ if ignore_errors:
32+ on_errors = watchers.log_on_errors(self.env)
33+ else:
34+ on_errors = watchers.exit_on_errors(self.env)
35+ self.env.wait_for_units(
36+ int(timeout), watch=self.options.watch,
37+ services=self.deployment.get_service_names(), on_errors=on_errors)
38
39 def run(self):
40+ options = self.options
41 self.start_time = time.time()
42- if self.options.bootstrap:
43+ if options.bootstrap:
44 self.env.bootstrap()
45 self.env.connect()
46
47 # Get charms
48 self.get_charms()
49- if self.options.branch_only:
50+ if options.branch_only:
51 return
52
53 self.deploy_services()
54@@ -189,20 +195,21 @@
55 time.sleep(5.1)
56 self.add_units()
57
58+ ignore_errors = bool(options.retry_count) or options.ignore_errors
59 self.log.debug("Waiting for units before adding relations")
60- self.wait_for_units()
61+ self.wait_for_units(ignore_errors=ignore_errors)
62
63 rels_created = self.add_relations()
64
65 # Wait for the units to be up before waiting for rel stability.
66 if rels_created:
67 self.log.debug(
68- "Waiting for relation convergence %ds", self.options.rel_wait)
69- time.sleep(self.options.rel_wait)
70- self.wait_for_units(self.options.retry_count)
71+ "Waiting for relation convergence %ds", options.rel_wait)
72+ time.sleep(options.rel_wait)
73+ self.wait_for_units(ignore_errors=ignore_errors)
74
75- if self.options.retry_count:
76+ if options.retry_count:
77 self.log.info("Looking for errors to auto-retry")
78 self.env.resolve_errors(
79- self.options.retry_count,
80- self.options.timeout - time.time() - self.start_time)
81+ options.retry_count,
82+ options.timeout - time.time() - self.start_time)
83
84=== modified file 'deployer/cli.py'
85--- deployer/cli.py 2014-01-23 15:02:57 +0000
86+++ deployer/cli.py 2014-02-20 12:33:52 +0000
87@@ -102,6 +102,10 @@
88 help=("Resolve CLI and unit errors via number of retries (default: 0)."
89 " Either standalone or in a deployment"))
90 parser.add_argument(
91+ '--ignore-errors', action='store_true', dest='ignore_errors',
92+ help='Proceed with the bundle deployment ignoring units errors. '
93+ 'Unit errors are also automatically ignored if --retry != 0')
94+ parser.add_argument(
95 "--diff", action="store_true", default=False,
96 help=("Generate a delta between a configured deployment and a running"
97 " environment."))
98
99=== modified file 'deployer/deployment.py'
100--- deployer/deployment.py 2013-11-20 05:08:15 +0000
101+++ deployer/deployment.py 2014-02-20 12:33:52 +0000
102@@ -47,6 +47,10 @@
103 services.sort(self._placement_sort)
104 return services
105
106+ def get_service_names(self):
107+ """Return a sequence of service names for this deployment."""
108+ return self.data.get('services', {}).keys()
109+
110 @staticmethod
111 def _placement_sort(svc_a, svc_b):
112 if svc_a.unit_placement:
113
114=== modified file 'deployer/env/go.py'
115--- deployer/env/go.py 2013-11-27 21:05:17 +0000
116+++ deployer/env/go.py 2014-02-20 12:33:52 +0000
117@@ -2,14 +2,19 @@
118 import socket
119 import time
120
121-from .base import BaseEnvironment
122-from ..utils import ErrorExit
123-
124 from jujuclient import (
125+ EnvError,
126 Environment as EnvironmentClient,
127 UnitErrors,
128- EnvError,
129- WatchWrapper)
130+)
131+
132+from .base import BaseEnvironment
133+from ..utils import ErrorExit
134+from .watchers import (
135+ raise_on_errors,
136+ WaitForMachineTermination,
137+ WaitForUnits,
138+)
139
140
141 class GoEnvironment(BaseEnvironment):
142@@ -45,8 +50,8 @@
143 while True:
144 try:
145 self.client = EnvironmentClient(self.api_endpoint)
146- except socket.error, e:
147- if e.errno != errno.ETIMEDOUT:
148+ except socket.error as err:
149+ if err.errno != errno.ETIMEDOUT:
150 raise
151 continue
152 else:
153@@ -60,11 +65,10 @@
154 def get_constraints(self, svc_name):
155 try:
156 return self.client.get_constraints(svc_name)
157- except EnvError, e:
158- if 'constraints do not apply to subordinate services' in str(e):
159+ except EnvError as err:
160+ if 'constraints do not apply to subordinate services' in str(err):
161 return {}
162- else:
163- raise e
164+ raise
165
166 def get_cli_status(self):
167 status = super(GoEnvironment, self).get_cli_status()
168@@ -94,7 +98,7 @@
169 self.resolve_errors()
170
171 # Wait for units
172- self.wait_for_units(timeout, "removed", watch=watch)
173+ self.wait_for_units(timeout, goal_state='removed', watch=watch)
174
175 # The only value to not terminating is keeping the data on the
176 # machines around.
177@@ -182,8 +186,8 @@
178 try:
179 self.client.resolved(e_uid, retry=bool(retry_count))
180 self.log.debug(" Resolving error on %s", e_uid)
181- except EnvError, e:
182- if 'already resolved' in e:
183+ except EnvError as err:
184+ if 'already resolved' in err:
185 continue
186
187 if not error_units:
188@@ -200,12 +204,13 @@
189 count += 1
190 try:
191 self.wait_for_units(
192- timeout=int(w_timeout), watch=True, no_exit=True)
193- except UnitErrors, e:
194+ timeout=int(w_timeout), watch=True,
195+ on_errors=raise_on_errors(UnitErrors))
196+ except UnitErrors as err:
197 if retry_count == count:
198 self.log.info(
199 " Retry count %d exhausted, but units in error (%s)",
200- retry_count, " ".join(u['Name'] for u in e.errors))
201+ retry_count, " ".join(u['Name'] for u in err.errors))
202 return
203 else:
204 return
205@@ -216,24 +221,29 @@
206 def status(self):
207 return self.client.get_stat()
208
209+ def log_errors(self, errors):
210+ """Log the given unit errors.
211+
212+ This can be used in the WaitForUnits error handling machinery, e.g.
213+ see deployer.watchers.log_on_errors.
214+ """
215+ messages = [
216+ 'unit: {Name}: machine: {MachineId} agent-state: {Status} '
217+ 'details: {StatusInfo}'.format(**error) for error in errors
218+ ]
219+ self.log.error(
220+ 'The following units had errors:\n {}'.format(
221+ ' \n'.join(messages)))
222+
223 def wait_for_units(
224- self, timeout, goal_state="started", watch=False, no_exit=False):
225- """Wait for units to reach a given condition.
226- """
227- callback = watch and self._delta_event_log or None
228- try:
229- self.client.wait_for_units(timeout, goal_state, callback=callback)
230- except UnitErrors, e:
231- error_units = [
232- "unit: %s: machine: %s agent-state: %s details: %s" % (
233- u['Name'], u['MachineId'], u['Status'], u['StatusInfo']
234- )
235- for u in e.errors]
236- if no_exit:
237- raise
238- self.log.error("The following units had errors:\n %s" % (
239- " \n".join(error_units)))
240- raise ErrorExit()
241+ self, timeout, goal_state="started", watch=False, services=None,
242+ on_errors=None):
243+ """Wait for units to reach a given condition."""
244+ callback = self._delta_event_log if watch else None
245+ watcher = self.client.get_watch(timeout)
246+ WaitForUnits(
247+ watcher, goal_state=goal_state,
248+ services=services, on_errors=on_errors).run(callback)
249
250 def _delta_event_log(self, et, ct, d):
251 # event type, change type, data
252@@ -260,25 +270,3 @@
253 eps[0]['Relation']['Name'],
254 eps[1]['ServiceName'],
255 eps[1]['Relation']['Name'])
256-
257-
258-class WaitForMachineTermination(WatchWrapper):
259-
260- def __init__(self, watch, machines):
261- super(WaitForMachineTermination, self).__init__(watch)
262- self.machines = set(machines)
263- self.known = set()
264-
265- def process(self, entity_type, change, data):
266- if entity_type != 'machine':
267- return
268- if change == 'remove' and data['Id'] in self.machines:
269- self.machines.remove(data['Id'])
270- else:
271- self.known.add(data['Id'])
272-
273- def complete(self):
274- for m in self.machines:
275- if m in self.known:
276- return False
277- return True
278
279=== modified file 'deployer/env/mem.py'
280--- deployer/env/mem.py 2013-11-29 22:21:54 +0000
281+++ deployer/env/mem.py 2014-02-20 12:33:52 +0000
282@@ -168,6 +168,5 @@
283 """ Return all services status """
284 return {'services': self._services}
285
286- def wait_for_units(
287- self, timeout, goal_state="started", watch=False, no_exit=False):
288+ def wait_for_units(self, *args, **kwargs):
289 pass
290
291=== modified file 'deployer/env/py.py'
292--- deployer/env/py.py 2013-07-30 23:39:51 +0000
293+++ deployer/env/py.py 2014-02-20 12:33:52 +0000
294@@ -77,9 +77,26 @@
295 def status(self):
296 return self.get_cli_status()
297
298+ def log_errors(self, errors):
299+ """Log the given unit errors.
300+
301+ This can be used in the WaitForUnits error handling machinery, e.g.
302+ see deployer.watchers.log_on_errors.
303+ """
304+ messages = [
305+ 'unit: {unit[name]}: machine: {unit[machine]} '
306+ 'agent-state: {unit[agent-state]}'.format(unit=error)
307+ for error in errors
308+ ]
309+ self.log.error(
310+ 'The following units had errors:\n {}'.format(
311+ ' \n'.join(messages)))
312+
313 def wait_for_units(
314- self, timeout, goal_state="started", watch=False, no_exit=False):
315-
316+ self, timeout, goal_state="started", watch=False, services=None,
317+ on_errors=None):
318+ # Note that the services keyword argument is ignored in this pyJuju
319+ # implementation: we wait for all the units in the environment.
320 max_time = time.time() + timeout
321 while max_time > time.time():
322 status = self.status()
323@@ -111,15 +128,4 @@
324 if not pending and not errors:
325 break
326 if errors:
327- if no_exit:
328- raise UnitErrors(errors)
329- else:
330- error_units = [
331- "unit: %s: machine: %s agent-state: %s" % (
332- u["name"], u["machine"], u["agent-state"]
333- )
334- for u in errors]
335- self.log.error(
336- "The following units had errors:\n %s" % (
337- " \n".join(error_units)))
338- raise ErrorExit()
339+ on_errors(errors)
340
341=== added file 'deployer/env/watchers.py'
342--- deployer/env/watchers.py 1970-01-01 00:00:00 +0000
343+++ deployer/env/watchers.py 2014-02-20 12:33:52 +0000
344@@ -0,0 +1,110 @@
345+"""A collection of juju-core environment watchers."""
346+
347+from jujuclient import WatchWrapper
348+
349+from ..utils import ErrorExit
350+
351+
352+class WaitForMachineTermination(WatchWrapper):
353+ """Wait until the given machines are terminated."""
354+
355+ def __init__(self, watch, machines):
356+ super(WaitForMachineTermination, self).__init__(watch)
357+ self.machines = set(machines)
358+ self.known = set()
359+
360+ def process(self, entity_type, change, data):
361+ if entity_type != 'machine':
362+ return
363+ if change == 'remove' and data['Id'] in self.machines:
364+ self.machines.remove(data['Id'])
365+ else:
366+ self.known.add(data['Id'])
367+
368+ def complete(self):
369+ for m in self.machines:
370+ if m in self.known:
371+ return False
372+ return True
373+
374+
375+class WaitForUnits(WatchWrapper):
376+ """Wait for units of the environment to reach a particular goal state.
377+
378+ If services are provided, only consider the units belonging to the given
379+ services.
380+ If the on_errors callable is provided, call the given function each time a
381+ change set is processed and a new unit is found in an error state. The
382+ callable is called passing a list of units' data corresponding to the units
383+ in an error state.
384+ """
385+ def __init__(
386+ self, watch, goal_state='started', services=None, on_errors=None):
387+ super(WaitForUnits, self).__init__(watch)
388+ self.goal_state = goal_state
389+ self.services = services
390+ self.on_errors = on_errors
391+ # The units dict maps unit names to units data.
392+ self.units = {}
393+ # The units_in_error list contains the names of the units in error.
394+ self.units_in_error = []
395+
396+ def process(self, entity, action, data):
397+ if entity != 'unit':
398+ return
399+ if (self.services is None) or (data['Service'] in self.services):
400+ unit_name = data['Name']
401+ if action == 'remove' and unit_name in self.units:
402+ del self.units[unit_name]
403+ else:
404+ self.units[unit_name] = data
405+
406+ def complete(self):
407+ ready = True
408+ new_errors = []
409+ goal_state = self.goal_state
410+ on_errors = self.on_errors
411+ units_in_error = self.units_in_error
412+ for unit_name, data in self.units.items():
413+ status = data['Status']
414+ if status == 'error':
415+ if unit_name not in units_in_error:
416+ units_in_error.append(unit_name)
417+ new_errors.append(data)
418+ elif status != goal_state:
419+ ready = False
420+ if new_errors and goal_state != 'removed' and callable(on_errors):
421+ on_errors(new_errors)
422+ return ready
423+
424+
425+def log_on_errors(env):
426+ """Return a function receiving errors and logging them.
427+
428+ The resulting function is suitable to be used as the on_errors callback
429+ for WaitForUnits (see above).
430+ """
431+ return env.log_errors
432+
433+
434+def exit_on_errors(env):
435+ """Return a function receiving errors, logging them and exiting the app.
436+
437+ The resulting function is suitable to be used as the on_errors callback
438+ for WaitForUnits (see above).
439+ """
440+ def callback(errors):
441+ log_on_errors(env)(errors)
442+ raise ErrorExit()
443+ return callback
444+
445+
446+def raise_on_errors(exception):
447+ """Return a function receiving errors and raising the given exception.
448+
449+ The resulting function is suitable to be used as the on_errors callback
450+ for WaitForUnits (see above).
451+ """
452+ def callback(errors):
453+ raise exception(errors)
454+ return callback
455
456=== modified file 'deployer/guiserver.py'
457--- deployer/guiserver.py 2014-02-12 21:00:47 +0000
458+++ deployer/guiserver.py 2014-02-20 12:33:52 +0000
459@@ -12,6 +12,7 @@
460 import os
461
462 from deployer.action.importer import Importer
463+from deployer.cli import setup_parser
464 from deployer.deployment import Deployment
465 from deployer.env.gui import GUIEnvironment
466 from deployer.utils import (
467@@ -26,6 +27,13 @@
468 JUJU_HOME = '/var/lib/juju-gui/juju-home'
469
470
471+def get_default_guiserver_options():
472+ """Return the default importer options used by the GUI server."""
473+ # Options used by the juju-deployer. The defaults work for us, except for
474+ # the ignore_errors flag.
475+ return setup_parser().parse_args(['--ignore-errors'])
476+
477+
478 class GUIDeployment(Deployment):
479 """Handle bundle deployments requested by the GUI server."""
480
481
482=== modified file 'deployer/tests/test_deployment.py'
483--- deployer/tests/test_deployment.py 2014-01-23 12:55:11 +0000
484+++ deployer/tests/test_deployment.py 2014-02-20 12:33:52 +0000
485@@ -148,3 +148,11 @@
486 self.assertEqual(
487 [('keystone', 'mysql'), ('glance', 'mysql'),
488 ('nova-compute', 'mysql')], list(d.get_relations()))
489+
490+ def test_getting_service_names(self):
491+ # It is possible to retrieve the service names.
492+ deployment = self.get_named_deployment("stack-placement.yaml", "stack")
493+ service_names = deployment.get_service_names()
494+ expected_service_names = [
495+ 'ceph', 'mysql', 'nova-compute', 'quantum', 'semper', 'verity']
496+ self.assertEqual(set(expected_service_names), set(service_names))
497
498=== modified file 'deployer/tests/test_guiserver.py'
499--- deployer/tests/test_guiserver.py 2014-02-20 01:20:23 +0000
500+++ deployer/tests/test_guiserver.py 2014-02-20 12:33:52 +0000
501@@ -10,14 +10,57 @@
502 import mock
503 import yaml
504
505-from deployer import (
506- cli,
507- guiserver,
508-)
509+from deployer import guiserver
510 from deployer.feedback import Feedback
511 from deployer.tests.base import TEST_OFFLINE
512
513
514+class TestGetDefaultGuiserverOptions(unittest.TestCase):
515+
516+ def setUp(self):
517+ self.options = guiserver.get_default_guiserver_options()
518+
519+ def test_option_keys(self):
520+ # All the required options are returned.
521+ # When adding/modifying options, ensure the defaults are sane for us.
522+ expected_keys = set([
523+ 'bootstrap', 'branch_only', 'configs', 'debug', 'deploy_delay',
524+ 'deployment', 'description', 'destroy_services', 'diff',
525+ 'find_service', 'ignore_errors', 'juju_env', 'list_deploys',
526+ 'no_local_mods', 'overrides', 'rel_wait', 'retry_count', 'series',
527+ 'terminate_machines', 'timeout', 'update_charms', 'verbose',
528+ 'watch'
529+ ])
530+ self.assertEqual(expected_keys, set(self.options.__dict__.keys()))
531+
532+ def test_option_values(self):
533+ # The options values are suitable to be used by the GUI server.
534+ # When adding/modifying options, ensure the defaults are sane for us.
535+ options = self.options
536+ self.assertFalse(options.bootstrap)
537+ self.assertFalse(options.branch_only)
538+ self.assertIsNone(options.configs)
539+ self.assertFalse(options.debug)
540+ self.assertEqual(0, options.deploy_delay)
541+ self.assertIsNone(options.deployment)
542+ self.assertFalse(options.destroy_services)
543+ self.assertFalse(options.diff)
544+ self.assertIsNone(options.find_service)
545+ self.assertTrue(options.ignore_errors)
546+ self.assertEqual(os.getenv("JUJU_ENV"), options.juju_env)
547+ self.assertFalse(options.list_deploys)
548+ self.assertTrue(options.no_local_mods)
549+ self.assertIsNone(options.overrides)
550+ self.assertEqual(60, options.rel_wait)
551+ self.assertEqual(0, options.retry_count)
552+ self.assertIsNone(options.series)
553+ self.assertFalse(options.terminate_machines)
554+ self.assertEqual(2700, options.timeout)
555+ self.assertFalse(options.update_charms)
556+ self.assertFalse(options.verbose)
557+ self.assertFalse(options.watch)
558+
559+
560 class TestDeploymentError(unittest.TestCase):
561
562 def test_error(self):
563@@ -152,7 +195,7 @@
564 class TestImportBundle(DeployerFunctionsTestMixin, unittest.TestCase):
565
566 # The options attribute simulates the options passed to the Importer.
567- options = cli.setup_parser().parse_args([])
568+ options = guiserver.get_default_guiserver_options()
569
570 @contextmanager
571 def patch_juju_home(self):
572@@ -274,30 +317,3 @@
573 'Invalid config charm cs:precise/mysql-28 bad=wolf',
574 ])
575 self.assertEqual(expected_errors, set(cm.exception.errors))
576-
577- def test_options(self, mock_environment):
578- # Ensure the default options are sane for us.
579- expected = argparse.Namespace(
580- bootstrap=False,
581- branch_only=False,
582- configs=None,
583- debug=False,
584- deploy_delay=0,
585- deployment=None,
586- description=False,
587- destroy_services=False,
588- diff=False,
589- find_service=None,
590- juju_env=os.environ.get("JUJU_ENV"),
591- list_deploys=False,
592- no_local_mods=True,
593- overrides=None,
594- rel_wait=60,
595- retry_count=0,
596- series=None,
597- terminate_machines=False,
598- timeout=2700,
599- update_charms=False,
600- verbose=False,
601- watch=False)
602- self.assertEqual(expected, self.options)
603
604=== modified file 'deployer/tests/test_pyenv.py'
605--- deployer/tests/test_pyenv.py 2013-07-22 15:29:31 +0000
606+++ deployer/tests/test_pyenv.py 2014-02-20 12:33:52 +0000
607@@ -1,14 +1,14 @@
608-
609 import StringIO
610
611+from jujuclient import UnitErrors
612+
613+from .base import Base
614+from deployer.env import watchers
615 from deployer.env.py import PyEnvironment
616 from deployer.errors import UnitErrors
617 from deployer.utils import setup_logging, ErrorExit
618
619
620-from .base import Base
621-
622-
623 class FakePyEnvironment(PyEnvironment):
624
625 def __init__(self, name, status):
626@@ -38,16 +38,15 @@
627 },
628 },
629 })
630- try:
631- env.wait_for_units(timeout=240, no_exit=True)
632- except UnitErrors, e:
633- self.assertEqual(1, len(e.errors))
634- unit = e.errors[0]
635- self.assertEqual("wordpress/0", unit["name"])
636- self.assertEqual("install-error", unit["agent-state"])
637- self.assertEqual(1, unit["machine"])
638- else:
639- self.fail("Did not get expected exception")
640+ with self.assertRaises(UnitErrors) as cm:
641+ env.wait_for_units(
642+ timeout=240, on_errors=watchers.raise_on_errors(UnitErrors))
643+ errors = cm.exception.errors
644+ self.assertEqual(1, len(errors))
645+ unit = errors[0]
646+ self.assertEqual("wordpress/0", unit["name"])
647+ self.assertEqual("install-error", unit["agent-state"])
648+ self.assertEqual(1, unit["machine"])
649
650 def test_wait_for_units_error_exit(self):
651 env = FakePyEnvironment(
652@@ -62,16 +61,13 @@
653 },
654 },
655 })
656- try:
657- env.wait_for_units(timeout=240, no_exit=False)
658- except ErrorExit:
659- self.assertIn('The following units had errors:',
660- self.output.getvalue())
661- self.assertIn('unit: wordpress/0: machine: 1 '
662- 'agent-state: install-error',
663- self.output.getvalue())
664- else:
665- self.fail("Did not get expected exception")
666+ with self.assertRaises(ErrorExit):
667+ env.wait_for_units(
668+ timeout=240, on_errors=watchers.exit_on_errors(env))
669+ output = self.output.getvalue()
670+ self.assertIn('The following units had errors:', output)
671+ self.assertIn(
672+ 'unit: wordpress/0: machine: 1 agent-state: install-error', output)
673
674 def test_wait_for_units_sub_error_no_exit(self):
675 env = FakePyEnvironment(
676@@ -91,16 +87,15 @@
677 },
678 },
679 })
680- try:
681- env.wait_for_units(timeout=240, no_exit=True)
682- except UnitErrors, e:
683- self.assertEqual(1, len(e.errors))
684- unit = e.errors[0]
685- self.assertEqual("nrpe/0", unit["name"])
686- self.assertEqual("install-error", unit["agent-state"])
687- self.assertEqual(1, unit["machine"])
688- else:
689- self.fail("Did not get expected exception")
690+ with self.assertRaises(UnitErrors) as cm:
691+ env.wait_for_units(
692+ timeout=240, on_errors=watchers.raise_on_errors(UnitErrors))
693+ errors = cm.exception.errors
694+ self.assertEqual(1, len(errors))
695+ unit = errors[0]
696+ self.assertEqual("nrpe/0", unit["name"])
697+ self.assertEqual("install-error", unit["agent-state"])
698+ self.assertEqual(1, unit["machine"])
699
700 def test_wait_for_units_no_error_no_exit(self):
701 env = FakePyEnvironment(
702@@ -121,9 +116,10 @@
703 },
704 })
705 try:
706- env.wait_for_units(timeout=240, no_exit=True)
707- except UnitErrors, e:
708- self.fail("Unexpected exception: %s" % e)
709+ env.wait_for_units(
710+ timeout=240, on_errors=watchers.raise_on_errors(UnitErrors))
711+ except UnitErrors as err:
712+ self.fail("Unexpected exception: %s" % err)
713
714 def test_wait_for_units_relation_error_no_exit(self):
715 env = FakePyEnvironment(
716@@ -141,16 +137,15 @@
717 },
718 },
719 })
720- try:
721- env.wait_for_units(timeout=240, no_exit=True)
722- except UnitErrors, e:
723- self.assertEqual(1, len(e.errors))
724- unit = e.errors[0]
725- self.assertEqual("wordpress/0", unit["name"])
726- self.assertEqual("relation-error: nrpe", unit["agent-state"])
727- self.assertEqual(1, unit["machine"])
728- else:
729- self.fail("Did not get expected exception")
730+ with self.assertRaises(UnitErrors) as cm:
731+ env.wait_for_units(
732+ timeout=240, on_errors=watchers.raise_on_errors(UnitErrors))
733+ errors = cm.exception.errors
734+ self.assertEqual(1, len(errors))
735+ unit = errors[0]
736+ self.assertEqual("wordpress/0", unit["name"])
737+ self.assertEqual("relation-error: nrpe", unit["agent-state"])
738+ self.assertEqual(1, unit["machine"])
739
740 def test_wait_for_units_subordinate_no_unit_no_error_no_exit(self):
741 env = FakePyEnvironment(
742@@ -163,6 +158,7 @@
743 },
744 })
745 try:
746- env.wait_for_units(timeout=240, no_exit=True)
747- except UnitErrors, e:
748- self.fail("Unexpected exception: %s" % e)
749+ env.wait_for_units(
750+ timeout=240, on_errors=watchers.raise_on_errors(UnitErrors))
751+ except UnitErrors as err:
752+ self.fail("Unexpected exception: %s" % err)
753
754=== added file 'deployer/tests/test_watchers.py'
755--- deployer/tests/test_watchers.py 1970-01-01 00:00:00 +0000
756+++ deployer/tests/test_watchers.py 2014-02-20 12:33:52 +0000
757@@ -0,0 +1,146 @@
758+"""Tests juju-core environment watchers."""
759+
760+import unittest
761+
762+import mock
763+
764+from deployer.env import watchers
765+from deployer.utils import ErrorExit
766+
767+
768+class TestWaitForUnits(unittest.TestCase):
769+
770+ def make_watch(self, *changesets):
771+ """Create and return a mock watcher returning the given change sets."""
772+ watch = mock.MagicMock()
773+ watch.__iter__.return_value = changesets
774+ return watch
775+
776+ def make_change(self, unit_name, status):
777+ """Create and return a juju-core mega-watcher unit change."""
778+ service = unit_name.split('/')[0]
779+ return {'Name': unit_name, 'Service': service, 'Status': status}
780+
781+ def test_success(self):
782+ # It is possible to watch for units to be started.
783+ watch = self.make_watch(
784+ [('unit', 'change', self.make_change('django/0', 'pending')),
785+ ('unit', 'change', self.make_change('haproxy/1', 'pending'))],
786+ [('unit', 'change', self.make_change('django/0', 'started')),
787+ ('unit', 'change', self.make_change('haproxy/1', 'started'))],
788+ )
789+ watcher = watchers.WaitForUnits(watch)
790+ callback = mock.Mock()
791+ watcher.run(callback)
792+ # The callback has been called once for each change in the change sets,
793+ # excluding the initial state.
794+ self.assertEqual(2, callback.call_count)
795+ # The watcher has been properly stopped.
796+ watch.stop.assert_called_once_with()
797+
798+ def test_errors_handling(self):
799+ # Errors can be handled providing an on_errors callable.
800+ watch = self.make_watch(
801+ [('unit', 'change', self.make_change('django/0', 'pending')),
802+ ('unit', 'change', self.make_change('django/42', 'pending')),
803+ ('unit', 'change', self.make_change('haproxy/1', 'pending'))],
804+ [('unit', 'change', self.make_change('django/0', 'pending')),
805+ ('unit', 'change', self.make_change('django/42', 'error')),
806+ ('unit', 'change', self.make_change('haproxy/1', 'error'))],
807+ [('unit', 'change', self.make_change('django/0', 'error')),
808+ ('unit', 'change', self.make_change('django/42', 'error')),
809+ ('unit', 'change', self.make_change('haproxy/1', 'error'))],
810+ )
811+ on_errors = mock.Mock()
812+ watcher = watchers.WaitForUnits(watch, on_errors=on_errors)
813+ watcher.run()
814+ # The watcher has been properly stopped.
815+ watch.stop.assert_called_once_with()
816+ # The errors handler has been called once for each changeset containing
817+ # errors.
818+ self.assertEqual(2, on_errors.call_count)
819+ on_errors.assert_has_calls([
820+ mock.call([
821+ {'Status': 'error', 'Name': 'django/42', 'Service': 'django'},
822+ {'Status': 'error', 'Name': 'haproxy/1', 'Service': 'haproxy'}
823+ ]),
824+ mock.call([
825+ {'Status': 'error', 'Name': 'django/0', 'Service': 'django'}]),
826+ ])
827+
828+ def test_specific_services(self):
829+ # It is possible to only watch units belonging to specific services.
830+ watch = self.make_watch(
831+ [('unit', 'change', self.make_change('django/0', 'pending')),
832+ ('unit', 'change', self.make_change('django/42', 'pending')),
833+ ('unit', 'change', self.make_change('haproxy/1', 'pending')),
834+ ('unit', 'change', self.make_change('haproxy/47', 'pending'))],
835+ [('unit', 'change', self.make_change('django/0', 'pending')),
836+ ('unit', 'change', self.make_change('django/42', 'started')),
837+ ('unit', 'change', self.make_change('haproxy/1', 'error')),
838+ ('unit', 'change', self.make_change('haproxy/47', 'pending'))],
839+ [('unit', 'change', self.make_change('django/0', 'error')),
840+ ('unit', 'change', self.make_change('django/42', 'started')),
841+ ('unit', 'change', self.make_change('haproxy/1', 'error')),
842+ ('unit', 'change', self.make_change('haproxy/47', 'pending'))],
843+ )
844+ on_errors = mock.Mock()
845+ watcher = watchers.WaitForUnits(
846+ watch, services=['django'], on_errors=on_errors)
847+ watcher.run()
848+ # The watcher has been properly stopped, even if haproxy/47 is pending.
849+ watch.stop.assert_called_once_with()
850+ # The errors handler has been called for the django error, not for the
851+ # haproxy one.
852+ on_errors.assert_called_once_with(
853+ [{'Status': 'error', 'Name': 'django/0', 'Service': 'django'}])
854+
855+ def test_goal_states(self):
856+ # It is possible to watch for the given goal state
857+ watch = self.make_watch(
858+ [('unit', 'change', self.make_change('django/0', 'pending')),
859+ ('unit', 'change', self.make_change('haproxy/1', 'pending'))],
860+ )
861+ watcher = watchers.WaitForUnits(watch, goal_state='pending')
862+ callback = mock.Mock()
863+ watcher.run(callback)
864+ # Since all the units are already pending, the watcher has been
865+ # properly stopped.
866+ watch.stop.assert_called_once_with()
867+
868+
869+class TestLogOnErrors(unittest.TestCase):
870+
871+ def setUp(self):
872+ # Set up a mock environment.
873+ self.env = mock.Mock()
874+
875+ def test_returned_callable(self):
876+ # The returned function uses the env to log errors.
877+ callback = watchers.log_on_errors(self.env)
878+ self.assertEqual(self.env.log_errors, callback)
879+
880+
881+class TestExitOnErrors(unittest.TestCase):
882+
883+ def setUp(self):
884+ # Set up a mock environment.
885+ self.env = mock.Mock()
886+
887+ def test_returned_callable(self):
888+ # The returned function uses the env to log errors and then exits the
889+ # application.
890+ callback = watchers.exit_on_errors(self.env)
891+ with self.assertRaises(ErrorExit):
892+ callback('bad wolf')
893+ self.env.log_errors.assert_called_once_with('bad wolf')
894+
895+
896+class TestRaiseOnErrors(unittest.TestCase):
897+
898+ def test_returned_callable(self):
899+ # The returned function raises the given exception passing the errors.
900+ callback = watchers.raise_on_errors(ValueError)
901+ with self.assertRaises(ValueError) as cm:
902+ callback('bad wolf')
903+ self.assertEqual('bad wolf', bytes(cm.exception))

Subscribers

People subscribed via source and target branches