summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChad Smith <chad.smith@canonical.com>2017-12-05 16:25:11 -0700
committerChad Smith <chad.smith@canonical.com>2017-12-05 16:25:11 -0700
commit30b4d15764a1a9644379cf95770e8b2480856882 (patch)
tree102b18e80e5ff8bf383a7fe35e56f88328cd924a
parent47016791ca5e97d80e45d3f100bc4e5d0b88627d (diff)
downloadvyos-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.py102
-rw-r--r--cloudinit/cmd/main.py18
-rw-r--r--cloudinit/cmd/status.py157
-rw-r--r--cloudinit/cmd/tests/__init__.py0
-rw-r--r--cloudinit/cmd/tests/test_clean.py159
-rw-r--r--cloudinit/cmd/tests/test_status.py353
-rwxr-xr-xcloudinit/distros/__init__.py16
-rw-r--r--cloudinit/util.py26
-rw-r--r--tests/unittests/test_cli.py30
-rw-r--r--tests/unittests/test_util.py38
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):