← Back to team overview

cloud-init-dev team mailing list archive

[Merge] ~raharper/cloud-init:feature/disk_setup_async into cloud-init:master

 

Ryan Harper has proposed merging ~raharper/cloud-init:feature/disk_setup_async into cloud-init:master.

Commit message:
Implement async mount via fstab entry
    
On systemd enabled systems, cloud-init can make use of systemd-makefs
service via use of 'x-systemd.makefs' fs_mntop entry.  This results in
the creation of a mount unit and mkfs service including dependencies to
ensure that the target is formatted and mounted by systemd prior to the
start of cloud-config.service.

This feature is enabled by specifying 'x-systemd.makefs' in a mount
cloud-config entry.  If enabled, cc_disk_setup will skip filesystem
creation deferring to systemd-makefs, if the specified device in the
mount entry matches an entry in fs_setup.

Lastly, cc_mounts now supports specifying 'cidefaults' as a fs_mntop
value which will replace the string 'cidefaults' with the default system
mount options; either cloud-init defaults or via cloud-config when users
specify 'default_mount_options'.

Also included in this branch:
 - Add eventReportStack context managers for timing disk_setup and
   fs_setup separately
 - fixes to cloud-init analyze parsing for sub-stage events

Requested reviews:
  cloud-init commiters (cloud-init-dev)

For more details, see:
https://code.launchpad.net/~raharper/cloud-init/+git/cloud-init/+merge/369362
-- 
Your team cloud-init commiters is requested to review the proposed merge of ~raharper/cloud-init:feature/disk_setup_async into cloud-init:master.
diff --git a/cloudinit/analyze/show.py b/cloudinit/analyze/show.py
index 3e778b8..b15cd2c 100644
--- a/cloudinit/analyze/show.py
+++ b/cloudinit/analyze/show.py
@@ -94,6 +94,10 @@ def event_parent(event):
     return None
 
 
+def event_is_stage(event):
+    return '/' not in event_name(event)
+
+
 def event_timestamp(event):
     return float(event.get('timestamp'))
 
@@ -146,7 +150,9 @@ def generate_records(events, blame_sort=False,
             next_evt = None
 
         if event_type(event) == 'start':
-            if event.get('name') in stages_seen:
+            stage_name = event_parent(event)
+            if stage_name == event_name(event) and stage_name in stages_seen:
+                # new boot record
                 records.append(total_time_record(total_time))
                 boot_records.append(records)
                 records = []
@@ -166,19 +172,26 @@ def generate_records(events, blame_sort=False,
                                                               event,
                                                               next_evt)))
             else:
-                # This is a parent event
-                records.append("Starting stage: %s" % event.get('name'))
-                unprocessed.append(event)
-                stages_seen.append(event.get('name'))
-                continue
+                if event_is_stage(event):
+                    records.append("Starting stage: %s" % event.get('name'))
+                    unprocessed.append(event)
+                    stages_seen.append(event.get('name'))
+                else:
+                    # Start of a substage event
+                    records.append(format_record(print_format,
+                                                 event_record(start_time,
+                                                              event,
+                                                              next_evt)))
+
         else:
             prev_evt = unprocessed.pop()
             if event_name(event) == event_name(prev_evt):
-                record = event_record(start_time, prev_evt, event)
-                records.append(format_record("Finished stage: "
-                                             "(%n) %d seconds ",
-                                             record) + "\n")
-                total_time += record.get('delta')
+                if event_is_stage(event):
+                    record = event_record(start_time, prev_evt, event)
+                    records.append(format_record("Finished stage: "
+                                                 "(%n) %d seconds ",
+                                                 record) + "\n")
+                    total_time += record.get('delta')
             else:
                 # not a match, put it back
                 unprocessed.append(prev_evt)
diff --git a/cloudinit/config/cc_disk_setup.py b/cloudinit/config/cc_disk_setup.py
index 29e192e..00d9930 100644
--- a/cloudinit/config/cc_disk_setup.py
+++ b/cloudinit/config/cc_disk_setup.py
@@ -71,6 +71,11 @@ specified using ``filesystem``.
 .. note::
     ``replace_fs`` is ignored unless ``partition`` is ``auto`` or ``any``.
 
+.. note::
+    Filesystem creation can be deferred to systemd-makefs if a specified
+    device is present in ``mounts`` cloud-config and includes the
+    ``x-systemd-makefs`` in the fs_mntopts field.
+
 **Internal name:** ``cc_disk_setup``
 
 **Module frequency:** per instance
@@ -99,6 +104,7 @@ specified using ``filesystem``.
 
 from cloudinit.settings import PER_INSTANCE
 from cloudinit import util
+from cloudinit.reporting import events
 import logging
 import os
 import shlex
@@ -126,9 +132,23 @@ def handle(_name, cfg, cloud, log, _args):
     """
     disk_setup = cfg.get("disk_setup")
     if isinstance(disk_setup, dict):
-        update_disk_setup_devices(disk_setup, cloud.device_name_to_device)
-        log.debug("Partitioning disks: %s", str(disk_setup))
-        for disk, definition in disk_setup.items():
+        _handle_disk_setup(disk_setup, cloud, log)
+
+    fs_setup = cfg.get("fs_setup")
+    mounts = cfg.get("mounts", [])
+    if isinstance(fs_setup, list):
+        _handle_fs_setup(fs_setup, mounts, cloud, log)
+
+
+def _handle_disk_setup(disk_setup, cloud, log):
+    update_disk_setup_devices(disk_setup, cloud.device_name_to_device)
+    log.debug("Partitioning disks: %s", str(disk_setup))
+    for disk, definition in disk_setup.items():
+        disk_str = disk.lstrip('/').replace('/', '-')
+        run_name = 'config-disk_setup/partition-' + disk_str
+        with events.ReportEventStack(name=run_name,
+                                     description='Partitioning ' + disk_str,
+                                     parent=cloud.reporter):
             if not isinstance(definition, dict):
                 log.warning("Invalid disk definition for %s" % disk)
                 continue
@@ -141,18 +161,32 @@ def handle(_name, cfg, cloud, log, _args):
             except Exception as e:
                 util.logexc(LOG, "Failed partitioning operation\n%s" % e)
 
-    fs_setup = cfg.get("fs_setup")
-    if isinstance(fs_setup, list):
-        log.debug("setting up filesystems: %s", str(fs_setup))
-        update_fs_setup_devices(fs_setup, cloud.device_name_to_device)
-        for definition in fs_setup:
+
+def _handle_fs_setup(fs_setup, mounts, cloud, log):
+    log.debug("setting up filesystems: %s", str(fs_setup))
+    update_fs_setup_devices(fs_setup, cloud.device_name_to_device)
+
+    # list of devices which use systemd-makefs to format
+    skip_mkfs = [mnt[0] for mnt in mounts
+                 if len(mnt) > 3 and 'x-systemd.makefs' in mnt[3]]
+
+    for idx, definition in enumerate(fs_setup):
+        run_name = 'config-disk_setup/mkfs-entry-%s' % idx
+        desc = 'Create Filesystem for entry %s' % idx
+        with events.ReportEventStack(name=run_name, description=desc,
+                                     parent=cloud.reporter):
             if not isinstance(definition, dict):
                 log.warning("Invalid file system definition: %s" % definition)
                 continue
 
             try:
-                log.debug("Creating new filesystem.")
                 device = definition.get('device')
+                og_dev = definition.get('_origname')
+                if set(skip_mkfs).intersection(set([device, og_dev])):
+                    log.debug('Deferring mkfs of %s to systemd-make-fs',
+                              device)
+                    continue
+                log.debug("Creating new filesystem.")
                 util.log_time(logfunc=LOG.debug,
                               msg="Creating fs for %s" % device,
                               func=mkfs, args=(definition,))
diff --git a/cloudinit/config/cc_mounts.py b/cloudinit/config/cc_mounts.py
index 123ffb8..c227253 100644
--- a/cloudinit/config/cc_mounts.py
+++ b/cloudinit/config/cc_mounts.py
@@ -38,6 +38,10 @@ On a systemd booted system that default is the mostly equivalent::
 Note that `nobootwait` is an upstart specific boot option that somewhat
 equates to the more standard `nofail`.
 
+When specifying mount opts, using ``cidefaults`` will expand into the
+the mount_default_fields values for fs_mntops.  This is useful for
+appending non-standard values without having to track the default values.
+
 Swap files can be configured by setting the path to the swap file to create
 with ``filename``, the size of the swap file with ``size`` maximum size of
 the swap file if using an ``size: auto`` with ``maxsize``. By default no
@@ -55,6 +59,7 @@ swap file is created.
         - [ /dev/ephemeral0, /mnt, auto, "defaults,noexec" ]
         - [ sdc, /opt/data ]
         - [ xvdh, /opt/data, "auto", "defaults,nofail", "0", "0" ]
+        - [ ephemeral0.1, ext4, cidefaults,x-systemd.makefs, none, none ]
     mount_default_fields: [None, None, "auto", "defaults,nofail", "0", "2"]
     swap:
         filename: <file>
@@ -77,6 +82,9 @@ DEVICE_NAME_RE = re.compile(DEVICE_NAME_FILTER)
 WS = re.compile("[%s]+" % (whitespace))
 FSTAB_PATH = "/etc/fstab"
 MNT_COMMENT = "comment=cloudconfig"
+MNT_BEFORE_CC = "x-systemd.before=cloud-config.service"
+DEF_MNT_OPTS = "defaults,nobootwait"
+SYSTEMD_DEF_MNT_OPTS = "defaults,nofail,x-systemd.requires=cloud-init.service"
 
 LOG = logging.getLogger(__name__)
 
@@ -310,16 +318,16 @@ def handle_swapcfg(swapcfg):
 
 def handle(_name, cfg, cloud, log, _args):
     # fs_spec, fs_file, fs_vfstype, fs_mntops, fs-freq, fs_passno
-    def_mnt_opts = "defaults,nobootwait"
+    def_mnt_opts = DEF_MNT_OPTS
     uses_systemd = cloud.distro.uses_systemd()
     if uses_systemd:
-        def_mnt_opts = "defaults,nofail,x-systemd.requires=cloud-init.service"
+        def_mnt_opts = SYSTEMD_DEF_MNT_OPTS
 
     defvals = [None, None, "auto", def_mnt_opts, "0", "2"]
     defvals = cfg.get("mount_default_fields", defvals)
 
     # these are our default set of mounts
-    defmnts = [["ephemeral0", "/mnt", "auto", defvals[3], "0", "2"],
+    defmnts = [["ephemeral0", "/mnt", defvals[2], defvals[3], "0", "2"],
                ["swap", "none", "swap", "sw", "0", "0"]]
 
     cfgmnt = []
@@ -390,6 +398,12 @@ def handle(_name, cfg, cloud, log, _args):
                 if cfgmnt[j][0] == cfgmnt[i][0]:
                     cfgmnt[j][1] = None
 
+        # expand fs_mntopts 'cidefaults' to defvals fs_mntopts
+        if 'cidefaults' in cfgmnt[i][3]:
+            log.debug("expanding fs_mntops keyword 'cidefaults' => %s",
+                      defvals[3])
+            cfgmnt[i][3] = cfgmnt[i][3].replace('cidefaults', defvals[3])
+
     # for each of the "default" mounts, add them only if no other
     # entry has the same device name
     for defmnt in defmnts:
@@ -441,7 +455,15 @@ def handle(_name, cfg, cloud, log, _args):
     needswap = False
     need_mount_all = False
     dirs = []
+    systemd_mounts = []
     for line in actlist:
+        # if user specifies a x-systemd.makefs, extract the mount point
+        # which is the name of the systemd mount unit we need to enqueue
+        if 'x-systemd.makefs' in line[3]:
+            # transform mount point entry to systemd mount unit name
+            systemd_mounts.append(line[1].lstrip('/').replace('/', '-'))
+            # ensure this unit is mounted before cloud-config.target
+            line[3] = "%s,%s" % (line[3], MNT_BEFORE_CC)
         # write 'comment' in the fs_mntops, entry,  claiming this
         line[3] = "%s,%s" % (line[3], MNT_COMMENT)
         if line[2] == "swap":
@@ -474,19 +496,22 @@ def handle(_name, cfg, cloud, log, _args):
     util.write_file(FSTAB_PATH, contents)
 
     activate_cmds = []
-    if needswap:
-        activate_cmds.append(["swapon", "-a"])
-
     if len(sops) == 0:
         log.debug("No changes to /etc/fstab made.")
     else:
         log.debug("Changes to fstab: %s", sops)
         need_mount_all = True
+        if needswap:
+            activate_cmds.append(["swapon", "-a"])
 
     if need_mount_all:
-        activate_cmds.append(["mount", "-a"])
         if uses_systemd:
             activate_cmds.append(["systemctl", "daemon-reload"])
+            for sd_mount in systemd_mounts:
+                activate_cmds.append(['systemctl', 'start', '--no-block',
+                                      '%s.mount' % sd_mount])
+        if len(systemd_mounts) == 0:
+            activate_cmds.append(["mount", "-a"])
 
     fmt = "Activating swap and mounts with: %s"
     for cmd in activate_cmds:
diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py
index d2fad9b..21dc0f0 100755
--- a/cloudinit/sources/DataSourceAzure.py
+++ b/cloudinit/sources/DataSourceAzure.py
@@ -232,6 +232,9 @@ BUILTIN_CLOUD_CONFIG = {
     },
     'fs_setup': [{'filesystem': DEFAULT_FS,
                   'device': 'ephemeral0.1'}],
+    'mounts': [
+        ['ephemeral0.1', '/mnt', DEFAULT_FS, 'cidefaults,x-systemd.makefs']
+    ],
 }
 
 DS_CFG_PATH = ['datasource', DS_NAME]
diff --git a/cloudinit/tests/helpers.py b/cloudinit/tests/helpers.py
index f41180f..f088bab 100644
--- a/cloudinit/tests/helpers.py
+++ b/cloudinit/tests/helpers.py
@@ -6,7 +6,9 @@ import functools
 import httpretty
 import logging
 import os
+import random
 import shutil
+import string
 import sys
 import tempfile
 import time
@@ -122,6 +124,12 @@ class TestCase(unittest2.TestCase):
             parser.readfp(contents)
         return parser
 
+    @classmethod
+    def random_string(cls, length=8):
+        """ return a random lowercase string with default length of 8"""
+        return ''.join(
+            random.choice(string.ascii_lowercase) for _ in range(length))
+
 
 class CiTestCase(TestCase):
     """This is the preferred test case base class unless user
diff --git a/tests/unittests/test_handler/test_handler_mounts.py b/tests/unittests/test_handler/test_handler_mounts.py
index 0fb160b..4c7470d 100644
--- a/tests/unittests/test_handler/test_handler_mounts.py
+++ b/tests/unittests/test_handler/test_handler_mounts.py
@@ -255,7 +255,52 @@ class TestFstabHandling(test_helpers.FilesystemMockingTestCase):
             self.assertEqual(fstab_expected_content, fstab_new_content)
         cc_mounts.handle(None, cc, self.mock_cloud, self.mock_log, [])
         self.m_util_subp.assert_has_calls([
+            mock.call(['swapon', '-a']),
+            mock.call(['systemctl', 'daemon-reload']),
             mock.call(['mount', '-a']),
-            mock.call(['systemctl', 'daemon-reload'])])
+        ])
+
+    def test_ephemeral_systemd_makefs(self):
+        self.maxDiff = None
+        dev = '/dev/disk/cloud/azure_resource-part1'
+        def_opts = "defaults,nofail,x-systemd.requires=cloud-init.service"
+        make_opts = ',x-systemd.makefs'
+        before_opts = ',x-systemd.before=cloud-config.service'
+        comment_opts = ',comment=cloudconfig'
+        opts = def_opts + make_opts + before_opts + comment_opts
+
+        fstab_original_content = (
+            'LABEL=cloudimg-rootfs / ext4 defaults 0 0\n'
+            'LABEL=UEFI /boot/efi vfat defaults 0 0\n'
+        )
+        fstab_expected_content = (
+            fstab_original_content +
+            '%s	/mnt	ext4	%s	0	2\n' % (dev, opts)
+        )
+
+        with open(cc_mounts.FSTAB_PATH, 'w') as fd:
+            fd.write(fstab_original_content)
+
+        # enable systemd
+        self.mock_cloud.distro.uses_systemd.return_value = True
+
+        # return devname
+        name_mock = mock.Mock()
+        name_mock.return_value = dev
+        self.mock_cloud.device_name_to_device = name_mock
+
+        cc_opts = def_opts + make_opts
+        cc = {'mount_default_fields': [None, None, 'ext4', cc_opts, "0", "2"]}
+        cc_mounts.handle(None, cc, self.mock_cloud, self.mock_log, [])
+
+        self.m_util_subp.assert_has_calls([
+            mock.call(['systemctl', 'daemon-reload']),
+            mock.call(['systemctl', 'start', '--no-block', 'mnt.mount']),
+        ])
+
+        with open(cc_mounts.FSTAB_PATH, 'r') as fd:
+            fstab_new_content = fd.read()
+            print('\n%s' % fstab_new_content)
+            self.assertEqual(fstab_expected_content, fstab_new_content)
 
 # vi: ts=4 expandtab

Follow ups