cloud-init-dev team mailing list archive
-
cloud-init-dev team
-
Mailing list archive
-
Message #03251
[Merge] ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master
Chad Smith has proposed merging ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd 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/330243
schema: Add json schema to resizefs and bootcmd modules
Add schema definitions to both cc_resizefs and cc_bootcmd modules. Those
schema defintions are used to generate module documention and log warnings
for schema infractions.
This branch also drops vestigial 'resize_rootfs_tmp' option as it
effectively only ensured a directory was created and didn't make use of
that directory for any resize operations.
--
Your team cloud-init commiters is requested to review the proposed merge of ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master.
diff --git a/cloudinit/config/cc_bootcmd.py b/cloudinit/config/cc_bootcmd.py
index 604f93b..fc787aa 100644
--- a/cloudinit/config/cc_bootcmd.py
+++ b/cloudinit/config/cc_bootcmd.py
@@ -3,44 +3,69 @@
#
# Author: Scott Moser <scott.moser@xxxxxxxxxxxxx>
# Author: Juerg Haefliger <juerg.haefliger@xxxxxx>
+# Author: Chad Smith <chad.smith@xxxxxxxxxxxxx>
#
# This file is part of cloud-init. See LICENSE file for license information.
-"""
-Bootcmd
--------
-**Summary:** run commands early in boot process
-
-This module runs arbitrary commands very early in the boot process,
-only slightly after a boothook would run. This is very similar to a
-boothook, but more user friendly. The environment variable ``INSTANCE_ID``
-will be set to the current instance id for all run commands. Commands can be
-specified either as lists or strings. For invocation details, see ``runcmd``.
-
-.. note::
- bootcmd should only be used for things that could not be done later in the
- boot process.
-
-**Internal name:** ``cc_bootcmd``
-
-**Module frequency:** per always
-
-**Supported distros:** all
-
-**Config keys**::
-
- bootcmd:
- - echo 192.168.1.130 us.archive.ubuntu.com > /etc/hosts
- - [ cloud-init-per, once, mymkfs, mkfs, /dev/vdb ]
-"""
+"""Bootcmd: run arbitrary commands early in the boot process."""
import os
+from textwrap import dedent
+from cloudinit.config.schema import validate_cloudconfig_schema
from cloudinit.settings import PER_ALWAYS
from cloudinit import util
frequency = PER_ALWAYS
+# The schema definition for each cloud-config module is a strict contract for
+# describing supported configuration parameters for each cloud-config section.
+# It allows cloud-config to validate and alert users to invalid or ignored
+# configuration options before actually attempting to deploy with said
+# configuration.
+
+distros = ['all']
+
+schema = {
+ 'id': 'cc_bootcmd',
+ 'name': 'Bootcmd',
+ 'title': 'Run arbitrary commands early in the boot process',
+ 'description': dedent("""\
+ This module runs arbitrary commands very early in the boot process,
+ only slightly after a boothook would run. This is very similar to a
+ boothook, but more user friendly. The environment variable
+ ``INSTANCE_ID`` will be set to the current instance id for all run
+ commands. Commands can be specified either as lists or strings. For
+ invocation details, see ``runcmd``.
+
+ .. note::
+ bootcmd should only be used for things that could not be done later
+ in the boot process."""),
+ 'distros': distros,
+ 'examples': [dedent("""\
+ bootcmd:
+ - echo 192.168.1.130 us.archive.ubuntu.com > /etc/hosts
+ - [ cloud-init-per, once, mymkfs, mkfs, /dev/vdb ]
+ """)],
+ 'frequency': PER_ALWAYS,
+ 'type': 'object',
+ 'properties': {
+ 'bootcmd': {
+ 'type': 'array',
+ 'items': {
+ 'oneOf': [
+ {'type': 'array', 'items': {'type': 'string'}},
+ {'type': 'string'}]
+ },
+ 'additionalItems': False, # Reject items of non-string non-list
+ 'additionalProperties': False,
+ 'minItems': 1,
+ 'required': [],
+ 'uniqueItems': True
+ }
+ }
+}
+
def handle(name, cfg, cloud, log, _args):
@@ -49,13 +74,14 @@ def handle(name, cfg, cloud, log, _args):
" no 'bootcmd' key in configuration"), name)
return
+ validate_cloudconfig_schema(cfg, schema)
with util.ExtendedTemporaryFile(suffix=".sh") as tmpf:
try:
content = util.shellify(cfg["bootcmd"])
tmpf.write(util.encode_text(content))
tmpf.flush()
- except Exception:
- util.logexc(log, "Failed to shellify bootcmd")
+ except Exception as e:
+ util.logexc(log, "Failed to shellify bootcmd: %s", str(e))
raise
try:
diff --git a/cloudinit/config/cc_resizefs.py b/cloudinit/config/cc_resizefs.py
index ceee952..718cdef 100644
--- a/cloudinit/config/cc_resizefs.py
+++ b/cloudinit/config/cc_resizefs.py
@@ -6,31 +6,8 @@
#
# This file is part of cloud-init. See LICENSE file for license information.
-"""
-Resizefs
---------
-**Summary:** resize filesystem
+"""Resizefs: cloud-config module which resizes the filesystem"""
-Resize a filesystem to use all avaliable space on partition. This module is
-useful along with ``cc_growpart`` and will ensure that if the root partition
-has been resized the root filesystem will be resized along with it. By default,
-``cc_resizefs`` will resize the root partition and will block the boot process
-while the resize command is running. Optionally, the resize operation can be
-performed in the background while cloud-init continues running modules. This
-can be enabled by setting ``resize_rootfs`` to ``true``. This module can be
-disabled altogether by setting ``resize_rootfs`` to ``false``.
-
-**Internal name:** ``cc_resizefs``
-
-**Module frequency:** per always
-
-**Supported distros:** all
-
-**Config keys**::
-
- resize_rootfs: <true/false/"noblock">
- resize_rootfs_tmp: <directory>
-"""
import errno
import getopt
@@ -38,11 +15,42 @@ import os
import re
import shlex
import stat
+from textwrap import dedent
+from cloudinit.config.schema import validate_cloudconfig_schema
from cloudinit.settings import PER_ALWAYS
from cloudinit import util
+NOBLOCK = "noblock"
+
frequency = PER_ALWAYS
+distros = ['all']
+
+schema = {
+ 'id': 'cc_resizefs',
+ 'name': 'Resizefs',
+ 'title': 'Resize filesystem',
+ 'description': dedent("""\
+ Resize a filesystem to use all avaliable space on partition. This
+ module is useful along with ``cc_growpart`` and will ensure that if the
+ root partition has been resized the root filesystem will be resized
+ along with it. By default, ``cc_resizefs`` will resize the root
+ partition and will block the boot process while the resize command is
+ running. Optionally, the resize operation can be performed in the
+ background while cloud-init continues running modules. This can be
+ enabled by setting ``resize_rootfs`` to ``true``. This module can be
+ disabled altogether by setting ``resize_rootfs`` to ``false``."""),
+ 'distros': distros,
+ 'frequency': PER_ALWAYS,
+ 'type': 'object',
+ 'properties': {
+ 'resize_rootfs': {
+ 'enum': [True, False, NOBLOCK],
+ 'description': dedent("""\
+ Whether to resize the root partition. Default: 'true'""")
+ }
+ }
+}
def _resize_btrfs(mount_point, devpth):
@@ -131,8 +139,6 @@ RESIZE_FS_PRECHECK_CMDS = {
'ufs': _can_skip_resize_ufs
}
-NOBLOCK = "noblock"
-
def rootdev_from_cmdline(cmdline):
found = None
@@ -161,71 +167,81 @@ def can_skip_resize(fs_type, resize_what, devpth):
return False
-def handle(name, cfg, _cloud, log, args):
- if len(args) != 0:
- resize_root = args[0]
- else:
- resize_root = util.get_cfg_option_str(cfg, "resize_rootfs", True)
-
- if not util.translate_bool(resize_root, addons=[NOBLOCK]):
- log.debug("Skipping module named %s, resizing disabled", name)
- return
-
- # TODO(harlowja) is the directory ok to be used??
- resize_root_d = util.get_cfg_option_str(cfg, "resize_rootfs_tmp", "/run")
- util.ensure_dir(resize_root_d)
-
- # TODO(harlowja): allow what is to be resized to be configurable??
- resize_what = "/"
- result = util.get_mount_info(resize_what, log)
- if not result:
- log.warn("Could not determine filesystem type of %s", resize_what)
- return
-
- (devpth, fs_type, mount_point) = result
+def is_device_path_writable_block(devpath, info, log):
+ """Return True if devpath is a writable block device.
- info = "dev=%s mnt_point=%s path=%s" % (devpth, mount_point, resize_what)
- log.debug("resize_info: %s" % info)
+ @param devpath: Path to the root device we want to resize.
+ @param info: String representing information about the requested device.
+ @param log: Logger to which logs will be added upon error.
+ @returns Boolean True if block device is writable
+ """
container = util.is_container()
# Ensure the path is a block device.
- if (devpth == "/dev/root" and not os.path.exists(devpth) and
+ if (devpath == "/dev/root" and not os.path.exists(devpath) and
not container):
- devpth = util.rootdev_from_cmdline(util.get_cmdline())
- if devpth is None:
+ devpath = util.rootdev_from_cmdline(util.get_cmdline())
+ if devpath is None:
log.warn("Unable to find device '/dev/root'")
- return
- log.debug("Converted /dev/root to '%s' per kernel cmdline", devpth)
+ return False
+ log.debug("Converted /dev/root to '%s' per kernel cmdline", devpath)
try:
- statret = os.stat(devpth)
+ statret = os.stat(devpath)
except OSError as exc:
if container and exc.errno == errno.ENOENT:
log.debug("Device '%s' did not exist in container. "
- "cannot resize: %s", devpth, info)
+ "cannot resize: %s", devpath, info)
elif exc.errno == errno.ENOENT:
log.warn("Device '%s' did not exist. cannot resize: %s",
- devpth, info)
+ devpath, info)
else:
raise exc
- return
+ return False
- if not os.access(devpth, os.W_OK):
+ if not os.access(devpath, os.W_OK):
if container:
log.debug("'%s' not writable in container. cannot resize: %s",
- devpth, info)
+ devpath, info)
else:
- log.warn("'%s' not writable. cannot resize: %s", devpth, info)
+ log.warn("'%s' not writable. cannot resize: %s", devpath, info)
return
if not stat.S_ISBLK(statret.st_mode) and not stat.S_ISCHR(statret.st_mode):
if container:
log.debug("device '%s' not a block device in container."
- " cannot resize: %s" % (devpth, info))
+ " cannot resize: %s" % (devpath, info))
else:
log.warn("device '%s' not a block device. cannot resize: %s" %
- (devpth, info))
+ (devpath, info))
+ return False
+ return True
+
+
+def handle(name, cfg, _cloud, log, args):
+ if len(args) != 0:
+ resize_root = args[0]
+ else:
+ resize_root = util.get_cfg_option_str(cfg, "resize_rootfs", True)
+ validate_cloudconfig_schema(cfg, schema)
+ if not util.translate_bool(resize_root, addons=[NOBLOCK]):
+ log.debug("Skipping module named %s, resizing disabled", name)
+ return
+
+ # TODO(harlowja): allow what is to be resized to be configurable??
+ resize_what = "/"
+ result = util.get_mount_info(resize_what, log)
+ if not result:
+ log.warn("Could not determine filesystem type of %s", resize_what)
+ return
+
+ (devpth, fs_type, mount_point) = result
+
+ info = "dev=%s mnt_point=%s path=%s" % (devpth, mount_point, resize_what)
+ log.debug("resize_info: %s" % info)
+
+ if not is_device_path_writable_block(devpth, info, log):
return
resizer = None
diff --git a/cloudinit/config/schema.py b/cloudinit/config/schema.py
index 73dd5c2..16a290e 100644
--- a/cloudinit/config/schema.py
+++ b/cloudinit/config/schema.py
@@ -212,6 +212,9 @@ def _schemapath_for_cloudconfig(config, original_content):
def _get_property_type(property_dict):
"""Return a string representing a property type from a given jsonschema."""
property_type = property_dict.get('type', SCHEMA_UNDEFINED)
+ if property_type == SCHEMA_UNDEFINED and property_dict.get('enum'):
+ yaml_string = yaml.dump(property_dict.get('enum')).strip()
+ property_type = yaml_string[1:-1].replace(', ', '/')
if isinstance(property_type, list):
property_type = '/'.join(property_type)
items = property_dict.get('items', {})
diff --git a/tests/unittests/test_handler/test_handler_bootcmd.py b/tests/unittests/test_handler/test_handler_bootcmd.py
new file mode 100644
index 0000000..3d37245
--- /dev/null
+++ b/tests/unittests/test_handler/test_handler_bootcmd.py
@@ -0,0 +1,161 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+from cloudinit.config import cc_bootcmd
+from cloudinit.sources import DataSourceNone
+from cloudinit import (distros, helpers, cloud, util)
+from ..helpers import CiTestCase, mock, skipIf
+
+import logging
+import os
+import stat
+
+try:
+ import jsonschema
+ assert jsonschema # avoid pyflakes error F401: import unused
+ _missing_jsonschema_dep = False
+except ImportError:
+ _missing_jsonschema_dep = True
+
+LOG = logging.getLogger(__name__)
+
+
+class TestBootcmd(CiTestCase):
+
+ with_logs = True
+
+ def setUp(self):
+ super(TestBootcmd, self).setUp()
+ self.subp = util.subp
+ self.new_root = self.tmp_dir()
+
+ def _get_cloud(self, distro):
+ paths = helpers.Paths({})
+ cls = distros.fetch(distro)
+ mydist = cls(distro, {}, paths)
+ myds = DataSourceNone.DataSourceNone({}, mydist, paths)
+ paths.datasource = myds
+ return cloud.Cloud(myds, paths, {}, mydist, None)
+
+ def test_handler_skip_if_no_bootcmd(self):
+ """When the provided config doesn't contain bootcmd, skip it."""
+ cfg = {}
+ mycloud = self._get_cloud('ubuntu')
+ cc_bootcmd.handle('notimportant', cfg, mycloud, LOG, None)
+ self.assertIn(
+ "Skipping module named notimportant, no 'bootcmd' key",
+ self.logs.getvalue())
+
+ def test_handler_invalid_command_set(self):
+ """Commands which can't be converted to shell will raise errors."""
+ invalid_config = {'bootcmd': 1}
+ cc = self._get_cloud('ubuntu')
+ with self.assertRaises(TypeError) as context_manager:
+ cc_bootcmd.handle('cc_bootcmd', invalid_config, cc, LOG, [])
+ self.assertIn('Failed to shellify bootcmd', self.logs.getvalue())
+ self.assertEqual(
+ "'int' object is not iterable",
+ str(context_manager.exception))
+
+ @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
+ def test_handler_schema_validation_warns_non_array_type(self):
+ """Schema validation warns of non-array type for bootcmd key.
+
+ Schema validation is not strict, so bootcmd attempts to shellify the
+ invalid content.
+ """
+ invalid_config = {'bootcmd': 1}
+ cc = self._get_cloud('ubuntu')
+ with self.assertRaises(TypeError):
+ cc_bootcmd.handle('cc_bootcmd', invalid_config, cc, LOG, [])
+ self.assertIn(
+ 'Invalid config:\nbootcmd: 1 is not of type \'array\'',
+ self.logs.getvalue())
+ self.assertIn('Failed to shellify', self.logs.getvalue())
+
+ @skipIf(_missing_jsonschema_dep, 'No python-jsonschema dependency')
+ def test_handler_schema_validation_warns_non_array_item_type(self):
+ """Schema validation warns of non-array or string bootcmd items.
+
+ Schema validation is not strict, so bootcmd attempts to shellify the
+ invalid content.
+ """
+ invalid_config = {
+ 'bootcmd': ['ls /', 20, ['wget', 'http://stuff/blah'], {'a': 'n'}]}
+ cc = self._get_cloud('ubuntu')
+ with self.assertRaises(RuntimeError) as context_manager:
+ cc_bootcmd.handle('cc_bootcmd', invalid_config, cc, LOG, [])
+ expected_warnings = [
+ 'bootcmd.1: 20 is not valid under any of the given schemas',
+ 'bootcmd.3: {\'a\': \'n\'} is not valid under any of the given'
+ ' schema'
+ ]
+ logs = self.logs.getvalue()
+ for warning in expected_warnings:
+ self.assertIn(warning, logs)
+ self.assertIn('Failed to shellify', logs)
+ self.assertEqual(
+ 'Unable to shellify type int which is not a list or string',
+ str(context_manager.exception))
+
+ def test_handler_creates_and_runs_bootcmd_script_with_instance_id(self):
+ """Valid schema runs a bootcmd script with INSTANCE_ID in the env."""
+ cc = self._get_cloud('ubuntu')
+ bootcmd_file = self.tmp_path('bootcmd.sh', self.new_root)
+ out_file = self.tmp_path('bootcmd.out', self.new_root)
+ valid_config = {'bootcmd': [
+ 'echo $INSTANCE_ID > {0}'.format(out_file)]}
+
+ class FakeExtendedTempFile(object):
+ def __init__(_self, suffix):
+ _self.suffix = suffix
+ path = self.tmp_path('bootcmd' + _self.suffix, self.new_root)
+ _self.handle = open(path, 'w+b')
+
+ def __enter__(_self):
+ return _self.handle
+
+ def __exit__(_self, exc_type, exc_value, traceback):
+ _self.handle.close()
+
+ mock_path = 'cloudinit.config.cc_bootcmd.util.ExtendedTemporaryFile'
+ with mock.patch(mock_path, FakeExtendedTempFile):
+ cc_bootcmd.handle('cc_bootcmd', valid_config, cc, LOG, [])
+ self.assertEqual(
+ '#!/bin/sh\necho $INSTANCE_ID > {0}\n'.format(out_file),
+ util.load_file(bootcmd_file))
+ self.assertEqual('iid-datasource-none\n', util.load_file(out_file))
+ file_stat = os.stat(bootcmd_file)
+ self.assertEqual(0o664, stat.S_IMODE(file_stat.st_mode))
+
+ def test_handler_runs_bootcmd_script_with_error(self):
+ """When a valid script generates an error, that error is raised."""
+ cc = self._get_cloud('ubuntu')
+ bootcmd_file = self.tmp_path('bootcmd.sh', self.new_root)
+ valid_config = {'bootcmd': ['exit 1']} # Script with error
+
+ class FakeExtendedTempFile(object):
+ def __init__(_self, suffix):
+ _self.suffix = suffix
+ path = self.tmp_path('bootcmd' + _self.suffix, self.new_root)
+ _self.handle = open(path, 'w+b')
+
+ def __enter__(_self):
+ return _self.handle
+
+ def __exit__(_self, exc_type, exc_value, traceback):
+ _self.handle.close()
+
+ mock_path = 'cloudinit.config.cc_bootcmd.util.ExtendedTemporaryFile'
+ with mock.patch(mock_path, FakeExtendedTempFile):
+ with self.assertRaises(util.ProcessExecutionError) as ctxt_manager:
+ cc_bootcmd.handle('does-not-matter', valid_config, cc, LOG, [])
+ self.assertIn(
+ 'Unexpected error while running command.\n'
+ "Command: ['/bin/sh', '{0}']".format(bootcmd_file),
+ str(ctxt_manager.exception))
+ self.assertIn(
+ 'Failed to run bootcmd module does-not-matter',
+ self.logs.getvalue())
+
+
+# vi: ts=4 expandtab
diff --git a/tests/unittests/test_handler/test_handler_resizefs.py b/tests/unittests/test_handler/test_handler_resizefs.py
index 52591b8..7ce7a7d 100644
--- a/tests/unittests/test_handler/test_handler_resizefs.py
+++ b/tests/unittests/test_handler/test_handler_resizefs.py
@@ -1,17 +1,29 @@
# This file is part of cloud-init. See LICENSE file for license information.
-from cloudinit.config import cc_resizefs
+from cloudinit.config.cc_resizefs import (
+ can_skip_resize, handle, is_device_path_writable_block,
+ rootdev_from_cmdline)
+import logging
import textwrap
-import unittest
+
+from ..helpers import CiTestCase, mock, skipIf, util, wrap_and_call
+
+
+LOG = logging.getLogger(__name__)
+
try:
- from unittest import mock
+ import jsonschema
+ assert jsonschema # avoid pyflakes error F401: import unused
+ _missing_jsonschema_dep = False
except ImportError:
- import mock
+ _missing_jsonschema_dep = True
+
+class TestResizefs(CiTestCase):
+ with_logs = True
-class TestResizefs(unittest.TestCase):
def setUp(self):
super(TestResizefs, self).setUp()
self.name = "resizefs"
@@ -34,7 +46,7 @@ class TestResizefs(unittest.TestCase):
58720296 3145728 3 freebsd-swap (1.5G)
61866024 1048496 - free - (512M)
""")
- res = cc_resizefs.can_skip_resize(fs_type, resize_what, devpth)
+ res = can_skip_resize(fs_type, resize_what, devpth)
self.assertTrue(res)
@mock.patch('cloudinit.config.cc_resizefs._get_dumpfs_output')
@@ -52,8 +64,241 @@ class TestResizefs(unittest.TestCase):
=> 34 297086 da0 GPT (145M)
34 297086 1 freebsd-ufs (145M)
""")
- res = cc_resizefs.can_skip_resize(fs_type, resize_what, devpth)
+ res = can_skip_resize(fs_type, resize_what, devpth)
self.assertTrue(res)
+ def test_handle_noops_on_disabled(self):
+ """The handle function logs when the configuration disables resize."""
+ cfg = {'resize_rootfs': False}
+ handle('cc_resizefs', cfg, _cloud=None, log=LOG, args=[])
+ self.assertIn(
+ 'DEBUG: Skipping module named cc_resizefs, resizing disabled\n',
+ self.logs.getvalue())
+
+ @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
+ def test_handle_schema_validation_logs_invalid_resize_rootfs_value(self):
+ """The handle reports json schema violations as a warning.
+
+ Invalid values for resize_rootfs result in disabling the module.
+ """
+ cfg = {'resize_rootfs': 'junk'}
+ handle('cc_resizefs', cfg, _cloud=None, log=LOG, args=[])
+ logs = self.logs.getvalue()
+ self.assertIn(
+ "WARNING: Invalid config:\nresize_rootfs: 'junk' is not one of"
+ " [True, False, 'noblock']",
+ logs)
+ self.assertIn(
+ 'DEBUG: Skipping module named cc_resizefs, resizing disabled\n',
+ logs)
+
+ @mock.patch('cloudinit.config.cc_resizefs.util.get_mount_info')
+ def test_handle_warns_on_unknown_mount_info(self, m_get_mount_info):
+ """handle warns when get_mount_info sees unknown filesystem for /."""
+ m_get_mount_info.return_value = None
+ cfg = {'resize_rootfs': True}
+ handle('cc_resizefs', cfg, _cloud=None, log=LOG, args=[])
+ logs = self.logs.getvalue()
+ self.assertNotIn("WARNING: Invalid config:\nresize_rootfs:", logs)
+ self.assertIn(
+ 'WARNING: Could not determine filesystem type of /\n',
+ logs)
+ self.assertEqual(
+ [mock.call('/', LOG)],
+ m_get_mount_info.call_args_list)
+
+ def test_handle_warns_on_undiscoverable_root_path_in_commandline(self):
+ """handle noops when the root path is not found on the commandline."""
+ cfg = {'resize_rootfs': True}
+ exists_mock_path = 'cloudinit.config.cc_resizefs.os.path.exists'
+
+ def fake_mount_info(path, log):
+ self.assertEqual('/', path)
+ self.assertEqual(LOG, log)
+ return ('/dev/root', 'ext4', '/')
+
+ with mock.patch(exists_mock_path) as m_exists:
+ m_exists.return_value = False
+ wrap_and_call(
+ 'cloudinit.config.cc_resizefs.util',
+ {'is_container': {'return_value': False},
+ 'get_mount_info': {'side_effect': fake_mount_info},
+ 'get_cmdline': {'return_value': 'BOOT_IMAGE=/vmlinuz.efi'}},
+ handle, 'cc_resizefs', cfg, _cloud=None, log=LOG,
+ args=[])
+ logs = self.logs.getvalue()
+ self.assertIn("WARNING: Unable to find device '/dev/root'", logs)
+
+
+class TestRootDevFromCmdline(CiTestCase):
+
+ def test_rootdev_from_cmdline_with_no_root(self):
+ """Return None from rootdev_from_cmdline when root is not present."""
+ invalid_cases = [
+ 'BOOT_IMAGE=/adsf asdfa werasef root adf', 'BOOT_IMAGE=/adsf', '']
+ for case in invalid_cases:
+ self.assertIsNone(rootdev_from_cmdline(case))
+
+ def test_rootdev_from_cmdline_with_root_startswith_dev(self):
+ """Return the cmdline root when the path starts with /dev."""
+ self.assertEqual(
+ '/dev/this',
+ rootdev_from_cmdline('asdf root=/dev/this'))
+
+ def test_rootdev_from_cmdline_with_root_without_dev_prefix(self):
+ """Add /dev prefix to cmdline root when the path lacks the prefix."""
+ self.assertEqual(
+ '/dev/this',
+ rootdev_from_cmdline('asdf root=this'))
+
+ def test_rootdev_from_cmdline_with_root_with_label(self):
+ """When cmdline root contains a LABEL, our root is disk/by-label."""
+ self.assertEqual(
+ '/dev/disk/by-label/unique',
+ rootdev_from_cmdline('asdf root=LABEL=unique'))
+
+ def test_rootdev_from_cmdline_with_root_with_uuid(self):
+ """When cmdline root contains a UUID, our root is disk/by-uuid."""
+ self.assertEqual(
+ '/dev/disk/by-uuid/adsfdsaf-adsf',
+ rootdev_from_cmdline('asdf root=UUID=adsfdsaf-adsf'))
+
+
+class TestIsDevicePathWritableBlock(CiTestCase):
+
+ with_logs = True
+
+ def test_is_device_path_writable_block_warns_missing_cmdline_root(self):
+ """When root does not exist isn't in the cmdline, log warning."""
+ info = 'does not matter'
+
+ def fake_mount_info(path, log):
+ self.assertEqual('/', path)
+ self.assertEqual(LOG, log)
+ return ('/dev/root', 'ext4', '/')
+
+ exists_mock_path = 'cloudinit.config.cc_resizefs.os.path.exists'
+ with mock.patch(exists_mock_path) as m_exists:
+ m_exists.return_value = False
+ is_valid = wrap_and_call(
+ 'cloudinit.config.cc_resizefs.util',
+ {'is_container': {'return_value': False},
+ 'get_mount_info': {'side_effect': fake_mount_info},
+ 'get_cmdline': {'return_value': 'BOOT_IMAGE=/vmlinuz.efi'}},
+ is_device_path_writable_block, '/dev/root', info, LOG)
+ self.assertFalse(is_valid)
+ logs = self.logs.getvalue()
+ self.assertIn("WARNING: Unable to find device '/dev/root'", logs)
+
+ def test_is_device_path_writable_block_does_not_exist(self):
+ """When devpath does not exist, a warning is logged."""
+ info = 'dev=/I/dont/exist mnt_point=/ path=/dev/none'
+ is_valid = wrap_and_call(
+ 'cloudinit.config.cc_resizefs.util',
+ {'is_container': {'return_value': False}},
+ is_device_path_writable_block, '/I/dont/exist', info, LOG)
+ self.assertFalse(is_valid)
+ self.assertIn(
+ "WARNING: Device '/I/dont/exist' did not exist."
+ ' cannot resize: %s' % info,
+ self.logs.getvalue())
+
+ def test_is_device_path_writable_block_does_not_exist_in_container(self):
+ """When devpath does not exist in a container, log a debug message."""
+ info = 'dev=/I/dont/exist mnt_point=/ path=/dev/none'
+ is_valid = wrap_and_call(
+ 'cloudinit.config.cc_resizefs.util',
+ {'is_container': {'return_value': True}},
+ is_device_path_writable_block, '/I/dont/exist', info, LOG)
+ self.assertFalse(is_valid)
+ self.assertIn(
+ "DEBUG: Device '/I/dont/exist' did not exist in container."
+ ' cannot resize: %s' % info,
+ self.logs.getvalue())
+
+ def test_is_device_path_writable_block_raises_oserror(self):
+ """When unexpected OSError is raises by os.stat it is reraised."""
+ info = 'dev=/I/dont/exist mnt_point=/ path=/dev/none'
+ with self.assertRaises(OSError) as context_manager:
+ wrap_and_call(
+ 'cloudinit.config.cc_resizefs',
+ {'util.is_container': {'return_value': True},
+ 'os.stat': {'side_effect': OSError('Something unexpected')}},
+ is_device_path_writable_block, '/I/dont/exist', info, LOG)
+ self.assertEqual(
+ 'Something unexpected', str(context_manager.exception))
+
+ def test_is_device_path_writable_block_readonly(self):
+ """When root device is readonly, emit a warning and return False."""
+ fake_devpath = self.tmp_path('dev/readonly')
+ util.write_file(fake_devpath, '', mode=0o400) # read-only
+ info = 'dev=/dev/root mnt_point=/ path={0}'.format(fake_devpath)
+
+ exists_mock_path = 'cloudinit.config.cc_resizefs.os.path.exists'
+ with mock.patch(exists_mock_path) as m_exists:
+ m_exists.return_value = False
+ is_valid = wrap_and_call(
+ 'cloudinit.config.cc_resizefs.util',
+ {'is_container': {'return_value': False},
+ 'rootdev_from_cmdline': {'return_value': fake_devpath}},
+ is_device_path_writable_block, '/dev/root', info, LOG)
+ self.assertFalse(is_valid)
+ logs = self.logs.getvalue()
+ self.assertIn(
+ "Converted /dev/root to '{0}' per kernel cmdline".format(
+ fake_devpath),
+ logs)
+ self.assertIn(
+ "WARNING: '{0}' not writable. cannot resize".format(fake_devpath),
+ logs)
+
+ def test_is_device_path_writable_block_readonly_in_container(self):
+ """When root device is readonly, emit debug log and return False."""
+ fake_devpath = self.tmp_path('dev/readonly')
+ util.write_file(fake_devpath, '', mode=0o400) # read-only
+ info = 'dev=/dev/root mnt_point=/ path={0}'.format(fake_devpath)
+
+ is_valid = wrap_and_call(
+ 'cloudinit.config.cc_resizefs.util',
+ {'is_container': {'return_value': True}},
+ is_device_path_writable_block, fake_devpath, info, LOG)
+ self.assertFalse(is_valid)
+ self.assertIn(
+ "DEBUG: '{0}' not writable in container. cannot resize".format(
+ fake_devpath),
+ self.logs.getvalue())
+
+ def test_is_device_path_writable_block_non_block(self):
+ """When device is not a block device, emit warning return False."""
+ fake_devpath = self.tmp_path('dev/readwrite')
+ util.write_file(fake_devpath, '', mode=0o600) # read-write
+ info = 'dev=/dev/root mnt_point=/ path={0}'.format(fake_devpath)
+
+ is_valid = wrap_and_call(
+ 'cloudinit.config.cc_resizefs.util',
+ {'is_container': {'return_value': False}},
+ is_device_path_writable_block, fake_devpath, info, LOG)
+ self.assertFalse(is_valid)
+ self.assertIn(
+ "WARNING: device '{0}' not a block device. cannot resize".format(
+ fake_devpath),
+ self.logs.getvalue())
+
+ def test_is_device_path_writable_block_non_block_on_container(self):
+ """When device is non-block device in container, emit debug log."""
+ fake_devpath = self.tmp_path('dev/readwrite')
+ util.write_file(fake_devpath, '', mode=0o600) # read-write
+ info = 'dev=/dev/root mnt_point=/ path={0}'.format(fake_devpath)
+
+ is_valid = wrap_and_call(
+ 'cloudinit.config.cc_resizefs.util',
+ {'is_container': {'return_value': True}},
+ is_device_path_writable_block, fake_devpath, info, LOG)
+ self.assertFalse(is_valid)
+ self.assertIn(
+ "DEBUG: device '{0}' not a block device in container."
+ ' cannot resize'.format(fake_devpath),
+ self.logs.getvalue())
+
# vi: ts=4 expandtab
diff --git a/tests/unittests/test_handler/test_schema.py b/tests/unittests/test_handler/test_schema.py
index 6137e3c..8caf0d4 100644
--- a/tests/unittests/test_handler/test_schema.py
+++ b/tests/unittests/test_handler/test_schema.py
@@ -27,7 +27,7 @@ class GetSchemaTest(CiTestCase):
"""Every cloudconfig module with schema is listed in allOf keyword."""
schema = get_schema()
self.assertItemsEqual(
- ['cc_ntp', 'cc_runcmd'],
+ ['cc_bootcmd', 'cc_ntp', 'cc_resizefs', 'cc_runcmd'],
[subschema['id'] for subschema in schema['allOf']])
self.assertEqual('cloud-config-schema', schema['id'])
self.assertEqual(
Follow ups
-
[Merge] ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master
From: Scott Moser, 2017-09-13
-
Re: [Merge] ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master
From: Server Team CI bot, 2017-09-08
-
[Merge] ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master
From: Chad Smith, 2017-09-08
-
[Merge] ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master
From: Chad Smith, 2017-09-08
-
Re: [Merge] ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master
From: Chad Smith, 2017-09-08
-
Re: [Merge] ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master
From: Server Team CI bot, 2017-09-06
-
Re: [Merge] ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master
From: Server Team CI bot, 2017-09-06
-
Re: [Merge] ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master
From: Server Team CI bot, 2017-09-06
-
Re: [Merge] ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master
From: Server Team CI bot, 2017-09-05
-
Re: [Merge] ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master
From: Server Team CI bot, 2017-09-05
-
[Merge] ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master
From: Chad Smith, 2017-09-05
-
Re: [Merge] ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master
From: Server Team CI bot, 2017-09-05
-
[Merge] ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master
From: Chad Smith, 2017-09-05
-
[Merge] ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master
From: Chad Smith, 2017-09-05
-
[Merge] ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master
From: Chad Smith, 2017-09-05
-
[Merge] ~chad.smith/cloud-init:feature/schema-resizefs-bootcmd into cloud-init:master
From: Chad Smith, 2017-09-05