1
# Copyright 2014-2015 Canonical Limited.
3
# This file is part of charm-helpers.
5
# charm-helpers is free software: you can redistribute it and/or modify
6
# it under the terms of the GNU Lesser General Public License version 3 as
7
# published by the Free Software Foundation.
9
# charm-helpers is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
# GNU Lesser General Public License for more details.
14
# You should have received a copy of the GNU Lesser General Public License
15
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
31
from six.moves import configparser
33
from urllib import parse as urlparse
38
class AmuletUtils(object):
41
This class provides common utility functions that are used by Amulet
45
def __init__(self, log_level=logging.ERROR):
46
self.log = self.get_logger(level=log_level)
47
self.ubuntu_releases = self.get_ubuntu_releases()
49
def get_logger(self, name="amulet-logger", level=logging.DEBUG):
50
"""Get a logger object that will log to stdout."""
52
logger = log.getLogger(name)
53
fmt = log.Formatter("%(asctime)s %(funcName)s "
54
"%(levelname)s: %(message)s")
56
handler = log.StreamHandler(stream=sys.stdout)
57
handler.setLevel(level)
58
handler.setFormatter(fmt)
60
logger.addHandler(handler)
61
logger.setLevel(level)
65
def valid_ip(self, ip):
66
if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", ip):
71
def valid_url(self, url):
74
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # noqa
76
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
85
def get_ubuntu_release_from_sentry(self, sentry_unit):
86
"""Get Ubuntu release codename from sentry unit.
88
:param sentry_unit: amulet sentry/service unit pointer
89
:returns: list of strings - release codename, failure message
92
cmd = 'lsb_release -cs'
93
release, code = sentry_unit.run(cmd)
95
self.log.debug('{} lsb_release: {}'.format(
96
sentry_unit.info['unit_name'], release))
98
msg = ('{} `{}` returned {} '
99
'{}'.format(sentry_unit.info['unit_name'],
101
if release not in self.ubuntu_releases:
102
msg = ("Release ({}) not found in Ubuntu releases "
103
"({})".format(release, self.ubuntu_releases))
106
def validate_services(self, commands):
107
"""Validate that lists of commands succeed on service units. Can be
108
used to verify system services are running on the corresponding
111
:param commands: dict with sentry keys and arbitrary command list vals
112
:returns: None if successful, Failure string message otherwise
114
self.log.debug('Checking status of system services...')
116
# /!\ DEPRECATION WARNING (beisner):
117
# New and existing tests should be rewritten to use
118
# validate_services_by_name() as it is aware of init systems.
119
self.log.warn('DEPRECATION WARNING: use '
120
'validate_services_by_name instead of validate_services '
121
'due to init system differences.')
123
for k, v in six.iteritems(commands):
125
output, code = k.run(cmd)
126
self.log.debug('{} `{}` returned '
127
'{}'.format(k.info['unit_name'],
130
return "command `{}` returned {}".format(cmd, str(code))
133
def validate_services_by_name(self, sentry_services):
134
"""Validate system service status by service name, automatically
135
detecting init system based on Ubuntu release codename.
137
:param sentry_services: dict with sentry keys and svc list values
138
:returns: None if successful, Failure string message otherwise
140
self.log.debug('Checking status of system services...')
142
# Point at which systemd became a thing
143
systemd_switch = self.ubuntu_releases.index('vivid')
145
for sentry_unit, services_list in six.iteritems(sentry_services):
146
# Get lsb_release codename from unit
147
release, ret = self.get_ubuntu_release_from_sentry(sentry_unit)
151
for service_name in services_list:
152
if (self.ubuntu_releases.index(release) >= systemd_switch or
153
service_name in ['rabbitmq-server', 'apache2']):
154
# init is systemd (or regular sysv)
155
cmd = 'sudo service {} status'.format(service_name)
156
output, code = sentry_unit.run(cmd)
157
service_running = code == 0
158
elif self.ubuntu_releases.index(release) < systemd_switch:
160
cmd = 'sudo status {}'.format(service_name)
161
output, code = sentry_unit.run(cmd)
162
service_running = code == 0 and "start/running" in output
164
self.log.debug('{} `{}` returned '
165
'{}'.format(sentry_unit.info['unit_name'],
167
if not service_running:
168
return u"command `{}` returned {} {}".format(
169
cmd, output, str(code))
172
def _get_config(self, unit, filename):
173
"""Get a ConfigParser object for parsing a unit's config file."""
174
file_contents = unit.file_contents(filename)
176
# NOTE(beisner): by default, ConfigParser does not handle options
177
# with no value, such as the flags used in the mysql my.cnf file.
178
# https://bugs.python.org/issue7005
179
config = configparser.ConfigParser(allow_no_value=True)
180
config.readfp(io.StringIO(file_contents))
183
def validate_config_data(self, sentry_unit, config_file, section,
185
"""Validate config file data.
187
Verify that the specified section of the config file contains
188
the expected option key:value pairs.
190
Compare expected dictionary data vs actual dictionary data.
191
The values in the 'expected' dictionary can be strings, bools, ints,
192
longs, or can be a function that evaluates a variable and returns a
195
self.log.debug('Validating config file data ({} in {} on {})'
196
'...'.format(section, config_file,
197
sentry_unit.info['unit_name']))
198
config = self._get_config(sentry_unit, config_file)
200
if section != 'DEFAULT' and not config.has_section(section):
201
return "section [{}] does not exist".format(section)
203
for k in expected.keys():
204
if not config.has_option(section, k):
205
return "section [{}] is missing option {}".format(section, k)
207
actual = config.get(section, k)
209
if (isinstance(v, six.string_types) or
210
isinstance(v, bool) or
211
isinstance(v, six.integer_types)):
212
# handle explicit values
214
return "section [{}] {}:{} != expected {}:{}".format(
215
section, k, actual, k, expected[k])
216
# handle function pointers, such as not_null or valid_ip
218
return "section [{}] {}:{} != expected {}:{}".format(
219
section, k, actual, k, expected[k])
222
def _validate_dict_data(self, expected, actual):
223
"""Validate dictionary data.
225
Compare expected dictionary data vs actual dictionary data.
226
The values in the 'expected' dictionary can be strings, bools, ints,
227
longs, or can be a function that evaluates a variable and returns a
230
self.log.debug('actual: {}'.format(repr(actual)))
231
self.log.debug('expected: {}'.format(repr(expected)))
233
for k, v in six.iteritems(expected):
235
if (isinstance(v, six.string_types) or
236
isinstance(v, bool) or
237
isinstance(v, six.integer_types)):
238
# handle explicit values
240
return "{}:{}".format(k, actual[k])
241
# handle function pointers, such as not_null or valid_ip
242
elif not v(actual[k]):
243
return "{}:{}".format(k, actual[k])
245
return "key '{}' does not exist".format(k)
248
def validate_relation_data(self, sentry_unit, relation, expected):
249
"""Validate actual relation data based on expected relation data."""
250
actual = sentry_unit.relation(relation[0], relation[1])
251
return self._validate_dict_data(expected, actual)
253
def _validate_list_data(self, expected, actual):
254
"""Compare expected list vs actual list data."""
257
return "expected item {} not found in actual list".format(e)
260
def not_null(self, string):
261
if string is not None:
266
def _get_file_mtime(self, sentry_unit, filename):
267
"""Get last modification time of file."""
268
return sentry_unit.file_stat(filename)['mtime']
270
def _get_dir_mtime(self, sentry_unit, directory):
271
"""Get last modification time of directory."""
272
return sentry_unit.directory_stat(directory)['mtime']
274
def _get_proc_start_time(self, sentry_unit, service, pgrep_full=None):
275
"""Get start time of a process based on the last modification time
276
of the /proc/pid directory.
278
:sentry_unit: The sentry unit to check for the service on
279
:service: service name to look for in process table
280
:pgrep_full: [Deprecated] Use full command line search mode with pgrep
281
:returns: epoch time of service process start
282
:param commands: list of bash commands
283
:param sentry_units: list of sentry unit pointers
284
:returns: None if successful; Failure message otherwise
286
if pgrep_full is not None:
287
# /!\ DEPRECATION WARNING (beisner):
288
# No longer implemented, as pidof is now used instead of pgrep.
289
# https://bugs.launchpad.net/charm-helpers/+bug/1474030
290
self.log.warn('DEPRECATION WARNING: pgrep_full bool is no '
291
'longer implemented re: lp 1474030.')
293
pid_list = self.get_process_id_list(sentry_unit, service)
295
proc_dir = '/proc/{}'.format(pid)
296
self.log.debug('Pid for {} on {}: {}'.format(
297
service, sentry_unit.info['unit_name'], pid))
299
return self._get_dir_mtime(sentry_unit, proc_dir)
301
def service_restarted(self, sentry_unit, service, filename,
302
pgrep_full=None, sleep_time=20):
303
"""Check if service was restarted.
305
Compare a service's start time vs a file's last modification time
306
(such as a config file for that service) to determine if the service
309
# /!\ DEPRECATION WARNING (beisner):
310
# This method is prone to races in that no before-time is known.
311
# Use validate_service_config_changed instead.
313
# NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
314
# used instead of pgrep. pgrep_full is still passed through to ensure
315
# deprecation WARNS. lp1474030
316
self.log.warn('DEPRECATION WARNING: use '
317
'validate_service_config_changed instead of '
318
'service_restarted due to known races.')
320
time.sleep(sleep_time)
321
if (self._get_proc_start_time(sentry_unit, service, pgrep_full) >=
322
self._get_file_mtime(sentry_unit, filename)):
327
def service_restarted_since(self, sentry_unit, mtime, service,
328
pgrep_full=None, sleep_time=20,
329
retry_count=2, retry_sleep_time=30):
330
"""Check if service was been started after a given time.
333
sentry_unit (sentry): The sentry unit to check for the service on
334
mtime (float): The epoch time to check against
335
service (string): service name to look for in process table
336
pgrep_full: [Deprecated] Use full command line search mode with pgrep
337
sleep_time (int): Seconds to sleep before looking for process
338
retry_count (int): If service is not found, how many times to retry
341
bool: True if service found and its start time it newer than mtime,
342
False if service is older than mtime or if service was
345
# NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
346
# used instead of pgrep. pgrep_full is still passed through to ensure
347
# deprecation WARNS. lp1474030
349
unit_name = sentry_unit.info['unit_name']
350
self.log.debug('Checking that %s service restarted since %s on '
351
'%s' % (service, mtime, unit_name))
352
time.sleep(sleep_time)
353
proc_start_time = None
355
while tries <= retry_count and not proc_start_time:
357
proc_start_time = self._get_proc_start_time(sentry_unit,
360
self.log.debug('Attempt {} to get {} proc start time on {} '
361
'OK'.format(tries, service, unit_name))
363
# NOTE(beisner) - race avoidance, proc may not exist yet.
364
# https://bugs.launchpad.net/charm-helpers/+bug/1474030
365
self.log.debug('Attempt {} to get {} proc start time on {} '
366
'failed'.format(tries, service, unit_name))
367
time.sleep(retry_sleep_time)
370
if not proc_start_time:
371
self.log.warn('No proc start time found, assuming service did '
374
if proc_start_time >= mtime:
375
self.log.debug('Proc start time is newer than provided mtime'
376
'(%s >= %s) on %s (OK)' % (proc_start_time,
380
self.log.warn('Proc start time (%s) is older than provided mtime '
381
'(%s) on %s, service did not '
382
'restart' % (proc_start_time, mtime, unit_name))
385
def config_updated_since(self, sentry_unit, filename, mtime,
387
"""Check if file was modified after a given time.
390
sentry_unit (sentry): The sentry unit to check the file mtime on
391
filename (string): The file to check mtime of
392
mtime (float): The epoch time to check against
393
sleep_time (int): Seconds to sleep before looking for process
396
bool: True if file was modified more recently than mtime, False if
397
file was modified before mtime,
399
self.log.debug('Checking %s updated since %s' % (filename, mtime))
400
time.sleep(sleep_time)
401
file_mtime = self._get_file_mtime(sentry_unit, filename)
402
if file_mtime >= mtime:
403
self.log.debug('File mtime is newer than provided mtime '
404
'(%s >= %s)' % (file_mtime, mtime))
407
self.log.warn('File mtime %s is older than provided mtime %s'
408
% (file_mtime, mtime))
411
def validate_service_config_changed(self, sentry_unit, mtime, service,
412
filename, pgrep_full=None,
413
sleep_time=20, retry_count=2,
414
retry_sleep_time=30):
415
"""Check service and file were updated after mtime
418
sentry_unit (sentry): The sentry unit to check for the service on
419
mtime (float): The epoch time to check against
420
service (string): service name to look for in process table
421
filename (string): The file to check mtime of
422
pgrep_full: [Deprecated] Use full command line search mode with pgrep
423
sleep_time (int): Initial sleep in seconds to pass to test helpers
424
retry_count (int): If service is not found, how many times to retry
425
retry_sleep_time (int): Time in seconds to wait between retries
428
u = OpenStackAmuletUtils(ERROR)
430
mtime = u.get_sentry_time(self.cinder_sentry)
431
self.d.configure('cinder', {'verbose': 'True', 'debug': 'True'})
432
if not u.validate_service_config_changed(self.cinder_sentry,
435
'/etc/cinder/cinder.conf')
436
amulet.raise_status(amulet.FAIL, msg='update failed')
438
bool: True if both service and file where updated/restarted after
439
mtime, False if service is older than mtime or if service was
440
not found or if filename was modified before mtime.
443
# NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
444
# used instead of pgrep. pgrep_full is still passed through to ensure
445
# deprecation WARNS. lp1474030
447
service_restart = self.service_restarted_since(
450
pgrep_full=pgrep_full,
451
sleep_time=sleep_time,
452
retry_count=retry_count,
453
retry_sleep_time=retry_sleep_time)
455
config_update = self.config_updated_since(
461
return service_restart and config_update
463
def get_sentry_time(self, sentry_unit):
464
"""Return current epoch time on a sentry"""
466
return float(sentry_unit.run(cmd)[0])
468
def relation_error(self, name, data):
469
return 'unexpected relation data in {} - {}'.format(name, data)
471
def endpoint_error(self, name, data):
472
return 'unexpected endpoint data in {} - {}'.format(name, data)
474
def get_ubuntu_releases(self):
475
"""Return a list of all Ubuntu releases in order of release."""
476
_d = distro_info.UbuntuDistroInfo()
477
_release_list = _d.all
480
def file_to_url(self, file_rel_path):
481
"""Convert a relative file path to a file URL."""
482
_abs_path = os.path.abspath(file_rel_path)
483
return urlparse.urlparse(_abs_path, scheme='file').geturl()
485
def check_commands_on_units(self, commands, sentry_units):
486
"""Check that all commands in a list exit zero on all
487
sentry units in a list.
489
:param commands: list of bash commands
490
:param sentry_units: list of sentry unit pointers
491
:returns: None if successful; Failure message otherwise
493
self.log.debug('Checking exit codes for {} commands on {} '
494
'sentry units...'.format(len(commands),
496
for sentry_unit in sentry_units:
498
output, code = sentry_unit.run(cmd)
500
self.log.debug('{} `{}` returned {} '
501
'(OK)'.format(sentry_unit.info['unit_name'],
504
return ('{} `{}` returned {} '
505
'{}'.format(sentry_unit.info['unit_name'],
509
def get_process_id_list(self, sentry_unit, process_name,
510
expect_success=True):
511
"""Get a list of process ID(s) from a single sentry juju unit
512
for a single process name.
514
:param sentry_unit: Amulet sentry instance (juju unit)
515
:param process_name: Process name
516
:param expect_success: If False, expect the PID to be missing,
517
raise if it is present.
518
:returns: List of process IDs
520
cmd = 'pidof -x {}'.format(process_name)
521
if not expect_success:
522
cmd += " || exit 0 && exit 1"
523
output, code = sentry_unit.run(cmd)
525
msg = ('{} `{}` returned {} '
526
'{}'.format(sentry_unit.info['unit_name'],
528
amulet.raise_status(amulet.FAIL, msg=msg)
529
return str(output).split()
531
def get_unit_process_ids(self, unit_processes, expect_success=True):
532
"""Construct a dict containing unit sentries, process names, and
535
:param unit_processes: A dictionary of Amulet sentry instance
536
to list of process names.
537
:param expect_success: if False expect the processes to not be
538
running, raise if they are.
539
:returns: Dictionary of Amulet sentry instance to dictionary
540
of process names to PIDs.
543
for sentry_unit, process_list in six.iteritems(unit_processes):
544
pid_dict[sentry_unit] = {}
545
for process in process_list:
546
pids = self.get_process_id_list(
547
sentry_unit, process, expect_success=expect_success)
548
pid_dict[sentry_unit].update({process: pids})
551
def validate_unit_process_ids(self, expected, actual):
552
"""Validate process id quantities for services on units."""
553
self.log.debug('Checking units for running processes...')
554
self.log.debug('Expected PIDs: {}'.format(expected))
555
self.log.debug('Actual PIDs: {}'.format(actual))
557
if len(actual) != len(expected):
558
return ('Unit count mismatch. expected, actual: {}, '
559
'{} '.format(len(expected), len(actual)))
561
for (e_sentry, e_proc_names) in six.iteritems(expected):
562
e_sentry_name = e_sentry.info['unit_name']
563
if e_sentry in actual.keys():
564
a_proc_names = actual[e_sentry]
566
return ('Expected sentry ({}) not found in actual dict data.'
567
'{}'.format(e_sentry_name, e_sentry))
569
if len(e_proc_names.keys()) != len(a_proc_names.keys()):
570
return ('Process name count mismatch. expected, actual: {}, '
571
'{}'.format(len(expected), len(actual)))
573
for (e_proc_name, e_pids_length), (a_proc_name, a_pids) in \
574
zip(e_proc_names.items(), a_proc_names.items()):
575
if e_proc_name != a_proc_name:
576
return ('Process name mismatch. expected, actual: {}, '
577
'{}'.format(e_proc_name, a_proc_name))
579
a_pids_length = len(a_pids)
580
fail_msg = ('PID count mismatch. {} ({}) expected, actual: '
581
'{}, {} ({})'.format(e_sentry_name, e_proc_name,
582
e_pids_length, a_pids_length,
585
# If expected is not bool, ensure PID quantities match
586
if not isinstance(e_pids_length, bool) and \
587
a_pids_length != e_pids_length:
589
# If expected is bool True, ensure 1 or more PIDs exist
590
elif isinstance(e_pids_length, bool) and \
591
e_pids_length is True and a_pids_length < 1:
593
# If expected is bool False, ensure 0 PIDs exist
594
elif isinstance(e_pids_length, bool) and \
595
e_pids_length is False and a_pids_length != 0:
598
self.log.debug('PID check OK: {} {} {}: '
599
'{}'.format(e_sentry_name, e_proc_name,
600
e_pids_length, a_pids))
603
def validate_list_of_identical_dicts(self, list_of_dicts):
604
"""Check that all dicts within a list are identical."""
606
for _dict in list_of_dicts:
607
hashes.append(hash(frozenset(_dict.items())))
609
self.log.debug('Hashes: {}'.format(hashes))
610
if len(set(hashes)) == 1:
611
self.log.debug('Dicts within list are identical')
613
return 'Dicts within list are not identical'
617
def validate_sectionless_conf(self, file_contents, expected):
618
"""A crude conf parser. Useful to inspect configuration files which
619
do not have section headers (as would be necessary in order to use
620
the configparser). Such as openstack-dashboard or rabbitmq confs."""
621
for line in file_contents.split('\n'):
623
args = line.split('=')
626
key = args[0].strip()
627
value = args[1].strip()
628
if key in expected.keys():
629
if expected[key] != value:
630
msg = ('Config mismatch. Expected, actual: {}, '
631
'{}'.format(expected[key], value))
632
amulet.raise_status(amulet.FAIL, msg=msg)
634
def get_unit_hostnames(self, units):
635
"""Return a dict of juju unit names to hostnames."""
638
host_names[unit.info['unit_name']] = \
639
str(unit.file_contents('/etc/hostname').strip())
640
self.log.debug('Unit host names: {}'.format(host_names))
643
def run_cmd_unit(self, sentry_unit, cmd):
644
"""Run a command on a unit, return the output and exit code."""
645
output, code = sentry_unit.run(cmd)
647
self.log.debug('{} `{}` command returned {} '
648
'(OK)'.format(sentry_unit.info['unit_name'],
651
msg = ('{} `{}` command returned {} '
652
'{}'.format(sentry_unit.info['unit_name'],
654
amulet.raise_status(amulet.FAIL, msg=msg)
655
return str(output), code
657
def file_exists_on_unit(self, sentry_unit, file_name):
658
"""Check if a file exists on a unit."""
660
sentry_unit.file_stat(file_name)
664
except Exception as e:
665
msg = 'Error checking file {}: {}'.format(file_name, e)
666
amulet.raise_status(amulet.FAIL, msg=msg)
668
def file_contents_safe(self, sentry_unit, file_name,
669
max_wait=60, fatal=False):
670
"""Get file contents from a sentry unit. Wrap amulet file_contents
671
with retry logic to address races where a file checks as existing,
672
but no longer exists by the time file_contents is called.
673
Return None if file not found. Optionally raise if fatal is True."""
674
unit_name = sentry_unit.info['unit_name']
675
file_contents = False
677
while not file_contents and tries < (max_wait / 4):
679
file_contents = sentry_unit.file_contents(file_name)
681
self.log.debug('Attempt {} to open file {} from {} '
682
'failed'.format(tries, file_name,
692
msg = 'Failed to get file contents from unit.'
693
amulet.raise_status(amulet.FAIL, msg)
695
def port_knock_tcp(self, host="localhost", port=22, timeout=15):
696
"""Open a TCP socket to check for a listening sevice on a host.
698
:param host: host name or IP address, default to localhost
699
:param port: TCP port number, default to 22
700
:param timeout: Connect timeout, default to 15 seconds
701
:returns: True if successful, False if connect failed
704
# Resolve host name if possible
706
connect_host = socket.gethostbyname(host)
707
host_human = "{} ({})".format(connect_host, host)
708
except socket.error as e:
709
self.log.warn('Unable to resolve address: '
710
'{} ({}) Trying anyway!'.format(host, e))
712
host_human = connect_host
714
# Attempt socket connection
716
knock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
717
knock.settimeout(timeout)
718
knock.connect((connect_host, port))
720
self.log.debug('Socket connect OK for host '
721
'{} on port {}.'.format(host_human, port))
723
except socket.error as e:
724
self.log.debug('Socket connect FAIL for'
725
' {} port {} ({})'.format(host_human, port, e))
728
def port_knock_units(self, sentry_units, port=22,
729
timeout=15, expect_success=True):
730
"""Open a TCP socket to check for a listening sevice on each
733
:param sentry_units: list of sentry unit pointers
734
:param port: TCP port number, default to 22
735
:param timeout: Connect timeout, default to 15 seconds
736
:expect_success: True by default, set False to invert logic
737
:returns: None if successful, Failure message otherwise
739
for unit in sentry_units:
740
host = unit.info['public-address']
741
connected = self.port_knock_tcp(host, port, timeout)
742
if not connected and expect_success:
743
return 'Socket connect failed.'
744
elif connected and not expect_success:
745
return 'Socket connected unexpectedly.'
747
def get_uuid_epoch_stamp(self):
748
"""Returns a stamp string based on uuid4 and epoch time. Useful in
749
generating test messages which need to be unique-ish."""
750
return '[{}-{}]'.format(uuid.uuid4(), time.time())
752
# amulet juju action helpers:
753
def run_action(self, unit_sentry, action,
754
_check_output=subprocess.check_output):
755
"""Run the named action on a given unit sentry.
757
_check_output parameter is used for dependency injection.
761
unit_id = unit_sentry.info["unit_name"]
762
command = ["juju", "action", "do", "--format=json", unit_id, action]
763
self.log.info("Running command: %s\n" % " ".join(command))
764
output = _check_output(command, universal_newlines=True)
765
data = json.loads(output)
766
action_id = data[u'Action queued with id']
769
def wait_on_action(self, action_id, _check_output=subprocess.check_output):
770
"""Wait for a given action, returning if it completed or not.
772
_check_output parameter is used for dependency injection.
774
command = ["juju", "action", "fetch", "--format=json", "--wait=0",
776
output = _check_output(command, universal_newlines=True)
777
data = json.loads(output)
778
return data.get(u"status") == "completed"