← Back to team overview

cloud-init-dev team mailing list archive

[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