diff options
| author | Chad Smith <chad.smith@canonical.com> | 2017-12-05 16:25:11 -0700 | 
|---|---|---|
| committer | Chad Smith <chad.smith@canonical.com> | 2017-12-05 16:25:11 -0700 | 
| commit | 30b4d15764a1a9644379cf95770e8b2480856882 (patch) | |
| tree | 102b18e80e5ff8bf383a7fe35e56f88328cd924a | |
| parent | 47016791ca5e97d80e45d3f100bc4e5d0b88627d (diff) | |
| download | vyos-cloud-init-30b4d15764a1a9644379cf95770e8b2480856882.tar.gz vyos-cloud-init-30b4d15764a1a9644379cf95770e8b2480856882.zip | |
cli: Add clean and status subcommands
The 'cloud-init clean' command 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 'cloud-init status' command allows the user or script to check whether
cloud-init has finished all configuration stages and whether errors
occurred. An optional --wait argument will poll on a 0.25 second interval
until cloud-init configuration is complete. The benefit here is scripts
can block on cloud-init completion before performing post-config tasks.
| -rw-r--r-- | cloudinit/cmd/clean.py | 102 | ||||
| -rw-r--r-- | cloudinit/cmd/main.py | 18 | ||||
| -rw-r--r-- | cloudinit/cmd/status.py | 157 | ||||
| -rw-r--r-- | cloudinit/cmd/tests/__init__.py | 0 | ||||
| -rw-r--r-- | cloudinit/cmd/tests/test_clean.py | 159 | ||||
| -rw-r--r-- | cloudinit/cmd/tests/test_status.py | 353 | ||||
| -rwxr-xr-x | cloudinit/distros/__init__.py | 16 | ||||
| -rw-r--r-- | cloudinit/util.py | 26 | ||||
| -rw-r--r-- | tests/unittests/test_cli.py | 30 | ||||
| -rw-r--r-- | tests/unittests/test_util.py | 38 | 
10 files changed, 888 insertions, 11 deletions
| diff --git a/cloudinit/cmd/clean.py b/cloudinit/cmd/clean.py new file mode 100644 index 00000000..81797b1c --- /dev/null +++ b/cloudinit/cmd/clean.py @@ -0,0 +1,102 @@ +# 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 +import os +import sys + +from cloudinit.stages import Init +from cloudinit.util import ( +    ProcessExecutionError, chdir, del_dir, del_file, get_config_logfiles, subp) + + +def error(msg): +    sys.stderr.write("ERROR: " + msg + "\n") + + +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.') +    parser.add_argument( +        '-s', '--seed', action='store_true', default=False, dest='remove_seed', +        help='Remove cloud-init seed directory /var/lib/cloud/seed.') +    return parser + + +def remove_artifacts(remove_logs, remove_seed=False): +    """Helper which removes artifacts dir and optionally log files. + +    @param: remove_logs: Boolean. Set True to delete the cloud_dir path. False +        preserves them. +    @param: remove_seed: Boolean. Set True to also delete seed subdir in +        paths.cloud_dir. +    @returns: 0 on success, 1 otherwise. +    """ +    init = Init(ds_deps=[]) +    init.read_cfg() +    if remove_logs: +        for log_file in get_config_logfiles(init.cfg): +            del_file(log_file) + +    if not os.path.isdir(init.paths.cloud_dir): +        return 0  # Artifacts dir already cleaned +    with chdir(init.paths.cloud_dir): +        for path in os.listdir('.'): +            if path == 'seed' and not remove_seed: +                continue +            try: +                if os.path.isdir(path): +                    del_dir(path) +                else: +                    del_file(path) +            except OSError as e: +                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, args.remove_seed) +    if exit_code == 0 and args.reboot: +        cmd = ['shutdown', '-r', 'now'] +        try: +            subp(cmd, capture=False) +        except ProcessExecutionError as e: +            error( +                '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 6fb9d9e7..aa56225d 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 00000000..3e5d0d07 --- /dev/null +++ b/cloudinit/cmd/status.py @@ -0,0 +1,157 @@ +# 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 +import os +import sys +from time import gmtime, strftime, sleep + +from cloudinit.distros import uses_systemd +from cloudinit.stages import Init +from cloudinit.util import get_cmdline, load_file, load_json + +CLOUDINIT_DISABLED_FILE = '/etc/cloud/cloud-init.disabled' + +# customer visible status messages +STATUS_ENABLED_NOT_RUN = 'not run' +STATUS_RUNNING = 'running' +STATUS_DONE = 'done' +STATUS_ERROR = 'error' +STATUS_DISABLED = 'disabled' + + +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.""" +    # Read configured paths +    init = Init(ds_deps=[]) +    init.read_cfg() + +    status, status_detail, time = _get_status_details(init.paths) +    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(init.paths) +            sleep(0.25) +        sys.stdout.write('\n') +    if args.long: +        print('status: {0}'.format(status)) +        if time: +            print('time: {0}'.format(time)) +        print('detail:\n{0}'.format(status_detail)) +    else: +        print('status: {0}'.format(status)) +    return 1 if status == STATUS_ERROR else 0 + + +def _is_cloudinit_disabled(disable_file, paths): +    """Report whether cloud-init is disabled. + +    @param disable_file: The path to the cloud-init disable file. +    @param paths: An initialized cloudinit.helpers.Paths object. +    @returns: A tuple containing (bool, reason) about cloud-init's status and +    why. +    """ +    is_disabled = False +    cmdline_parts = get_cmdline().split() +    if not uses_systemd(): +        reason = 'Cloud-init enabled on sysvinit' +    elif 'cloud-init=enabled' in cmdline_parts: +        reason = 'Cloud-init enabled by kernel command line cloud-init=enabled' +    elif os.path.exists(disable_file): +        is_disabled = True +        reason = 'Cloud-init disabled by {0}'.format(disable_file) +    elif 'cloud-init=disabled' in cmdline_parts: +        is_disabled = True +        reason = 'Cloud-init disabled by kernel parameter cloud-init=disabled' +    elif not os.path.exists(os.path.join(paths.run_dir, 'enabled')): +        is_disabled = True +        reason = 'Cloud-init disabled by cloud-init-generator' +    return (is_disabled, reason) + + +def _get_status_details(paths): +    """Return a 3-tuple of status, status_details and time of last event. + +    @param paths: An initialized cloudinit.helpers.paths object. + +    Values are obtained from parsing paths.run_dir/status.json. +    """ + +    status = STATUS_ENABLED_NOT_RUN +    status_detail = '' +    status_v1 = {} + +    status_file = os.path.join(paths.run_dir, 'status.json') + +    (is_disabled, reason) = _is_cloudinit_disabled( +        CLOUDINIT_DISABLED_FILE, paths) +    if is_disabled: +        status = STATUS_DISABLED +        status_detail = reason +    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 +    if latest_event: +        time = strftime('%a, %d %b %Y %H:%M:%S %z', gmtime(latest_event)) +    else: +        time = '' +    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 00000000..e69de29b --- /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 00000000..af438aab --- /dev/null +++ b/cloudinit/cmd/tests/test_clean.py @@ -0,0 +1,159 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +from cloudinit.cmd import clean +from cloudinit.util import ensure_dir, write_file +from cloudinit.tests.helpers import CiTestCase, wrap_and_call, mock +from collections import namedtuple +import os +from six import StringIO + +mypaths = namedtuple('MyPaths', 'cloud_dir') + + +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) +        self.log1 = self.tmp_path('cloud-init.log', self.new_root) +        self.log2 = self.tmp_path('cloud-init-output.log', self.new_root) + +        class FakeInit(object): +            cfg = {'def_log_file': self.log1, +                   'output': {'all': '|tee -a {0}'.format(self.log2)}} +            paths = mypaths(cloud_dir=self.artifact_dir) + +            def __init__(self, ds_deps): +                pass + +            def read_cfg(self): +                pass + +        self.init_class = FakeInit + +    def test_remove_artifacts_removes_logs(self): +        """remove_artifacts removes logs when remove_logs is True.""" +        write_file(self.log1, 'cloud-init-log') +        write_file(self.log2, 'cloud-init-output-log') + +        self.assertFalse( +            os.path.exists(self.artifact_dir), 'Unexpected artifacts dir') +        retcode = wrap_and_call( +            'cloudinit.cmd.clean', +            {'Init': {'side_effect': self.init_class}}, +            clean.remove_artifacts, remove_logs=True) +        self.assertFalse(os.path.exists(self.log1), 'Unexpected file') +        self.assertFalse(os.path.exists(self.log2), 'Unexpected file') +        self.assertEqual(0, retcode) + +    def test_remove_artifacts_preserves_logs(self): +        """remove_artifacts leaves logs when remove_logs is False.""" +        write_file(self.log1, 'cloud-init-log') +        write_file(self.log2, 'cloud-init-output-log') + +        retcode = wrap_and_call( +            'cloudinit.cmd.clean', +            {'Init': {'side_effect': self.init_class}}, +            clean.remove_artifacts, remove_logs=False) +        self.assertTrue(os.path.exists(self.log1), 'Missing expected file') +        self.assertTrue(os.path.exists(self.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', +            {'Init': {'side_effect': self.init_class}}, +            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_removes_artifacts_removes_seed(self): +        """remove_artifacts removes seed dir when remove_seed is True.""" +        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', +            {'Init': {'side_effect': self.init_class}}, +            clean.remove_artifacts, remove_logs=False, remove_seed=True) +        self.assertEqual(0, retcode) +        self.assertTrue( +            os.path.exists(self.artifact_dir), 'Missing artifact dir') +        for deleted_dir in dirs[1:]: +            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.""" +        ensure_dir(self.artifact_dir) +        ensure_dir(os.path.join(self.artifact_dir, 'dir1')) + +        with mock.patch('sys.stderr', new_callable=StringIO) as m_stderr: +            retcode = wrap_and_call( +                'cloudinit.cmd.clean', +                {'del_dir': {'side_effect': OSError('oops')}, +                 'Init': {'side_effect': self.init_class}}, +                clean.remove_artifacts, remove_logs=False) +        self.assertEqual(1, retcode) +        self.assertEqual( +            'ERROR: Could not remove dir1: oops\n', m_stderr.getvalue()) + +    def test_handle_clean_args_reboots(self): +        """handle_clean_args_reboots when reboot arg is provided.""" + +        called_cmds = [] + +        def fake_subp(cmd, capture): +            called_cmds.append((cmd, capture)) +            return '', '' + +        myargs = namedtuple('MyArgs', 'remove_logs remove_seed reboot') +        cmdargs = myargs(remove_logs=False, remove_seed=False, reboot=True) +        retcode = wrap_and_call( +            'cloudinit.cmd.clean', +            {'subp': {'side_effect': fake_subp}, +             'Init': {'side_effect': self.init_class}}, +            clean.handle_clean_args, name='does not matter', args=cmdargs) +        self.assertEqual(0, retcode) +        self.assertEqual( +            [(['shutdown', '-r', 'now'], False)], called_cmds) + +    def test_status_main(self): +        '''clean.main can be run as a standalone script.''' +        write_file(self.log1, 'cloud-init-log') +        with self.assertRaises(SystemExit) as context_manager: +            wrap_and_call( +                'cloudinit.cmd.clean', +                {'Init': {'side_effect': self.init_class}, +                 'sys.argv': {'new': ['clean', '--logs']}}, +                clean.main) + +        self.assertEqual(0, context_manager.exception.code) +        self.assertFalse( +            os.path.exists(self.log1), 'Unexpected log {0}'.format(self.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 00000000..8ec9b5bc --- /dev/null +++ b/cloudinit/cmd/tests/test_status.py @@ -0,0 +1,353 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +from collections import namedtuple +import os +from six import StringIO +from textwrap import dedent + +from cloudinit.atomic_helper import write_json +from cloudinit.cmd import status +from cloudinit.util import write_file +from cloudinit.tests.helpers import CiTestCase, wrap_and_call, mock + +mypaths = namedtuple('MyPaths', 'run_dir') +myargs = namedtuple('MyArgs', 'long wait') + + +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.disable_file = self.tmp_path('cloudinit-disable', self.new_root) +        self.paths = mypaths(run_dir=self.new_root) + +        class FakeInit(object): +            paths = self.paths + +            def __init__(self, ds_deps): +                pass + +            def read_cfg(self): +                pass + +        self.init_class = FakeInit + +    def test__is_cloudinit_disabled_false_on_sysvinit(self): +        '''When not in an environment using systemd, return False.''' +        write_file(self.disable_file, '')  # Create the ignored disable file +        (is_disabled, reason) = wrap_and_call( +            'cloudinit.cmd.status', +            {'uses_systemd': False}, +            status._is_cloudinit_disabled, self.disable_file, self.paths) +        self.assertFalse( +            is_disabled, 'expected enabled cloud-init on sysvinit') +        self.assertEqual('Cloud-init enabled on sysvinit', reason) + +    def test__is_cloudinit_disabled_true_on_disable_file(self): +        '''When using systemd and disable_file is present return disabled.''' +        write_file(self.disable_file, '')  # Create observed disable file +        (is_disabled, reason) = wrap_and_call( +            'cloudinit.cmd.status', +            {'uses_systemd': True}, +            status._is_cloudinit_disabled, self.disable_file, self.paths) +        self.assertTrue(is_disabled, 'expected disabled cloud-init') +        self.assertEqual( +            'Cloud-init disabled by {0}'.format(self.disable_file), reason) + +    def test__is_cloudinit_disabled_false_on_kernel_cmdline_enable(self): +        '''Not disabled when using systemd and enabled via commandline.''' +        write_file(self.disable_file, '')  # Create ignored disable file +        (is_disabled, reason) = wrap_and_call( +            'cloudinit.cmd.status', +            {'uses_systemd': True, +             'get_cmdline': 'something cloud-init=enabled else'}, +            status._is_cloudinit_disabled, self.disable_file, self.paths) +        self.assertFalse(is_disabled, 'expected enabled cloud-init') +        self.assertEqual( +            'Cloud-init enabled by kernel command line cloud-init=enabled', +            reason) + +    def test__is_cloudinit_disabled_true_on_kernel_cmdline(self): +        '''When using systemd and disable_file is present return disabled.''' +        (is_disabled, reason) = wrap_and_call( +            'cloudinit.cmd.status', +            {'uses_systemd': True, +             'get_cmdline': 'something cloud-init=disabled else'}, +            status._is_cloudinit_disabled, self.disable_file, self.paths) +        self.assertTrue(is_disabled, 'expected disabled cloud-init') +        self.assertEqual( +            'Cloud-init disabled by kernel parameter cloud-init=disabled', +            reason) + +    def test__is_cloudinit_disabled_true_when_generator_disables(self): +        '''When cloud-init-generator doesn't write enabled file return True.''' +        enabled_file = os.path.join(self.paths.run_dir, 'enabled') +        self.assertFalse(os.path.exists(enabled_file)) +        (is_disabled, reason) = wrap_and_call( +            'cloudinit.cmd.status', +            {'uses_systemd': True, +             'get_cmdline': 'something'}, +            status._is_cloudinit_disabled, self.disable_file, self.paths) +        self.assertTrue(is_disabled, 'expected disabled cloud-init') +        self.assertEqual('Cloud-init disabled by cloud-init-generator', reason) + +    def test_status_returns_not_run(self): +        '''When status.json does not exist yet, return 'not run'.''' +        self.assertFalse( +            os.path.exists(self.status_file), 'Unexpected status.json found') +        cmdargs = myargs(long=False, wait=False) +        with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: +            retcode = wrap_and_call( +                'cloudinit.cmd.status', +                {'_is_cloudinit_disabled': (False, ''), +                 'Init': {'side_effect': self.init_class}}, +                status.handle_status_args, 'ignored', cmdargs) +        self.assertEqual(0, retcode) +        self.assertEqual('status: not run\n', m_stdout.getvalue()) + +    def test_status_returns_disabled_long_on_presence_of_disable_file(self): +        '''When cloudinit is disabled, return disabled reason.''' + +        checked_files = [] + +        def fakeexists(filepath): +            checked_files.append(filepath) +            status_file = os.path.join(self.paths.run_dir, 'status.json') +            return bool(not filepath == status_file) + +        cmdargs = myargs(long=True, wait=False) +        with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: +            retcode = wrap_and_call( +                'cloudinit.cmd.status', +                {'os.path.exists': {'side_effect': fakeexists}, +                 '_is_cloudinit_disabled': (True, 'disabled for some reason'), +                 'Init': {'side_effect': self.init_class}}, +                status.handle_status_args, 'ignored', cmdargs) +        self.assertEqual(0, retcode) +        self.assertEqual( +            [os.path.join(self.paths.run_dir, 'status.json')], +            checked_files) +        expected = dedent('''\ +            status: disabled +            detail: +            disabled for some reason +        ''') +        self.assertEqual(expected, 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 = myargs(long=False, wait=False) +        with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: +            retcode = wrap_and_call( +                'cloudinit.cmd.status', +                {'_is_cloudinit_disabled': (False, ''), +                 'Init': {'side_effect': self.init_class}}, +                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 = myargs(long=False, wait=False) +        with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: +            retcode = wrap_and_call( +                'cloudinit.cmd.status', +                {'_is_cloudinit_disabled': (False, ''), +                 'Init': {'side_effect': self.init_class}}, +                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 = myargs(long=True, wait=False) +        with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: +            retcode = wrap_and_call( +                'cloudinit.cmd.status', +                {'_is_cloudinit_disabled': (False, ''), +                 'Init': {'side_effect': self.init_class}}, +                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 = myargs(long=False, wait=False) +        with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: +            retcode = wrap_and_call( +                'cloudinit.cmd.status', +                {'_is_cloudinit_disabled': (False, ''), +                 'Init': {'side_effect': self.init_class}}, +                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 = myargs(long=True, wait=False) +        with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: +            retcode = wrap_and_call( +                'cloudinit.cmd.status', +                {'_is_cloudinit_disabled': (False, ''), +                 'Init': {'side_effect': self.init_class}}, +                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 = myargs(long=True, wait=False) +        with mock.patch('sys.stdout', new_callable=StringIO) as m_stdout: +            retcode = wrap_and_call( +                'cloudinit.cmd.status', +                {'_is_cloudinit_disabled': (False, ''), +                 'Init': {'side_effect': self.init_class}}, +                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 = myargs(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}, +                 '_is_cloudinit_disabled': (False, ''), +                 'Init': {'side_effect': self.init_class}}, +                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 = myargs(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}, +                 '_is_cloudinit_disabled': (False, ''), +                 'Init': {'side_effect': self.init_class}}, +                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']}, +                     '_is_cloudinit_disabled': (False, ''), +                     'Init': {'side_effect': self.init_class}}, +                    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/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py index d5becd12..99e60e7a 100755 --- a/cloudinit/distros/__init__.py +++ b/cloudinit/distros/__init__.py @@ -102,11 +102,8 @@ class Distro(object):          self._apply_hostname(writeable_hostname)      def uses_systemd(self): -        try: -            res = os.lstat('/run/systemd/system') -            return stat.S_ISDIR(res.st_mode) -        except Exception: -            return False +        """Wrapper to report whether this distro uses systemd or sysvinit.""" +        return uses_systemd()      @abc.abstractmethod      def package_command(self, cmd, args=None, pkgs=None): @@ -761,4 +758,13 @@ def set_etc_timezone(tz, tz_file=None, tz_conf="/etc/timezone",              util.copy(tz_file, tz_local)      return + +def uses_systemd(): +    try: +        res = os.lstat('/run/systemd/system') +        return stat.S_ISDIR(res.st_mode) +    except Exception: +        return False + +  # vi: ts=4 expandtab diff --git a/cloudinit/util.py b/cloudinit/util.py index 6c014ba5..320d64e0 100644 --- a/cloudinit/util.py +++ b/cloudinit/util.py @@ -1398,6 +1398,32 @@ def get_output_cfg(cfg, mode):      return ret +def get_config_logfiles(cfg): +    """Return a list of log file paths from the configuration dictionary. + +    @param cfg: The cloud-init merged configuration dictionary. +    """ +    logs = [] +    if not cfg or not isinstance(cfg, dict): +        return logs +    default_log = cfg.get('def_log_file') +    if default_log: +        logs.append(default_log) +    for fmt in get_output_cfg(cfg, None): +        if not fmt: +            continue +        match = re.match('(?P<type>\||>+)\s*(?P<target>.*)', fmt) +        if not match: +            continue +        target = match.group('target') +        parts = target.split() +        if len(parts) == 1: +            logs.append(target) +        elif ['tee', '-a'] == parts[:2]: +            logs.append(parts[2]) +    return list(set(logs)) + +  def logexc(log, msg, *args):      # Setting this here allows this to change      # levels easily (not always error level) diff --git a/tests/unittests/test_cli.py b/tests/unittests/test_cli.py index fccbbd23..a8d28ae6 100644 --- a/tests/unittests/test_cli.py +++ b/tests/unittests/test_cli.py @@ -2,9 +2,9 @@  import six +from cloudinit.cmd import main as cli  from cloudinit.tests import helpers as test_helpers -from cloudinit.cmd import main as cli  mock = test_helpers.mock @@ -45,8 +45,8 @@ class TestCLI(test_helpers.FilesystemMockingTestCase):          """All known subparsers are represented in the cloud-int help doc."""          self._call_main()          error = self.stderr.getvalue() -        expected_subcommands = ['analyze', 'init', 'modules', 'single', -                                'dhclient-hook', 'features', 'devel'] +        expected_subcommands = ['analyze', 'clean', 'devel', 'dhclient-hook', +                                'features', 'init', 'modules', 'single']          for subcommand in expected_subcommands:              self.assertIn(subcommand, error) @@ -76,9 +76,11 @@ class TestCLI(test_helpers.FilesystemMockingTestCase):          self.patchStdoutAndStderr(stdout=stdout)          expected_errors = [ -            'usage: cloud-init analyze', 'usage: cloud-init collect-logs', -            'usage: cloud-init devel'] -        conditional_subcommands = ['analyze', 'collect-logs', 'devel'] +            'usage: cloud-init analyze', 'usage: cloud-init clean', +            'usage: cloud-init collect-logs', 'usage: cloud-init devel', +            'usage: cloud-init status'] +        conditional_subcommands = [ +            'analyze', 'clean', 'collect-logs', 'devel', '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']) diff --git a/tests/unittests/test_util.py b/tests/unittests/test_util.py index 3e4154ca..71f59529 100644 --- a/tests/unittests/test_util.py +++ b/tests/unittests/test_util.py @@ -477,6 +477,44 @@ class TestReadDMIData(helpers.FilesystemMockingTestCase):          self.assertIsNone(util.read_dmi_data("system-product-name")) +class TestGetConfigLogfiles(helpers.CiTestCase): + +    def test_empty_cfg_returns_empty_list(self): +        """An empty config passed to get_config_logfiles returns empty list.""" +        self.assertEqual([], util.get_config_logfiles(None)) +        self.assertEqual([], util.get_config_logfiles({})) + +    def test_default_log_file_present(self): +        """When default_log_file is set get_config_logfiles finds it.""" +        self.assertEqual( +            ['/my.log'], +            util.get_config_logfiles({'def_log_file': '/my.log'})) + +    def test_output_logs_parsed_when_teeing_files(self): +        """When output configuration is parsed when teeing files.""" +        self.assertEqual( +            ['/himom.log', '/my.log'], +            sorted(util.get_config_logfiles({ +                'def_log_file': '/my.log', +                'output': {'all': '|tee -a /himom.log'}}))) + +    def test_output_logs_parsed_when_redirecting(self): +        """When output configuration is parsed when redirecting to a file.""" +        self.assertEqual( +            ['/my.log', '/test.log'], +            sorted(util.get_config_logfiles({ +                'def_log_file': '/my.log', +                'output': {'all': '>/test.log'}}))) + +    def test_output_logs_parsed_when_appending(self): +        """When output configuration is parsed when appending to a file.""" +        self.assertEqual( +            ['/my.log', '/test.log'], +            sorted(util.get_config_logfiles({ +                'def_log_file': '/my.log', +                'output': {'all': '>> /test.log'}}))) + +  class TestMultiLog(helpers.FilesystemMockingTestCase):      def _createConsole(self, root): | 
