cloud-init-dev team mailing list archive
-
cloud-init-dev team
-
Mailing list archive
-
Message #03697
[Merge] ~chad.smith/cloud-init:clean-status-commands into cloud-init:master
Chad Smith has proposed merging ~chad.smith/cloud-init:clean-status-commands into cloud-init:master.
Requested reviews:
cloud-init commiters (cloud-init-dev)
For more details, see:
https://code.launchpad.net/~chad.smith/cloud-init/+git/cloud-init/+merge/333513
cli: Add clean and status subcommands
The 'clean' subcommand allows a user or script to clear cloud-init artifacts from the system so that cloud-init sees the system as unconfigured upon reboot. Optional parameters can be provided to remove cloud-init logs and reboot after clean.
The 'status' subcommand allows the user or script to check whether cloud-init has finished all configuration stages and whether or not it is in error. It can also be provided with a --wait argument which will poll on a 0.25 second interval until cloud-init stages have finished running. The benefit here is scripts can block on cloud-init completion before performing post-config tasks.
--
Your team cloud-init commiters is requested to review the proposed merge of ~chad.smith/cloud-init:clean-status-commands into cloud-init:master.
diff --git a/cloudinit/cmd/clean.py b/cloudinit/cmd/clean.py
new file mode 100644
index 0000000..4a7e323
--- /dev/null
+++ b/cloudinit/cmd/clean.py
@@ -0,0 +1,90 @@
+# Copyright (C) 2017 Canonical Ltd.
+#
+# This file is part of cloud-init. See LICENSE file for license information.
+
+"""Define 'clean' utility and handler as part of cloud-init commandline."""
+
+import argparse
+from cloudinit.util import (
+ ProcessExecutionError, chdir, del_file, subp, which)
+import os
+import sys
+
+
+CLOUDINIT_LOGS = ['/var/log/cloud-init.log', '/var/log/cloud-init-output.log']
+CLOUDINIT_ARTIFACTS_DIR = '/var/lib/cloud'
+
+
+def get_parser(parser=None):
+ """Build or extend an arg parser for clean utility.
+
+ @param parser: Optional existing ArgumentParser instance representing the
+ clean subcommand which will be extended to support the args of
+ this utility.
+
+ @returns: ArgumentParser with proper argument configuration.
+ """
+ if not parser:
+ parser = argparse.ArgumentParser(
+ prog='clean',
+ description=('Remove logs and artifacts so cloud-init re-runs on '
+ 'a clean system'))
+ parser.add_argument(
+ '-l', '--logs', action='store_true', default=False, dest='remove_logs',
+ help='Remove cloud-init logs.')
+ parser.add_argument(
+ '-r', '--reboot', action='store_true', default=False,
+ help='Reboot system after logs are cleaned so cloud-init re-runs.')
+ return parser
+
+
+def remove_artifacts(remove_logs):
+ """Helper which removes artifacts dir and optionally log files.
+
+ @param: remove_logs: Boolean. Set True to delete CLOUDINIT_LOGS. False
+ preserves them.
+ @returns: 0 on success, 1 otherwise.
+ """
+ if remove_logs:
+ for log_file in CLOUDINIT_LOGS:
+ del_file(log_file)
+
+ if not os.path.isdir(CLOUDINIT_ARTIFACTS_DIR):
+ return 0 # Artfiacts dir already cleaned
+ with chdir(CLOUDINIT_ARTIFACTS_DIR):
+ for path in os.listdir('.'):
+ if path == 'seed':
+ continue
+ try:
+ subp(['rm', '-Rf', path])
+ except ProcessExecutionError as e:
+ print('ERROR: Could not remove {0}: {1}'.format(path, str(e)))
+ return 1
+ return 0
+
+
+def handle_clean_args(name, args):
+ """Handle calls to 'cloud-init clean' as a subcommand."""
+ exit_code = remove_artifacts(args.remove_logs)
+ if exit_code == 0 and args.reboot:
+ cmd = ['sudo', which('shutdown'), '-r', 'now']
+ try:
+ subp(cmd)
+ except ProcessExecutionError as e:
+ print(
+ 'Could not reboot this system using "{0}": {1}'.format(
+ cmd, str(e)))
+ exit_code = 1
+ return exit_code
+
+
+def main():
+ """Tool to collect and tar all cloud-init related logs."""
+ parser = get_parser()
+ sys.exit(handle_clean_args('clean', parser.parse_args()))
+
+
+if __name__ == '__main__':
+ main()
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/cmd/main.py b/cloudinit/cmd/main.py
index 6fb9d9e..aa56225 100644
--- a/cloudinit/cmd/main.py
+++ b/cloudinit/cmd/main.py
@@ -767,6 +767,12 @@ def main(sysv_args=None):
parser_collect_logs = subparsers.add_parser(
'collect-logs', help='Collect and tar all cloud-init debug info')
+ parser_clean = subparsers.add_parser(
+ 'clean', help='Remove logs and artifacts so cloud-init can re-run.')
+
+ parser_status = subparsers.add_parser(
+ 'status', help='Report cloud-init status or wait on completion.')
+
if sysv_args:
# Only load subparsers if subcommand is specified to avoid load cost
if sysv_args[0] == 'analyze':
@@ -783,6 +789,18 @@ def main(sysv_args=None):
logs_parser(parser_collect_logs)
parser_collect_logs.set_defaults(
action=('collect-logs', handle_collect_logs_args))
+ elif sysv_args[0] == 'clean':
+ from cloudinit.cmd.clean import (
+ get_parser as clean_parser, handle_clean_args)
+ clean_parser(parser_clean)
+ parser_clean.set_defaults(
+ action=('clean', handle_clean_args))
+ elif sysv_args[0] == 'status':
+ from cloudinit.cmd.status import (
+ get_parser as status_parser, handle_status_args)
+ status_parser(parser_status)
+ parser_status.set_defaults(
+ action=('status', handle_status_args))
args = parser.parse_args(args=sysv_args)
diff --git a/cloudinit/cmd/status.py b/cloudinit/cmd/status.py
new file mode 100644
index 0000000..b98a6d5
--- /dev/null
+++ b/cloudinit/cmd/status.py
@@ -0,0 +1,119 @@
+# Copyright (C) 2017 Canonical Ltd.
+#
+# This file is part of cloud-init. See LICENSE file for license information.
+
+"""Define 'status' utility and handler as part of cloud-init commandline."""
+
+import argparse
+from cloudinit.util import load_file, load_json
+import os
+from time import gmtime, strftime, sleep
+import sys
+
+
+CLOUDINIT_STATUS_FILE = '/run/cloud-init/status.json'
+
+# customer visible status messages
+STATUS_ENABLED_NOT_RUN = 'not run'
+STATUS_RUNNING = 'running'
+STATUS_DONE = 'done'
+STATUS_ERROR = 'error'
+
+LONG_STATUS_TMPL = '''\
+status: {status}
+time: {time}
+detail:
+{details}
+'''
+
+
+def get_parser(parser=None):
+ """Build or extend an arg parser for status utility.
+
+ @param parser: Optional existing ArgumentParser instance representing the
+ status subcommand which will be extended to support the args of
+ this utility.
+
+ @returns: ArgumentParser with proper argument configuration.
+ """
+ if not parser:
+ parser = argparse.ArgumentParser(
+ prog='status',
+ description='Report run status of cloud init')
+ parser.add_argument(
+ '-l', '--long', action='store_true', default=False,
+ help=('Report long format of statuses including run stage name and'
+ ' error messages'))
+ parser.add_argument(
+ '-w', '--wait', action='store_true', default=False,
+ help='Block waiting on cloud-init to complete')
+ return parser
+
+
+def handle_status_args(name, args):
+ """Handle calls to 'cloud-init status' as a subcommand."""
+
+ status, status_detail, time = _get_status_details(CLOUDINIT_STATUS_FILE)
+ if args.wait:
+ while status in (STATUS_ENABLED_NOT_RUN, STATUS_RUNNING):
+ sys.stdout.write('.')
+ sys.stdout.flush()
+ status, status_detail, time = _get_status_details(
+ CLOUDINIT_STATUS_FILE)
+ sleep(0.25)
+ sys.stdout.write('\n')
+ if args.long:
+ print(
+ LONG_STATUS_TMPL.format(
+ status=status, details=status_detail, time=time))
+ else:
+ print('status: {0}'.format(status))
+ return 1 if status == STATUS_ERROR else 0
+
+
+def _get_status_details(status_file):
+ """Return a 3-tuple of status, status_details and time of last event.
+
+ Values are obtained from parsing CLOUDINIT_STATUS_FILE.
+ """
+
+ status = STATUS_ENABLED_NOT_RUN
+ status_detail = ''
+ status_v1 = {}
+ if os.path.exists(status_file):
+ status_v1 = load_json(load_file(status_file)).get('v1', {})
+ errors = []
+ latest_event = 0
+ for key, value in sorted(status_v1.items()):
+ if key == 'stage':
+ if value:
+ status_detail = 'Running in stage: {0}'.format(value)
+ elif key == 'datasource':
+ status_detail = value
+ elif isinstance(value, dict):
+ errors.extend(value.get('errors', []))
+ finished = value.get('finished') or 0
+ if finished == 0:
+ status = STATUS_RUNNING
+ event_time = max(value.get('start', 0), finished)
+ if event_time > latest_event:
+ latest_event = event_time
+ if errors:
+ status = STATUS_ERROR
+ status_detail = '\n'.join(errors)
+ elif status == STATUS_ENABLED_NOT_RUN and latest_event > 0:
+ status = STATUS_DONE
+ time = strftime('%a, %d %b %Y %H:%M:%S %z', gmtime(latest_event))
+ return status, status_detail, time
+
+
+def main():
+ """Tool to report status of cloud-init."""
+ parser = get_parser()
+ sys.exit(handle_status_args('status', parser.parse_args()))
+
+
+if __name__ == '__main__':
+ main()
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/cmd/tests/__init__.py b/cloudinit/cmd/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/cloudinit/cmd/tests/__init__.py
diff --git a/cloudinit/cmd/tests/test_clean.py b/cloudinit/cmd/tests/test_clean.py
new file mode 100644
index 0000000..cbe390c
--- /dev/null
+++ b/cloudinit/cmd/tests/test_clean.py
@@ -0,0 +1,135 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+from cloudinit.cmd import clean
+from cloudinit.util import ProcessExecutionError, ensure_dir, write_file
+from cloudinit.tests.helpers import CiTestCase, wrap_and_call, mock
+from collections import namedtuple
+import os
+from six import StringIO
+
+
+class TestClean(CiTestCase):
+
+ def setUp(self):
+ super(TestClean, self).setUp()
+ self.new_root = self.tmp_dir()
+ self.artifact_dir = self.tmp_path('artifacts', self.new_root)
+
+ def test_remove_artifacts_removes_logs(self):
+ """remove_artifacts removes logs when remove_logs is True."""
+ log1 = self.tmp_path('cloud-init.log', self.new_root)
+ write_file(log1, 'cloud-init-log')
+ log2 = self.tmp_path('cloud-init-output.log', self.new_root)
+ write_file(log2, 'cloud-init-output-log')
+
+ self.assertFalse(
+ os.path.exists(self.artifact_dir), 'Unexpected artifacts dir')
+ retcode = wrap_and_call(
+ 'cloudinit.cmd.clean',
+ {'CLOUDINIT_LOGS': {'new': [log1, log2]},
+ 'CLOUDINIT_ARTIFACTS_DIR': {'new': self.artifact_dir}},
+ clean.remove_artifacts, remove_logs=True)
+ self.assertFalse(os.path.exists(log1), 'Unexpected file')
+ self.assertFalse(os.path.exists(log2), 'Unexpected file')
+ self.assertEqual(0, retcode)
+
+ def test_remove_artifacts_preserves_logs(self):
+ """remove_artifacts leaves logs when remove_logs is False."""
+ log1 = self.tmp_path('cloud-init.log', self.new_root)
+ write_file(log1, 'cloud-init-log')
+ log2 = self.tmp_path('cloud-init-output.log', self.new_root)
+ write_file(log2, 'cloud-init-output-log')
+
+ retcode = wrap_and_call(
+ 'cloudinit.cmd.clean',
+ {'CLOUDINIT_LOGS': {'new': [log1, log2]},
+ 'CLOUDINIT_ARTIFACTS_DIR': {'new': self.artifact_dir}},
+ clean.remove_artifacts, remove_logs=False)
+ self.assertTrue(os.path.exists(log1), 'Missing expected file')
+ self.assertTrue(os.path.exists(log2), 'Missing expected file')
+ self.assertEqual(0, retcode)
+
+ def test_remove_artifacts_removes_artifacts_skipping_seed(self):
+ """remove_artifacts cleans artifacts dir with exception of seed dir."""
+ dirs = [
+ self.artifact_dir,
+ os.path.join(self.artifact_dir, 'seed'),
+ os.path.join(self.artifact_dir, 'dir1'),
+ os.path.join(self.artifact_dir, 'dir2')]
+ for _dir in dirs:
+ ensure_dir(_dir)
+
+ retcode = wrap_and_call(
+ 'cloudinit.cmd.clean',
+ {'CLOUDINIT_LOGS': {'new': []},
+ 'CLOUDINIT_ARTIFACTS_DIR': {'new': self.artifact_dir}},
+ clean.remove_artifacts, remove_logs=False)
+ self.assertEqual(0, retcode)
+ for expected_dir in dirs[:2]:
+ self.assertTrue(
+ os.path.exists(expected_dir),
+ 'Missing {0} dir'.format(expected_dir))
+ for deleted_dir in dirs[2:]:
+ self.assertFalse(
+ os.path.exists(deleted_dir),
+ 'Unexpected {0} dir'.format(deleted_dir))
+
+ def test_remove_artifacts_returns_one_on_errors(self):
+ """remove_artifacts returns non-zero on failure and prints an error."""
+ # log1 and log2 will not be created but won't raise errors when absent.
+ log1 = self.tmp_path('cloud-init.log', self.new_root)
+ log2 = self.tmp_path('cloud-init-output.log', self.new_root)
+ ensure_dir(self.artifact_dir)
+ write_file(os.path.join(self.artifact_dir, 'file1'), 'file1')
+
+ with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout:
+ retcode = wrap_and_call(
+ 'cloudinit.cmd.clean',
+ {'subp': {'side_effect': ProcessExecutionError('oops')},
+ 'CLOUDINIT_LOGS': {'new': [log1, log2]},
+ 'CLOUDINIT_ARTIFACTS_DIR': {'new': self.artifact_dir}},
+ clean.remove_artifacts, remove_logs=False)
+ self.assertEqual(1, retcode)
+ self.assertIn(
+ 'ERROR: Could not remove file1:'
+ ' Unexpected error while running command.',
+ m_stdout.getvalue())
+
+ def test_handle_clean_args_reboots(self):
+ """handle_clean_args_reboots when reboot arg is provided."""
+
+ called_cmds = []
+
+ def fake_subp(cmd):
+ called_cmds.append(cmd)
+ return '', ''
+
+ myargs = namedtuple('MyArgs', 'remove_logs reboot')
+ cmdargs = myargs(remove_logs=False, reboot=True)
+ retcode = wrap_and_call(
+ 'cloudinit.cmd.clean',
+ {'which': '/sbin/reboot',
+ 'subp': {'side_effect': fake_subp},
+ 'CLOUDINIT_ARTIFACTS_DIR': {'new': self.artifact_dir}},
+ clean.handle_clean_args, name='does not matter', args=cmdargs)
+ self.assertEqual(0, retcode)
+ self.assertEqual([['sudo', '/sbin/reboot', '-r', 'now']], called_cmds)
+
+ def test_status_main(self):
+ '''clean.main can be run as a standalone script.'''
+ log1 = self.tmp_path('cloud-init.log', self.new_root)
+ write_file(log1, 'cloud-init-log')
+ with self.assertRaises(SystemExit) as context_manager:
+ wrap_and_call(
+ 'cloudinit.cmd.clean',
+ {'CLOUDINIT_LOGS': {'new': [log1]},
+ 'CLOUDINIT_ARTIFACTS_DIR': {'new': self.artifact_dir},
+ 'sys.argv': {'new': ['clean', '--logs']}},
+ clean.main)
+
+ self.assertEqual(0, context_manager.exception.code)
+ self.assertFalse(
+ os.path.exists(log1), 'Unexpected log {0}'.format(log1))
+
+
+# vi: ts=4 expandtab syntax=python
diff --git a/cloudinit/cmd/tests/test_status.py b/cloudinit/cmd/tests/test_status.py
new file mode 100644
index 0000000..d712550
--- /dev/null
+++ b/cloudinit/cmd/tests/test_status.py
@@ -0,0 +1,243 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+from cloudinit.atomic_helper import write_json
+from cloudinit.cmd import status
+from cloudinit.tests.helpers import CiTestCase, wrap_and_call, mock
+from collections import namedtuple
+import os
+from six import StringIO
+from textwrap import dedent
+
+
+class TestStatus(CiTestCase):
+
+ def setUp(self):
+ super(TestStatus, self).setUp()
+ self.new_root = self.tmp_dir()
+ self.status_file = self.tmp_path('status.json', self.new_root)
+ self.args = namedtuple('MyArgs', 'long wait')
+
+ def test_status_returns_not_run(self):
+ '''When CLOUDINIT_STATUS_FILE does not exist yet, return 'not run'.'''
+ self.assertFalse(
+ os.path.exists(self.status_file), 'Unexpected status.json found')
+ self.assertEqual(
+ status.CLOUDINIT_STATUS_FILE, '/run/cloud-init/status.json')
+ cmdargs = self.args(long=False, wait=False)
+ with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout:
+ retcode = wrap_and_call(
+ 'cloudinit.cmd.status',
+ {'CLOUDINIT_STATUS_FILE': {'new': self.status_file}},
+ status.handle_status_args, 'ignored', cmdargs)
+ self.assertEqual(0, retcode)
+ self.assertEqual('status: not run\n', m_stdout.getvalue())
+
+ def test_status_returns_running(self):
+ '''Report running when status file exists but isn't finished.'''
+ write_json(self.status_file, {'v1': {'init': {'finished': None}}})
+ cmdargs = self.args(long=False, wait=False)
+ with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout:
+ retcode = wrap_and_call(
+ 'cloudinit.cmd.status',
+ {'CLOUDINIT_STATUS_FILE': {'new': self.status_file}},
+ status.handle_status_args, 'ignored', cmdargs)
+ self.assertEqual(0, retcode)
+ self.assertEqual('status: running\n', m_stdout.getvalue())
+
+ def test_status_returns_done(self):
+ '''Reports done when stage is None and all stages are finished.'''
+ write_json(
+ self.status_file,
+ {'v1': {'stage': None,
+ 'datasource': (
+ 'DataSourceNoCloud [seed=/var/.../seed/nocloud-net]'
+ '[dsmode=net]'),
+ 'blah': {'finished': 123.456},
+ 'init': {'errors': [], 'start': 124.567,
+ 'finished': 125.678},
+ 'init-local': {'start': 123.45, 'finished': 123.46}}})
+ cmdargs = self.args(long=False, wait=False)
+ with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout:
+ retcode = wrap_and_call(
+ 'cloudinit.cmd.status',
+ {'CLOUDINIT_STATUS_FILE': {'new': self.status_file}},
+ status.handle_status_args, 'ignored', cmdargs)
+ self.assertEqual(0, retcode)
+ self.assertEqual('status: done\n', m_stdout.getvalue())
+
+ def test_status_returns_done_long(self):
+ '''Long format of done status includes datasource info.'''
+ write_json(
+ self.status_file,
+ {'v1': {'stage': None,
+ 'datasource': (
+ 'DataSourceNoCloud [seed=/var/.../seed/nocloud-net]'
+ '[dsmode=net]'),
+ 'init': {'start': 124.567, 'finished': 125.678},
+ 'init-local': {'start': 123.45, 'finished': 123.46}}})
+ cmdargs = self.args(long=True, wait=False)
+ with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout:
+ retcode = wrap_and_call(
+ 'cloudinit.cmd.status',
+ {'CLOUDINIT_STATUS_FILE': {'new': self.status_file}},
+ status.handle_status_args, 'ignored', cmdargs)
+ self.assertEqual(0, retcode)
+ expected = dedent('''\
+ status: done
+ time: Thu, 01 Jan 1970 00:02:05 +0000
+ detail:
+ DataSourceNoCloud [seed=/var/.../seed/nocloud-net][dsmode=net]
+
+ ''')
+ self.assertEqual(expected, m_stdout.getvalue())
+
+ def test_status_on_errors(self):
+ '''Reports error when any stage has errors.'''
+ write_json(
+ self.status_file,
+ {'v1': {'stage': None,
+ 'blah': {'errors': [], 'finished': 123.456},
+ 'init': {'errors': ['error1'], 'start': 124.567,
+ 'finished': 125.678},
+ 'init-local': {'start': 123.45, 'finished': 123.46}}})
+ cmdargs = self.args(long=False, wait=False)
+ with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout:
+ retcode = wrap_and_call(
+ 'cloudinit.cmd.status',
+ {'CLOUDINIT_STATUS_FILE': {'new': self.status_file}},
+ status.handle_status_args, 'ignored', cmdargs)
+ self.assertEqual(1, retcode)
+ self.assertEqual('status: error\n', m_stdout.getvalue())
+
+ def test_status_on_errors_long(self):
+ '''Long format of error status includes all error messages.'''
+ write_json(
+ self.status_file,
+ {'v1': {'stage': None,
+ 'datasource': (
+ 'DataSourceNoCloud [seed=/var/.../seed/nocloud-net]'
+ '[dsmode=net]'),
+ 'init': {'errors': ['error1'], 'start': 124.567,
+ 'finished': 125.678},
+ 'init-local': {'errors': ['error2', 'error3'],
+ 'start': 123.45, 'finished': 123.46}}})
+ cmdargs = self.args(long=True, wait=False)
+ with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout:
+ retcode = wrap_and_call(
+ 'cloudinit.cmd.status',
+ {'CLOUDINIT_STATUS_FILE': {'new': self.status_file}},
+ status.handle_status_args, 'ignored', cmdargs)
+ self.assertEqual(1, retcode)
+ expected = dedent('''\
+ status: error
+ time: Thu, 01 Jan 1970 00:02:05 +0000
+ detail:
+ error1
+ error2
+ error3
+
+ ''')
+ self.assertEqual(expected, m_stdout.getvalue())
+
+ def test_status_returns_running_long_format(self):
+ '''Long format reports the stage in which we are running.'''
+ write_json(
+ self.status_file,
+ {'v1': {'stage': 'init',
+ 'init': {'start': 124.456, 'finished': None},
+ 'init-local': {'start': 123.45, 'finished': 123.46}}})
+ cmdargs = self.args(long=True, wait=False)
+ with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout:
+ retcode = wrap_and_call(
+ 'cloudinit.cmd.status',
+ {'CLOUDINIT_STATUS_FILE': {'new': self.status_file}},
+ status.handle_status_args, 'ignored', cmdargs)
+ self.assertEqual(0, retcode)
+ expected = dedent('''\
+ status: running
+ time: Thu, 01 Jan 1970 00:02:04 +0000
+ detail:
+ Running in stage: init
+
+ ''')
+ self.assertEqual(expected, m_stdout.getvalue())
+
+ def test_status_wait_blocks_until_done(self):
+ '''Specifying wait will poll every 1/4 second until done state.'''
+ running_json = {
+ 'v1': {'stage': 'init',
+ 'init': {'start': 124.456, 'finished': None},
+ 'init-local': {'start': 123.45, 'finished': 123.46}}}
+ done_json = {
+ 'v1': {'stage': None,
+ 'init': {'start': 124.456, 'finished': 125.678},
+ 'init-local': {'start': 123.45, 'finished': 123.46}}}
+
+ self.sleep_calls = 0
+
+ def fake_sleep(interval):
+ self.assertEqual(0.25, interval)
+ self.sleep_calls += 1
+ if self.sleep_calls == 2:
+ write_json(self.status_file, running_json)
+ elif self.sleep_calls == 3:
+ write_json(self.status_file, done_json)
+
+ cmdargs = self.args(long=False, wait=True)
+ with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout:
+ retcode = wrap_and_call(
+ 'cloudinit.cmd.status',
+ {'sleep': {'side_effect': fake_sleep},
+ 'CLOUDINIT_STATUS_FILE': {'new': self.status_file}},
+ status.handle_status_args, 'ignored', cmdargs)
+ self.assertEqual(0, retcode)
+ self.assertEqual(4, self.sleep_calls)
+ self.assertEqual('....\nstatus: done\n', m_stdout.getvalue())
+
+ def test_status_wait_blocks_until_error(self):
+ '''Specifying wait will poll every 1/4 second until error state.'''
+ running_json = {
+ 'v1': {'stage': 'init',
+ 'init': {'start': 124.456, 'finished': None},
+ 'init-local': {'start': 123.45, 'finished': 123.46}}}
+ error_json = {
+ 'v1': {'stage': None,
+ 'init': {'errors': ['error1'], 'start': 124.456,
+ 'finished': 125.678},
+ 'init-local': {'start': 123.45, 'finished': 123.46}}}
+
+ self.sleep_calls = 0
+
+ def fake_sleep(interval):
+ self.assertEqual(0.25, interval)
+ self.sleep_calls += 1
+ if self.sleep_calls == 2:
+ write_json(self.status_file, running_json)
+ elif self.sleep_calls == 3:
+ write_json(self.status_file, error_json)
+
+ cmdargs = self.args(long=False, wait=True)
+ with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout:
+ retcode = wrap_and_call(
+ 'cloudinit.cmd.status',
+ {'sleep': {'side_effect': fake_sleep},
+ 'CLOUDINIT_STATUS_FILE': {'new': self.status_file}},
+ status.handle_status_args, 'ignored', cmdargs)
+ self.assertEqual(1, retcode)
+ self.assertEqual(4, self.sleep_calls)
+ self.assertEqual('....\nstatus: error\n', m_stdout.getvalue())
+
+ def test_status_main(self):
+ '''status.main can be run as a standalone script.'''
+ write_json(self.status_file, {'v1': {'init': {'finished': None}}})
+ with self.assertRaises(SystemExit) as context_manager:
+ with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout:
+ wrap_and_call(
+ 'cloudinit.cmd.status',
+ {'sys.argv': {'new': ['status']},
+ 'CLOUDINIT_STATUS_FILE': {'new': self.status_file}},
+ status.main)
+ self.assertEqual(0, context_manager.exception.code)
+ self.assertEqual('status: running\n', m_stdout.getvalue())
+
+# vi: ts=4 expandtab syntax=python
diff --git a/tests/unittests/test_cli.py b/tests/unittests/test_cli.py
index fccbbd2..a151771 100644
--- a/tests/unittests/test_cli.py
+++ b/tests/unittests/test_cli.py
@@ -46,7 +46,7 @@ class TestCLI(test_helpers.FilesystemMockingTestCase):
self._call_main()
error = self.stderr.getvalue()
expected_subcommands = ['analyze', 'init', 'modules', 'single',
- 'dhclient-hook', 'features', 'devel']
+ 'dhclient-hook', 'features', 'devel', 'clean']
for subcommand in expected_subcommands:
self.assertIn(subcommand, error)
@@ -77,8 +77,10 @@ class TestCLI(test_helpers.FilesystemMockingTestCase):
expected_errors = [
'usage: cloud-init analyze', 'usage: cloud-init collect-logs',
- 'usage: cloud-init devel']
- conditional_subcommands = ['analyze', 'collect-logs', 'devel']
+ 'usage: cloud-init devel', 'usage: cloud-init clean',
+ 'usage: cloud-init status']
+ conditional_subcommands = [
+ 'analyze', 'collect-logs', 'devel', 'clean', 'status']
# The cloud-init entrypoint calls main without passing sys_argv
for subcommand in conditional_subcommands:
with mock.patch('sys.argv', ['cloud-init', subcommand, '-h']):
@@ -106,6 +108,22 @@ class TestCLI(test_helpers.FilesystemMockingTestCase):
self._call_main(['cloud-init', 'collect-logs', '-h'])
self.assertIn('usage: cloud-init collect-log', stdout.getvalue())
+ def test_clean_subcommand_parser(self):
+ """The subcommand cloud-init clean calls the subparser."""
+ # Provide -h param to clean to avoid having to mock behavior.
+ stdout = six.StringIO()
+ self.patchStdoutAndStderr(stdout=stdout)
+ self._call_main(['cloud-init', 'clean', '-h'])
+ self.assertIn('usage: cloud-init clean', stdout.getvalue())
+
+ def test_status_subcommand_parser(self):
+ """The subcommand cloud-init status calls the subparser."""
+ # Provide -h param to clean to avoid having to mock behavior.
+ stdout = six.StringIO()
+ self.patchStdoutAndStderr(stdout=stdout)
+ self._call_main(['cloud-init', 'status', '-h'])
+ self.assertIn('usage: cloud-init status', stdout.getvalue())
+
def test_devel_subcommand_parser(self):
"""The subcommand cloud-init devel calls the correct subparser."""
self._call_main(['cloud-init', 'devel'])
Follow ups