← Back to team overview

cloud-init-dev team mailing list archive

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

 

Scott Moser has proposed merging ~raharper/cloud-init:feature/cloud-init-hotplug-handler into cloud-init:master.

Commit message:
wip fixme

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

For more details, see:
https://code.launchpad.net/~raharper/cloud-init/+git/cloud-init/+merge/356152
-- 
Your team cloud-init commiters is requested to review the proposed merge of ~raharper/cloud-init:feature/cloud-init-hotplug-handler into cloud-init:master.
diff --git a/bash_completion/cloud-init b/bash_completion/cloud-init
index 8c25032..0eab40c 100644
--- a/bash_completion/cloud-init
+++ b/bash_completion/cloud-init
@@ -28,7 +28,7 @@ _cloudinit_complete()
                     COMPREPLY=($(compgen -W "--help --tarfile --include-userdata" -- $cur_word))
                     ;;
                 devel)
-                    COMPREPLY=($(compgen -W "--help schema net-convert" -- $cur_word))
+                    COMPREPLY=($(compgen -W "--help hotplug-hook net-convert schema" -- $cur_word))
                     ;;
                 dhclient-hook|features)
                     COMPREPLY=($(compgen -W "--help" -- $cur_word))
@@ -61,6 +61,9 @@ _cloudinit_complete()
                 --frequency)
                     COMPREPLY=($(compgen -W "--help instance always once" -- $cur_word))
                     ;;
+                hotplug-hook)
+                    COMPREPLY=($(compgen -W "--help" -- $cur_word))
+                    ;;
                 net-convert)
                     COMPREPLY=($(compgen -W "--help --network-data --kind --directory --output-kind" -- $cur_word))
                     ;;
diff --git a/cloudinit/cmd/devel/hotplug_hook.py b/cloudinit/cmd/devel/hotplug_hook.py
new file mode 100644
index 0000000..c24b1ff
--- /dev/null
+++ b/cloudinit/cmd/devel/hotplug_hook.py
@@ -0,0 +1,195 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+"""Handle reconfiguration on hotplug events"""
+import argparse
+import os
+import sys
+
+from cloudinit.event import EventType
+from cloudinit import log
+from cloudinit import reporting
+from cloudinit.reporting import events
+from cloudinit import sources
+from cloudinit.stages import Init
+from cloudinit.net import read_sys_net_safe
+from cloudinit.net.network_state import parse_net_config_data
+
+
+LOG = log.getLogger(__name__)
+NAME = 'hotplug-hook'
+
+
+def get_parser(parser=None):
+    """Build or extend and arg parser for hotplug-hook utility.
+
+    @param parser: Optional existing ArgumentParser instance representing the
+        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=NAME, description=__doc__)
+
+    parser.add_argument("-d", "--devpath",
+                        metavar="PATH",
+                        help="sysfs path to hotplugged device")
+    parser.add_argument("--hotplug-debug", action='store_true',
+                        help='enable debug logging to stderr.')
+    parser.add_argument("-s", "--subsystem",
+                        choices=['net', 'block'])
+    parser.add_argument("-u", "--udevaction",
+                        choices=['add', 'change', 'remove'])
+
+    return parser
+
+
+def log_console(msg):
+    """Log messages to stderr console and configured logging."""
+    sys.stderr.write(msg + '\n')
+    sys.stderr.flush()
+    LOG.debug(msg)
+
+
+def devpath_to_macaddr(devpath):
+    macaddr = read_sys_net_safe(os.path.basename(devpath), 'address')
+    log_console('Checking if %s in netconfig' % macaddr)
+    return macaddr
+
+
+def in_netconfig(unique_id, netconfig):
+    netstate = parse_net_config_data(netconfig)
+    found = [iface
+             for iface in netstate.iter_interfaces()
+             if iface.get('mac_address') == unique_id]
+    log_console('Ifaces with ID=%s : %s' % (unique_id, found))
+    return len(found) > 0
+
+
+class UeventHandler(object):
+    def __init__(self, ds, devpath, success_fn):
+        self.datasource = ds
+        self.devpath = devpath
+        self.success_fn = success_fn
+
+    def apply(self):
+        raise NotImplemented()
+
+    @property
+    def config(self):
+        raise NotImplemented()
+
+    def detect(self, action):
+        raise NotImplemented()
+
+    def success(self):
+        return self.success_fn()
+
+    def update(self):
+        self.datasource.update_metadata([EventType.UDEV])
+
+
+class NetHandler(UeventHandler):
+    def __init__(self, ds, devpath, success_fn):
+        super(NetHandler, self).__init__(ds, devpath, success_fn)
+        self.id = devpath_to_macaddr(self.devpath)
+
+    def apply(self):
+        return self.datasource.distro.apply_network_config(self.config,
+                                                           bring_up=True)
+
+    @property
+    def config(self):
+        return self.datasource.network_config
+
+    def detect(self, action):
+        detect_presence = None
+        if action == 'add':
+            detect_presence = True
+        elif action == 'remove':
+            detect_presence = False
+        else:
+            raise ValueError('Cannot detect unknown action: %s' % action)
+
+        return detect_presence == in_netconfig(self.id, self.config)
+
+
+UEVENT_HANDLERS = {
+    'net': NetHandler,
+}
+
+SUBSYSTEM_TO_EVENT = {
+    'net': 'network',
+    'block': 'storage',
+}
+
+
+def handle_args(name, args):
+    log_console('%s called with args=%s' % (NAME, args))
+    hotplug_reporter = events.ReportEventStack(NAME, __doc__,
+                                               reporting_enabled=True)
+    with hotplug_reporter:
+        # only handling net udev events for now
+        event_handler_cls = UEVENT_HANDLERS.get(args.subsystem)
+        if not event_handler_cls:
+            log_console('hotplug-hook: cannot handle events for subsystem: '
+                        '"%s"' % args.subsystem)
+            return 1
+
+        log_console('Reading cloud-init configation')
+        hotplug_init = Init(ds_deps=[], reporter=hotplug_reporter)
+        hotplug_init.read_cfg()
+
+        log_console('Configuring logging')
+        log.setupLogging(hotplug_init.cfg)
+        if 'reporting' in hotplug_init.cfg:
+            reporting.update_configuration(hotplug_init.cfg.get('reporting'))
+
+        log_console('Fetching datasource')
+        try:
+            ds = hotplug_init.fetch(existing="trust")
+        except sources.DatasourceNotFoundException:
+            log_console('No Ds found')
+            return 1
+
+        subevent = SUBSYSTEM_TO_EVENT.get(args.subsystem)
+        if hotplug_init.update_event_allowed(EventType.UDEV, scope=subevent):
+            log_console('cloud-init not configured to handle udev events')
+            return
+
+        log_console('Creating %s event handler' % args.subsystem)
+        event_handler = event_handler_cls(ds, args.devpath,
+                                          hotplug_init._write_to_cache)
+        retries = [1, 1, 1, 3, 5]
+        for attempt, wait in enumerate(retries):
+            log_console('subsystem=%s update attempt %s/%s' % (args.subsystem,
+                                                               attempt,
+                                                               len(retries)))
+            try:
+                log_console('Refreshing metadata')
+                event_handler.update()
+                if event_handler.detect(action=args.udevaction):
+                    log_console('Detected update, apply config change')
+                    event_handler.apply()
+                    log_console('Updating cache')
+                    event_handler.success()
+                    break
+                else:
+                    raise Exception(
+                            "Failed to detect device change in metadata")
+
+            except Exception as e:
+                if attempt + 1 >= len(retries):
+                    raise
+                log_console('exception while processing hotplug event. %s' % e)
+
+        log_console('exiting handler')
+        reporting.flush_events()
+
+
+if __name__ == '__main__':
+    if 'TZ' not in os.environ:
+        os.environ['TZ'] = ":/etc/localtime"
+    args = get_parser().parse_args()
+    handle_args(NAME, args)
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/cmd/devel/parser.py b/cloudinit/cmd/devel/parser.py
index 99a234c..3ad09b3 100644
--- a/cloudinit/cmd/devel/parser.py
+++ b/cloudinit/cmd/devel/parser.py
@@ -6,7 +6,7 @@
 
 import argparse
 from cloudinit.config import schema
-
+from . import hotplug_hook
 from . import net_convert
 from . import render
 
@@ -20,6 +20,8 @@ def get_parser(parser=None):
     subparsers.required = True
 
     subcmds = [
+        (hotplug_hook.NAME, hotplug_hook.__doc__,
+         hotplug_hook.get_parser, hotplug_hook.handle_args),
         ('schema', 'Validate cloud-config files for document schema',
          schema.get_parser, schema.handle_schema_args),
         (net_convert.NAME, net_convert.__doc__,
diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py
index ef618c2..92285f5 100644
--- a/cloudinit/distros/__init__.py
+++ b/cloudinit/distros/__init__.py
@@ -69,6 +69,7 @@ class Distro(object):
         self._paths = paths
         self._cfg = cfg
         self.name = name
+        self.net_renderer = None
 
     @abc.abstractmethod
     def install_packages(self, pkglist):
@@ -89,9 +90,8 @@ class Distro(object):
         name, render_cls = renderers.select(priority=priority)
         LOG.debug("Selected renderer '%s' from priority list: %s",
                   name, priority)
-        renderer = render_cls(config=self.renderer_configs.get(name))
-        renderer.render_network_config(network_config)
-        return []
+        self.net_renderer = render_cls(config=self.renderer_configs.get(name))
+        return self.net_renderer.render_network_config(network_config)
 
     def _find_tz_file(self, tz):
         tz_file = os.path.join(self.tz_zone_dir, str(tz))
@@ -176,6 +176,7 @@ class Distro(object):
         # a much less complete network config format (interfaces(5)).
         try:
             dev_names = self._write_network_config(netconfig)
+            LOG.debug('Network config found dev names: %s', dev_names)
         except NotImplementedError:
             # backwards compat until all distros have apply_network_config
             return self._apply_network_from_network_config(
diff --git a/cloudinit/distros/debian.py b/cloudinit/distros/debian.py
index d517fb8..4f1e6a9 100644
--- a/cloudinit/distros/debian.py
+++ b/cloudinit/distros/debian.py
@@ -114,14 +114,23 @@ class Distro(distros.Distro):
         return self._supported_write_network_config(netconfig)
 
     def _bring_up_interfaces(self, device_names):
-        use_all = False
-        for d in device_names:
-            if d == 'all':
-                use_all = True
-        if use_all:
-            return distros.Distro._bring_up_interface(self, '--all')
+        render_name = self.net_renderer.name
+        if render_name == 'eni':
+            LOG.debug('Bringing up interfaces with eni/ifup')
+            use_all = False
+            for d in device_names:
+                if d == 'all':
+                    use_all = True
+            if use_all:
+                return distros.Distro._bring_up_interface(self, '--all')
+            else:
+                return distros.Distro._bring_up_interfaces(self, device_names)
+        elif render_name == 'netplan':
+            LOG.debug('Bringing up interfaces with netplan apply')
+            util.subp(['netplan', 'apply'])
         else:
-            return distros.Distro._bring_up_interfaces(self, device_names)
+            LOG.warning('Cannot bring up interfaces, unknown renderer: "%s"',
+                        render_name)
 
     def _write_hostname(self, your_hostname, out_fn):
         conf = None
diff --git a/cloudinit/event.py b/cloudinit/event.py
index f7b311f..77ce631 100644
--- a/cloudinit/event.py
+++ b/cloudinit/event.py
@@ -2,16 +2,68 @@
 
 """Classes and functions related to event handling."""
 
+from cloudinit import log as logging
+from cloudinit import util
+
+
+LOG = logging.getLogger(__name__)
+
 
 # Event types which can generate maintenance requests for cloud-init.
 class EventType(object):
     BOOT = "System boot"
     BOOT_NEW_INSTANCE = "New instance first boot"
+    UDEV = "Udev add|change event on net|storage"
 
     # TODO: Cloud-init will grow support for the follow event types:
-    # UDEV
     # METADATA_CHANGE
     # USER_REQUEST
 
+EventTypeMap = {
+    'boot': EventType.BOOT,
+    'boot-new-instance': EventType.BOOT_NEW_INSTANCE,
+    'udev': EventType.UDEV,
+}
+
+# inverted mapping
+EventNameMap = {v: k for k, v in EventTypeMap.items()}
+
+
+def get_allowed_events(sys_events, ds_events):
+    '''Merge datasource capabilties with system config to determine which
+       update events are allowed.'''
+
+    # updates:
+    #   policy-version: 1
+    #   network:
+    #     when: [boot-new-instance, boot, udev]
+    #   storage:
+    #     when: [boot-new-instance, udev]
+    #     watch: http://169.254.169.254/metadata/storage_config/
+
+    LOG.debug('updates: system   cfg: %s', sys_events)
+    LOG.debug('updates: datasrc caps: %s', ds_events)
+
+    updates = util.mergemanydict([sys_events, ds_events])
+    LOG.debug('updates: merged  cfg: %s', updates)
+
+    events = {}
+    for etype in ['network', 'storage']:
+        events[etype] = (
+            set([EventTypeMap.get(evt)
+                 for evt in updates.get(etype, {}).get('when', [])
+                 if evt in EventTypeMap]))
+
+    LOG.debug('updates: allowed events: %s', events)
+    return events
+
+
+def get_update_events_config(update_events):
+    '''Return a dictionary of updates config'''
+    evt_cfg = {'policy-version': 1}
+    for scope, events in update_events.items():
+        evt_cfg[scope] = {'when': [EventNameMap[evt] for evt in events]}
+
+    return evt_cfg
 
 # vi: ts=4 expandtab
diff --git a/cloudinit/net/eni.py b/cloudinit/net/eni.py
index c6f631a..3d8dcfb 100644
--- a/cloudinit/net/eni.py
+++ b/cloudinit/net/eni.py
@@ -338,6 +338,8 @@ def _ifaces_to_net_config_data(ifaces):
 class Renderer(renderer.Renderer):
     """Renders network information in a /etc/network/interfaces format."""
 
+    name = 'eni'
+
     def __init__(self, config=None):
         if not config:
             config = {}
diff --git a/cloudinit/net/netplan.py b/cloudinit/net/netplan.py
index bc1087f..08c9d05 100644
--- a/cloudinit/net/netplan.py
+++ b/cloudinit/net/netplan.py
@@ -178,6 +178,8 @@ def _clean_default(target=None):
 class Renderer(renderer.Renderer):
     """Renders network information in a /etc/netplan/network.yaml format."""
 
+    name = 'netplan'
+
     NETPLAN_GENERATE = ['netplan', 'generate']
 
     def __init__(self, config=None):
diff --git a/cloudinit/net/renderer.py b/cloudinit/net/renderer.py
index 5f32e90..88a1221 100644
--- a/cloudinit/net/renderer.py
+++ b/cloudinit/net/renderer.py
@@ -44,6 +44,10 @@ class Renderer(object):
                                                  driver=driver))
         return content.getvalue()
 
+    @staticmethod
+    def get_interface_names(network_state):
+        return [cfg.get('name') for cfg in network_state.iter_interfaces()]
+
     @abc.abstractmethod
     def render_network_state(self, network_state, templates=None,
                              target=None):
@@ -51,8 +55,9 @@ class Renderer(object):
 
     def render_network_config(self, network_config, templates=None,
                               target=None):
-        return self.render_network_state(
-            network_state=parse_net_config_data(network_config),
-            templates=templates, target=target)
+        network_state = parse_net_config_data(network_config)
+        self.render_network_state(network_state=network_state,
+                                  templates=templates, target=target)
+        return self.get_interface_names(network_state)
 
 # vi: ts=4 expandtab
diff --git a/cloudinit/net/sysconfig.py b/cloudinit/net/sysconfig.py
index 9c16d3a..d502268 100644
--- a/cloudinit/net/sysconfig.py
+++ b/cloudinit/net/sysconfig.py
@@ -232,6 +232,8 @@ class NetInterface(ConfigMap):
 class Renderer(renderer.Renderer):
     """Renders network information in a /etc/sysconfig format."""
 
+    name = 'sysconfig'
+
     # See: https://access.redhat.com/documentation/en-US/\
     #      Red_Hat_Enterprise_Linux/6/html/Deployment_Guide/\
     #      s1-networkscripts-interfaces.html (or other docs for
diff --git a/cloudinit/sources/DataSourceOpenStack.py b/cloudinit/sources/DataSourceOpenStack.py
index 4a01524..5136dcb 100644
--- a/cloudinit/sources/DataSourceOpenStack.py
+++ b/cloudinit/sources/DataSourceOpenStack.py
@@ -7,7 +7,9 @@
 import time
 
 from cloudinit import log as logging
+from cloudinit.event import EventType
 from cloudinit.net.dhcp import EphemeralDHCPv4, NoDHCPLeaseError
+from cloudinit.net import is_up
 from cloudinit import sources
 from cloudinit import url_helper
 from cloudinit import util
@@ -93,6 +95,15 @@ class DataSourceOpenStack(openstack.SourceMixin, sources.DataSource):
         return sources.instance_id_matches_system_uuid(self.get_instance_id())
 
     @property
+    def update_events(self):
+        events = {'network': set([EventType.BOOT_NEW_INSTANCE,
+                                  EventType.BOOT,
+                                  EventType.UDEV]),
+                  'storage': set([])}
+        LOG.debug('OpenStack update events: %s', events)
+        return events
+
+    @property
     def network_config(self):
         """Return a network config dict for rendering ENI or netplan files."""
         if self._network_config != sources.UNSET:
@@ -122,11 +133,13 @@ class DataSourceOpenStack(openstack.SourceMixin, sources.DataSource):
             False when unable to contact metadata service or when metadata
             format is invalid or disabled.
         """
-        oracle_considered = 'Oracle' in self.sys_cfg.get('datasource_list')
+        oracle_considered = 'Oracle' in self.sys_cfg.get('datasource_list',
+                                                         {})
         if not detect_openstack(accept_oracle=not oracle_considered):
             return False
 
-        if self.perform_dhcp_setup:  # Setup networking in init-local stage.
+        if self.perform_dhcp_setup and not is_up(self.fallback_interface):
+            # Setup networking in init-local stage.
             try:
                 with EphemeralDHCPv4(self.fallback_interface):
                     results = util.log_time(
diff --git a/cloudinit/sources/__init__.py b/cloudinit/sources/__init__.py
index 5ac9882..71da091 100644
--- a/cloudinit/sources/__init__.py
+++ b/cloudinit/sources/__init__.py
@@ -141,15 +141,6 @@ class DataSource(object):
     url_timeout = 10    # timeout for each metadata url read attempt
     url_retries = 5     # number of times to retry url upon 404
 
-    # The datasource defines a set of supported EventTypes during which
-    # the datasource can react to changes in metadata and regenerate
-    # network configuration on metadata changes.
-    # A datasource which supports writing network config on each system boot
-    # would call update_events['network'].add(EventType.BOOT).
-
-    # Default: generate network config on new instance id (first boot).
-    update_events = {'network': set([EventType.BOOT_NEW_INSTANCE])}
-
     # N-tuple listing default values for any metadata-related class
     # attributes cached on an instance by a process_data runs. These attribute
     # values are reset via clear_cached_attrs during any update_metadata call.
@@ -159,6 +150,7 @@ class DataSource(object):
         ('vendordata', None), ('vendordata_raw', None))
 
     _dirty_cache = False
+    _update_events = {}
 
     # N-tuple of keypaths or keynames redact from instance-data.json for
     # non-root users
@@ -525,6 +517,24 @@ class DataSource(object):
     def get_package_mirror_info(self):
         return self.distro.get_package_mirror_info(data_source=self)
 
+    # The datasource defines a set of supported EventTypes during which
+    # the datasource can react to changes in metadata and regenerate
+    # network configuration on metadata changes.
+    # A datasource which supports writing network config on each system boot
+    # would call update_events['network'].add(EventType.BOOT).
+
+    # Default: generate network config on new instance id (first boot).
+    @property
+    def update_events(self):
+        if not self._update_events:
+            self._update_events = {'network':
+                                   set([EventType.BOOT_NEW_INSTANCE])}
+        return self._update_events
+
+    @update_events.setter
+    def update_events(self, events):
+        self._update_events.update(events)
+
     def update_metadata(self, source_event_types):
         """Refresh cached metadata if the datasource supports this event.
 
diff --git a/cloudinit/stages.py b/cloudinit/stages.py
index 8a06412..3dce084 100644
--- a/cloudinit/stages.py
+++ b/cloudinit/stages.py
@@ -22,9 +22,8 @@ from cloudinit.handlers.cloud_config import CloudConfigPartHandler
 from cloudinit.handlers.jinja_template import JinjaTemplatePartHandler
 from cloudinit.handlers.shell_script import ShellScriptPartHandler
 from cloudinit.handlers.upstart_job import UpstartJobPartHandler
-
-from cloudinit.event import EventType
-
+from cloudinit.event import (
+    EventType, get_allowed_events, get_update_events_config)
 from cloudinit import cloud
 from cloudinit import config
 from cloudinit import distros
@@ -644,7 +643,45 @@ class Init(object):
                 return (ncfg, loc)
         return (self.distro.generate_fallback_config(), "fallback")
 
+    def update_event_allowed(self, event_source_type, scope=None):
+        # convert ds events to config
+        ds_config = get_update_events_config(self.datasource.update_events)
+        LOG.debug('Datasource updates cfg: %s', ds_config)
+
+        allowed = get_allowed_events(self.cfg.get('updates', {}), ds_config)
+        LOG.debug('Allowable update events: %s', allowed)
+
+        if not scope:
+            scopes = [allowed.keys()]
+        else:
+            scopes = [scope]
+        LOG.debug('Possible scopes for this event: %s', scopes)
+
+        for evt_scope in scopes:
+            if event_source_type in allowed[evt_scope]:
+                LOG.debug('Event Allowed: scope=%s EventType=%s',
+                          evt_scope, event_source_type)
+                return True
+
+        LOG.debug('Event Denied: scopes=%s EventType=%s',
+                  scopes, event_source_type)
+        return False
+
     def apply_network_config(self, bring_up):
+        apply_network = True
+        if self.datasource is not NULL_DATA_SOURCE:
+            if not self.is_new_instance():
+                if self.update_event_allowed(EventType.BOOT, scope='network'):
+                    if not self.datasource.update_metadata([EventType.BOOT]):
+                        LOG.debug(
+                            "No network config applied. Datasource failed"
+                            " update metadata on '%s' event", EventType.BOOT)
+                        apply_network = False
+                else:
+                    LOG.debug("No network config applied. "
+                              "'%s' event not allowed", EventType.BOOT)
+                    apply_network = False
+
         netcfg, src = self._find_networking_config()
         if netcfg is None:
             LOG.info("network config is disabled by %s", src)
@@ -656,14 +693,8 @@ class Init(object):
         except Exception as e:
             LOG.warning("Failed to rename devices: %s", e)
 
-        if self.datasource is not NULL_DATA_SOURCE:
-            if not self.is_new_instance():
-                if not self.datasource.update_metadata([EventType.BOOT]):
-                    LOG.debug(
-                        "No network config applied. Neither a new instance"
-                        " nor datasource network update on '%s' event",
-                        EventType.BOOT)
-                    return
+        if not apply_network:
+            return
 
         LOG.info("Applying network configuration from %s bringup=%s: %s",
                  src, bring_up, netcfg)
diff --git a/cloudinit/tests/test_stages.py b/cloudinit/tests/test_stages.py
index 94b6b25..6e2068a 100644
--- a/cloudinit/tests/test_stages.py
+++ b/cloudinit/tests/test_stages.py
@@ -47,6 +47,10 @@ class TestInit(CiTestCase):
             'distro': 'ubuntu', 'paths': {'cloud_dir': self.tmpdir,
                                           'run_dir': self.tmpdir}}}
         self.init.datasource = FakeDataSource(paths=self.init.paths)
+        self.init.datasource.update_events = {
+            'network': set([EventType.BOOT_NEW_INSTANCE])}
+        self.add_patch('cloudinit.stages.get_allowed_events', 'mock_allowed',
+                       return_value=self.init.datasource.update_events)
 
     def test_wb__find_networking_config_disabled(self):
         """find_networking_config returns no config when disabled."""
@@ -200,11 +204,10 @@ class TestInit(CiTestCase):
         self.init._find_networking_config = fake_network_config
         self.init.apply_network_config(True)
         self.init.distro.apply_network_config_names.assert_called_with(net_cfg)
+        self.assertIn("No network config applied. "
+                      "'%s' event not allowed" % EventType.BOOT,
+                      self.logs.getvalue())
         self.init.distro.apply_network_config.assert_not_called()
-        self.assertIn(
-            'No network config applied. Neither a new instance'
-            " nor datasource network update on '%s' event" % EventType.BOOT,
-            self.logs.getvalue())
 
     @mock.patch('cloudinit.distros.ubuntu.Distro')
     def test_apply_network_on_datasource_allowed_event(self, m_ubuntu):
@@ -222,7 +225,7 @@ class TestInit(CiTestCase):
 
         self.init._find_networking_config = fake_network_config
         self.init.datasource = FakeDataSource(paths=self.init.paths)
-        self.init.datasource.update_events = {'network': [EventType.BOOT]}
+        self.init.datasource.update_events = {'network': set([EventType.BOOT])}
         self.init.apply_network_config(True)
         self.init.distro.apply_network_config_names.assert_called_with(net_cfg)
         self.init.distro.apply_network_config.assert_called_with(
diff --git a/config/cloud.cfg.d/10_updates_policy.cfg b/config/cloud.cfg.d/10_updates_policy.cfg
new file mode 100644
index 0000000..245a2d8
--- /dev/null
+++ b/config/cloud.cfg.d/10_updates_policy.cfg
@@ -0,0 +1,6 @@
+# default policy for cloud-init for when to update system config
+# such as network and storage configurations
+updates:
+  policy-version: 1
+  network:
+    when: ['boot-new-instance']
diff --git a/setup.py b/setup.py
index 5ed8eae..5f3521e 100755
--- a/setup.py
+++ b/setup.py
@@ -138,6 +138,7 @@ INITSYS_FILES = {
     'systemd': [render_tmpl(f)
                 for f in (glob('systemd/*.tmpl') +
                           glob('systemd/*.service') +
+                          glob('systemd/*.socket') +
                           glob('systemd/*.target')) if is_f(f)],
     'systemd.generators': [f for f in glob('systemd/*-generator') if is_f(f)],
     'upstart': [f for f in glob('upstart/*') if is_f(f)],
@@ -243,6 +244,7 @@ data_files = [
     (ETC + '/cloud/cloud.cfg.d', glob('config/cloud.cfg.d/*')),
     (ETC + '/cloud/templates', glob('templates/*')),
     (USR_LIB_EXEC + '/cloud-init', ['tools/ds-identify',
+                                    'tools/hook-hotplug',
                                     'tools/uncloud-init',
                                     'tools/write-ssh-key-fingerprints']),
     (USR + '/share/doc/cloud-init', [f for f in glob('doc/*') if is_f(f)]),
diff --git a/systemd/cloud-init-hotplugd.service b/systemd/cloud-init-hotplugd.service
new file mode 100644
index 0000000..6f231cd
--- /dev/null
+++ b/systemd/cloud-init-hotplugd.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=cloud-init hotplug hook daemon
+After=cloud-init-hotplugd.socket
+
+[Service]
+Type=simple
+ExecStart=/bin/bash -c 'read args <&3; echo "args=$args"; \
+                        exec /usr/bin/cloud-init devel hotplug-hook $args; \
+                        exit 0'
+SyslogIdentifier=cloud-init-hotplugd
+TimeoutStopSec=5
diff --git a/systemd/cloud-init-hotplugd.socket b/systemd/cloud-init-hotplugd.socket
new file mode 100644
index 0000000..f8f1048
--- /dev/null
+++ b/systemd/cloud-init-hotplugd.socket
@@ -0,0 +1,8 @@
+[Unit]
+Description=cloud-init hotplug hook socket
+
+[Socket]
+ListenFIFO=/run/cloud-init/hook-hotplug-cmd
+
+[Install]
+WantedBy=cloud-init.target
diff --git a/tools/hook-hotplug b/tools/hook-hotplug
new file mode 100755
index 0000000..697d3ad
--- /dev/null
+++ b/tools/hook-hotplug
@@ -0,0 +1,26 @@
+#!/bin/bash
+# This file is part of cloud-init. See LICENSE file for license information.
+
+# This script checks if cloud-init has hotplug hooked and if
+# cloud-init has finished; if so invoke cloud-init hotplug-hook
+
+is_finished() {
+    [ -e /run/cloud-init/result.json ] || return 1
+}
+
+if is_finished; then
+    # only hook pci devices at this time
+    case ${DEVPATH} in
+        /devices/pci*)
+            # open cloud-init's hotplug-hook fifo rw
+            exec 3<>/run/cloud-init/hook-hotplug-cmd
+            env_params=( \
+                --devpath=${DEVPATH}
+                --subsystem=${SUBSYSTEM}
+                --udevaction=${ACTION}
+            )
+            # write params to cloud-init's hotplug-hook fifo
+            echo "--hotplug-debug ${env_params[@]}" >&3
+            ;;
+    esac
+fi
diff --git a/udev/10-cloud-init-hook-hotplug.rules b/udev/10-cloud-init-hook-hotplug.rules
new file mode 100644
index 0000000..74324f4
--- /dev/null
+++ b/udev/10-cloud-init-hook-hotplug.rules
@@ -0,0 +1,5 @@
+# Handle device adds only
+ACTION!="add", GOTO="cloudinit_end"
+LABEL="cloudinit_hook"
+SUBSYSTEM=="net|block", RUN+="/usr/lib/cloud-init/hook-hotplug"
+LABEL="cloudinit_end"

References