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

Proposed by Scott Moser
Status: Work in progress
Proposed branch: ~raharper/cloud-init:feature/cloud-init-hotplug-handler
Merge into: cloud-init:master
Diff against target: 1122 lines (+709/-45)
26 files modified
bash_completion/cloud-init (+4/-1)
cloudinit/cmd/devel/hotplug_hook.py (+210/-0)
cloudinit/cmd/devel/parser.py (+3/-1)
cloudinit/distros/__init__.py (+4/-3)
cloudinit/distros/debian.py (+16/-7)
cloudinit/event.py (+59/-1)
cloudinit/net/eni.py (+2/-0)
cloudinit/net/netplan.py (+2/-0)
cloudinit/net/renderer.py (+8/-3)
cloudinit/net/sysconfig.py (+2/-0)
cloudinit/sources/DataSourceOpenStack.py (+15/-2)
cloudinit/sources/__init__.py (+19/-9)
cloudinit/stages.py (+45/-11)
cloudinit/tests/test_event.py (+75/-0)
cloudinit/tests/test_stages.py (+26/-5)
cloudinit/version.py (+2/-0)
config/cloud.cfg.d/10_updates_policy.cfg (+6/-0)
doc/rtd/index.rst (+1/-0)
doc/rtd/topics/capabilities.rst (+6/-0)
doc/rtd/topics/datasources/smartos.rst (+2/-2)
doc/rtd/topics/events.rst (+150/-0)
setup.py (+2/-0)
systemd/cloud-init-hotplugd.service (+11/-0)
systemd/cloud-init-hotplugd.socket (+8/-0)
tools/hook-hotplug (+26/-0)
udev/10-cloud-init-hook-hotplug.rules (+5/-0)
Reviewer Review Type Date Requested Status
cloud-init Commiters review-wip Pending
Review via email: mp+356152@code.launchpad.net

Commit message

wip fixme

To post a comment you must log in.
Revision history for this message
Scott Moser (smoser) wrote :

i'm not all the way through, but here are some comments.

725b992... by Ryan Harper

Add unittests, remove hard coded scope, fix tox

abaa5b1... by Ryan Harper

Add tests for event module

Revision history for this message
Scott Moser (smoser) :
Revision history for this message
Ryan Harper (raharper) wrote :

Thanks for the review. I've pushed some of your changes already. Doing another round now.

a2eb198... by Ryan Harper

Address review comments

hotplug:
  - Capture update result, log on error
  - Add more error details when cloud-init is not configured for hotplug
  - Use RuntimeError instead of Exception, add more detail in message
tools/hook-hotplug
  - Drop trailing slash
  - Quote environment variables when creating array

97dc6e6... by Ryan Harper

EventType.UDEV -> EventType.HOTPLUG and udev -> hotplug

2d16ce8... by Ryan Harper

Drop extra || return 1

5b0d4cf... by Ryan Harper

Fix logic thinko if not allowed return

94d37e1... by Ryan Harper

docs: add documentation for events and hotplug support

Revision history for this message
Si-Wei Liu (siwliu) wrote :

Any plan to merge this into master soon before the next quarterly cloud-init release? I see no progress had been made since November last year, wondering what happened to this enhancement? Oracle is looking forward to adopting and leveraging this feature in its Ubuntu cloud images.

Unmerged commits

94d37e1... by Ryan Harper

docs: add documentation for events and hotplug support

5b0d4cf... by Ryan Harper

Fix logic thinko if not allowed return

2d16ce8... by Ryan Harper

Drop extra || return 1

97dc6e6... by Ryan Harper

EventType.UDEV -> EventType.HOTPLUG and udev -> hotplug

a2eb198... by Ryan Harper

Address review comments

hotplug:
  - Capture update result, log on error
  - Add more error details when cloud-init is not configured for hotplug
  - Use RuntimeError instead of Exception, add more detail in message
tools/hook-hotplug
  - Drop trailing slash
  - Quote environment variables when creating array

abaa5b1... by Ryan Harper

Add tests for event module

725b992... by Ryan Harper

Add unittests, remove hard coded scope, fix tox

af4ca37... by Ryan Harper

Add debug, copy dictionaries before merging

82287a4... by Ryan Harper

Reorder updates config merging with datasource capabilities

System config needs to filter datasource capabilities.

809bb1a... by Ryan Harper

Retain apply_network_config, apply names but not config in some cases

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/bash_completion/cloud-init b/bash_completion/cloud-init
2index 8c25032..0eab40c 100644
3--- a/bash_completion/cloud-init
4+++ b/bash_completion/cloud-init
5@@ -28,7 +28,7 @@ _cloudinit_complete()
6 COMPREPLY=($(compgen -W "--help --tarfile --include-userdata" -- $cur_word))
7 ;;
8 devel)
9- COMPREPLY=($(compgen -W "--help schema net-convert" -- $cur_word))
10+ COMPREPLY=($(compgen -W "--help hotplug-hook net-convert schema" -- $cur_word))
11 ;;
12 dhclient-hook|features)
13 COMPREPLY=($(compgen -W "--help" -- $cur_word))
14@@ -61,6 +61,9 @@ _cloudinit_complete()
15 --frequency)
16 COMPREPLY=($(compgen -W "--help instance always once" -- $cur_word))
17 ;;
18+ hotplug-hook)
19+ COMPREPLY=($(compgen -W "--help" -- $cur_word))
20+ ;;
21 net-convert)
22 COMPREPLY=($(compgen -W "--help --network-data --kind --directory --output-kind" -- $cur_word))
23 ;;
24diff --git a/cloudinit/cmd/devel/hotplug_hook.py b/cloudinit/cmd/devel/hotplug_hook.py
25new file mode 100644
26index 0000000..ae23d07
27--- /dev/null
28+++ b/cloudinit/cmd/devel/hotplug_hook.py
29@@ -0,0 +1,210 @@
30+# This file is part of cloud-init. See LICENSE file for license information.
31+
32+"""Handle reconfiguration on hotplug events"""
33+import abc
34+import argparse
35+import os
36+import six
37+import sys
38+import time
39+
40+from cloudinit.event import EventType
41+from cloudinit import log
42+from cloudinit import reporting
43+from cloudinit.reporting import events
44+from cloudinit import sources
45+from cloudinit.stages import Init
46+from cloudinit.net import read_sys_net_safe
47+from cloudinit.net.network_state import parse_net_config_data
48+
49+
50+LOG = log.getLogger(__name__)
51+NAME = 'hotplug-hook'
52+
53+
54+def get_parser(parser=None):
55+ """Build or extend and arg parser for hotplug-hook utility.
56+
57+ @param parser: Optional existing ArgumentParser instance representing the
58+ subcommand which will be extended to support the args of this utility.
59+
60+ @returns: ArgumentParser with proper argument configuration.
61+ """
62+ if not parser:
63+ parser = argparse.ArgumentParser(prog=NAME, description=__doc__)
64+
65+ parser.add_argument("-d", "--devpath",
66+ metavar="PATH",
67+ help="sysfs path to hotplugged device")
68+ parser.add_argument("--hotplug-debug", action='store_true',
69+ help='enable debug logging to stderr.')
70+ parser.add_argument("-s", "--subsystem",
71+ choices=['net', 'block'])
72+ parser.add_argument("-u", "--udevaction",
73+ choices=['add', 'change', 'remove'])
74+
75+ return parser
76+
77+
78+def log_console(msg):
79+ """Log messages to stderr console and configured logging."""
80+ sys.stderr.write(msg + '\n')
81+ sys.stderr.flush()
82+ LOG.debug(msg)
83+
84+
85+def devpath_to_macaddr(devpath):
86+ macaddr = read_sys_net_safe(os.path.basename(devpath), 'address')
87+ return macaddr
88+
89+
90+def in_netconfig(unique_id, netconfig):
91+ netstate = parse_net_config_data(netconfig)
92+ found = [iface
93+ for iface in netstate.iter_interfaces()
94+ if iface.get('mac_address') == unique_id]
95+ log_console('Ifaces with ID=%s : %s' % (unique_id, found))
96+ return len(found) > 0
97+
98+
99+@six.add_metaclass(abc.ABCMeta)
100+class UeventHandler(object):
101+ def __init__(self, ds, devpath, success_fn):
102+ self.datasource = ds
103+ self.devpath = devpath
104+ self.success_fn = success_fn
105+
106+ @abc.abstractmethod
107+ def apply(self):
108+ raise NotImplementedError()
109+
110+ @property
111+ @abc.abstractmethod
112+ def config(self):
113+ raise NotImplementedError()
114+
115+ @abc.abstractmethod
116+ def detect(self, action):
117+ raise NotImplementedError()
118+
119+ def success(self):
120+ return self.success_fn()
121+
122+ def update(self):
123+ result = self.datasource.update_metadata([EventType.HOTPLUG])
124+ if not result:
125+ log_console('Datasource %s not updated for '
126+ ' event %s' % (self.datasource, EventType.HOTPLUG))
127+ return result
128+
129+
130+class NetHandler(UeventHandler):
131+ def __init__(self, ds, devpath, success_fn):
132+ super(NetHandler, self).__init__(ds, devpath, success_fn)
133+ self.id = devpath_to_macaddr(self.devpath)
134+
135+ def apply(self):
136+ return self.datasource.distro.apply_network_config(self.config,
137+ bring_up=True)
138+
139+ @property
140+ def config(self):
141+ return self.datasource.network_config
142+
143+ def detect(self, action):
144+ detect_presence = None
145+ if action == 'add':
146+ detect_presence = True
147+ elif action == 'remove':
148+ detect_presence = False
149+ else:
150+ raise ValueError('Cannot detect unknown action: %s' % action)
151+
152+ return detect_presence == in_netconfig(self.id, self.config)
153+
154+
155+UEVENT_HANDLERS = {
156+ 'net': NetHandler,
157+}
158+
159+SUBSYSTEM_TO_EVENT = {
160+ 'net': 'network',
161+ 'block': 'storage',
162+}
163+
164+
165+def handle_args(name, args):
166+ log_console('%s called with args=%s' % (NAME, args))
167+ hotplug_reporter = events.ReportEventStack(NAME, __doc__,
168+ reporting_enabled=True)
169+ with hotplug_reporter:
170+ # only handling net udev events for now
171+ event_handler_cls = UEVENT_HANDLERS.get(args.subsystem)
172+ if not event_handler_cls:
173+ log_console('hotplug-hook: cannot handle events for subsystem: '
174+ '"%s"' % args.subsystem)
175+ return 1
176+
177+ log_console('Reading cloud-init configation')
178+ hotplug_init = Init(ds_deps=[], reporter=hotplug_reporter)
179+ hotplug_init.read_cfg()
180+
181+ log_console('Configuring logging')
182+ log.setupLogging(hotplug_init.cfg)
183+ if 'reporting' in hotplug_init.cfg:
184+ reporting.update_configuration(hotplug_init.cfg.get('reporting'))
185+
186+ log_console('Fetching datasource')
187+ try:
188+ ds = hotplug_init.fetch(existing="trust")
189+ except sources.DataSourceNotFoundException:
190+ log_console('No Ds found')
191+ return 1
192+
193+ subevent = SUBSYSTEM_TO_EVENT.get(args.subsystem)
194+ if not hotplug_init.update_event_allowed(EventType.HOTPLUG,
195+ scope=subevent):
196+ log_console('cloud-init not configured to handle hotplug event'
197+ ' of type %s' % subevent)
198+ return
199+
200+ log_console('Creating %s event handler' % args.subsystem)
201+ event_handler = event_handler_cls(ds, args.devpath,
202+ hotplug_init._write_to_cache)
203+ retries = [1, 1, 1, 3, 5]
204+ for attempt, wait in enumerate(retries):
205+ log_console('subsystem=%s update attempt %s/%s' % (args.subsystem,
206+ attempt,
207+ len(retries)))
208+ try:
209+ log_console('Refreshing metadata')
210+ result = event_handler.update()
211+ if not result:
212+ raise RuntimeError(
213+ 'Event handler %s update failed' % event_handler_cls)
214+
215+ if event_handler.detect(action=args.udevaction):
216+ log_console('Detected update, apply config change')
217+ event_handler.apply()
218+ log_console('Updating cache')
219+ event_handler.success()
220+ break
221+ else:
222+ raise RuntimeError('Failed to detect %s in updated '
223+ 'metadata' % event_handler.id)
224+
225+ except Exception as e:
226+ if attempt + 1 >= len(retries):
227+ raise
228+ log_console('Exception while processing hotplug event. %s' % e)
229+ time.sleep(wait)
230+
231+ log_console('Exiting hotplug handler')
232+ reporting.flush_events()
233+
234+
235+if __name__ == '__main__':
236+ args = get_parser().parse_args()
237+ handle_args(NAME, args)
238+
239+# vi: ts=4 expandtab
240diff --git a/cloudinit/cmd/devel/parser.py b/cloudinit/cmd/devel/parser.py
241index 99a234c..3ad09b3 100644
242--- a/cloudinit/cmd/devel/parser.py
243+++ b/cloudinit/cmd/devel/parser.py
244@@ -6,7 +6,7 @@
245
246 import argparse
247 from cloudinit.config import schema
248-
249+from . import hotplug_hook
250 from . import net_convert
251 from . import render
252
253@@ -20,6 +20,8 @@ def get_parser(parser=None):
254 subparsers.required = True
255
256 subcmds = [
257+ (hotplug_hook.NAME, hotplug_hook.__doc__,
258+ hotplug_hook.get_parser, hotplug_hook.handle_args),
259 ('schema', 'Validate cloud-config files for document schema',
260 schema.get_parser, schema.handle_schema_args),
261 (net_convert.NAME, net_convert.__doc__,
262diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py
263index ef618c2..92285f5 100644
264--- a/cloudinit/distros/__init__.py
265+++ b/cloudinit/distros/__init__.py
266@@ -69,6 +69,7 @@ class Distro(object):
267 self._paths = paths
268 self._cfg = cfg
269 self.name = name
270+ self.net_renderer = None
271
272 @abc.abstractmethod
273 def install_packages(self, pkglist):
274@@ -89,9 +90,8 @@ class Distro(object):
275 name, render_cls = renderers.select(priority=priority)
276 LOG.debug("Selected renderer '%s' from priority list: %s",
277 name, priority)
278- renderer = render_cls(config=self.renderer_configs.get(name))
279- renderer.render_network_config(network_config)
280- return []
281+ self.net_renderer = render_cls(config=self.renderer_configs.get(name))
282+ return self.net_renderer.render_network_config(network_config)
283
284 def _find_tz_file(self, tz):
285 tz_file = os.path.join(self.tz_zone_dir, str(tz))
286@@ -176,6 +176,7 @@ class Distro(object):
287 # a much less complete network config format (interfaces(5)).
288 try:
289 dev_names = self._write_network_config(netconfig)
290+ LOG.debug('Network config found dev names: %s', dev_names)
291 except NotImplementedError:
292 # backwards compat until all distros have apply_network_config
293 return self._apply_network_from_network_config(
294diff --git a/cloudinit/distros/debian.py b/cloudinit/distros/debian.py
295index d517fb8..4f1e6a9 100644
296--- a/cloudinit/distros/debian.py
297+++ b/cloudinit/distros/debian.py
298@@ -114,14 +114,23 @@ class Distro(distros.Distro):
299 return self._supported_write_network_config(netconfig)
300
301 def _bring_up_interfaces(self, device_names):
302- use_all = False
303- for d in device_names:
304- if d == 'all':
305- use_all = True
306- if use_all:
307- return distros.Distro._bring_up_interface(self, '--all')
308+ render_name = self.net_renderer.name
309+ if render_name == 'eni':
310+ LOG.debug('Bringing up interfaces with eni/ifup')
311+ use_all = False
312+ for d in device_names:
313+ if d == 'all':
314+ use_all = True
315+ if use_all:
316+ return distros.Distro._bring_up_interface(self, '--all')
317+ else:
318+ return distros.Distro._bring_up_interfaces(self, device_names)
319+ elif render_name == 'netplan':
320+ LOG.debug('Bringing up interfaces with netplan apply')
321+ util.subp(['netplan', 'apply'])
322 else:
323- return distros.Distro._bring_up_interfaces(self, device_names)
324+ LOG.warning('Cannot bring up interfaces, unknown renderer: "%s"',
325+ render_name)
326
327 def _write_hostname(self, your_hostname, out_fn):
328 conf = None
329diff --git a/cloudinit/event.py b/cloudinit/event.py
330index f7b311f..3a18ef9 100644
331--- a/cloudinit/event.py
332+++ b/cloudinit/event.py
333@@ -2,16 +2,74 @@
334
335 """Classes and functions related to event handling."""
336
337+import copy
338+
339+from cloudinit import log as logging
340+from cloudinit import util
341+
342+
343+LOG = logging.getLogger(__name__)
344+
345
346 # Event types which can generate maintenance requests for cloud-init.
347 class EventType(object):
348 BOOT = "System boot"
349 BOOT_NEW_INSTANCE = "New instance first boot"
350+ HOTPLUG = "Hotplug add event"
351
352 # TODO: Cloud-init will grow support for the follow event types:
353- # UDEV
354 # METADATA_CHANGE
355 # USER_REQUEST
356
357
358+EventTypeMap = {
359+ 'boot': EventType.BOOT,
360+ 'boot-new-instance': EventType.BOOT_NEW_INSTANCE,
361+ 'hotplug': EventType.HOTPLUG,
362+}
363+
364+
365+# inverted mapping
366+EventNameMap = {v: k for k, v in EventTypeMap.items()}
367+
368+
369+def get_allowed_events(sys_events, ds_events):
370+ '''Merge datasource capabilties with system config to determine which
371+ update events are allowed.'''
372+
373+ # updates:
374+ # policy-version: 1
375+ # network:
376+ # when: [boot-new-instance, boot, hotplug]
377+ # storage:
378+ # when: [boot-new-instance, hotplug]
379+ # watch: http://169.254.169.254/metadata/storage_config/
380+
381+ LOG.debug('updates: system cfg: %s', sys_events)
382+ LOG.debug('updates: datasrc caps: %s', ds_events)
383+
384+ updates = util.mergemanydict([copy.deepcopy(sys_events),
385+ copy.deepcopy(ds_events)])
386+ LOG.debug('updates: merged cfg: %s', updates)
387+
388+ events = {}
389+ for etype in [scope for scope, val in sys_events.items()
390+ if type(val) == dict and 'when' in val]:
391+ events[etype] = (
392+ set([EventTypeMap.get(evt)
393+ for evt in updates.get(etype, {}).get('when', [])
394+ if evt in EventTypeMap]))
395+
396+ LOG.debug('updates: allowed events: %s', events)
397+ return events
398+
399+
400+def get_update_events_config(update_events):
401+ '''Return a dictionary of updates config'''
402+ evt_cfg = {'policy-version': 1}
403+ for scope, events in update_events.items():
404+ evt_cfg[scope] = {'when': [EventNameMap[evt] for evt in events]}
405+
406+ return evt_cfg
407+
408 # vi: ts=4 expandtab
409diff --git a/cloudinit/net/eni.py b/cloudinit/net/eni.py
410index c6f631a..3d8dcfb 100644
411--- a/cloudinit/net/eni.py
412+++ b/cloudinit/net/eni.py
413@@ -338,6 +338,8 @@ def _ifaces_to_net_config_data(ifaces):
414 class Renderer(renderer.Renderer):
415 """Renders network information in a /etc/network/interfaces format."""
416
417+ name = 'eni'
418+
419 def __init__(self, config=None):
420 if not config:
421 config = {}
422diff --git a/cloudinit/net/netplan.py b/cloudinit/net/netplan.py
423index bc1087f..08c9d05 100644
424--- a/cloudinit/net/netplan.py
425+++ b/cloudinit/net/netplan.py
426@@ -178,6 +178,8 @@ def _clean_default(target=None):
427 class Renderer(renderer.Renderer):
428 """Renders network information in a /etc/netplan/network.yaml format."""
429
430+ name = 'netplan'
431+
432 NETPLAN_GENERATE = ['netplan', 'generate']
433
434 def __init__(self, config=None):
435diff --git a/cloudinit/net/renderer.py b/cloudinit/net/renderer.py
436index 5f32e90..88a1221 100644
437--- a/cloudinit/net/renderer.py
438+++ b/cloudinit/net/renderer.py
439@@ -44,6 +44,10 @@ class Renderer(object):
440 driver=driver))
441 return content.getvalue()
442
443+ @staticmethod
444+ def get_interface_names(network_state):
445+ return [cfg.get('name') for cfg in network_state.iter_interfaces()]
446+
447 @abc.abstractmethod
448 def render_network_state(self, network_state, templates=None,
449 target=None):
450@@ -51,8 +55,9 @@ class Renderer(object):
451
452 def render_network_config(self, network_config, templates=None,
453 target=None):
454- return self.render_network_state(
455- network_state=parse_net_config_data(network_config),
456- templates=templates, target=target)
457+ network_state = parse_net_config_data(network_config)
458+ self.render_network_state(network_state=network_state,
459+ templates=templates, target=target)
460+ return self.get_interface_names(network_state)
461
462 # vi: ts=4 expandtab
463diff --git a/cloudinit/net/sysconfig.py b/cloudinit/net/sysconfig.py
464index 9c16d3a..d502268 100644
465--- a/cloudinit/net/sysconfig.py
466+++ b/cloudinit/net/sysconfig.py
467@@ -232,6 +232,8 @@ class NetInterface(ConfigMap):
468 class Renderer(renderer.Renderer):
469 """Renders network information in a /etc/sysconfig format."""
470
471+ name = 'sysconfig'
472+
473 # See: https://access.redhat.com/documentation/en-US/\
474 # Red_Hat_Enterprise_Linux/6/html/Deployment_Guide/\
475 # s1-networkscripts-interfaces.html (or other docs for
476diff --git a/cloudinit/sources/DataSourceOpenStack.py b/cloudinit/sources/DataSourceOpenStack.py
477index 4a01524..b777c65 100644
478--- a/cloudinit/sources/DataSourceOpenStack.py
479+++ b/cloudinit/sources/DataSourceOpenStack.py
480@@ -7,7 +7,9 @@
481 import time
482
483 from cloudinit import log as logging
484+from cloudinit.event import EventType
485 from cloudinit.net.dhcp import EphemeralDHCPv4, NoDHCPLeaseError
486+from cloudinit.net import is_up
487 from cloudinit import sources
488 from cloudinit import url_helper
489 from cloudinit import util
490@@ -93,6 +95,15 @@ class DataSourceOpenStack(openstack.SourceMixin, sources.DataSource):
491 return sources.instance_id_matches_system_uuid(self.get_instance_id())
492
493 @property
494+ def update_events(self):
495+ events = {'network': set([EventType.BOOT_NEW_INSTANCE,
496+ EventType.BOOT,
497+ EventType.HOTPLUG]),
498+ 'storage': set([])}
499+ LOG.debug('OpenStack update events: %s', events)
500+ return events
501+
502+ @property
503 def network_config(self):
504 """Return a network config dict for rendering ENI or netplan files."""
505 if self._network_config != sources.UNSET:
506@@ -122,11 +133,13 @@ class DataSourceOpenStack(openstack.SourceMixin, sources.DataSource):
507 False when unable to contact metadata service or when metadata
508 format is invalid or disabled.
509 """
510- oracle_considered = 'Oracle' in self.sys_cfg.get('datasource_list')
511+ oracle_considered = 'Oracle' in self.sys_cfg.get('datasource_list',
512+ {})
513 if not detect_openstack(accept_oracle=not oracle_considered):
514 return False
515
516- if self.perform_dhcp_setup: # Setup networking in init-local stage.
517+ if self.perform_dhcp_setup and not is_up(self.fallback_interface):
518+ # Setup networking in init-local stage.
519 try:
520 with EphemeralDHCPv4(self.fallback_interface):
521 results = util.log_time(
522diff --git a/cloudinit/sources/__init__.py b/cloudinit/sources/__init__.py
523index e6966b3..00b88d4 100644
524--- a/cloudinit/sources/__init__.py
525+++ b/cloudinit/sources/__init__.py
526@@ -158,15 +158,6 @@ class DataSource(object):
527 url_timeout = 10 # timeout for each metadata url read attempt
528 url_retries = 5 # number of times to retry url upon 404
529
530- # The datasource defines a set of supported EventTypes during which
531- # the datasource can react to changes in metadata and regenerate
532- # network configuration on metadata changes.
533- # A datasource which supports writing network config on each system boot
534- # would call update_events['network'].add(EventType.BOOT).
535-
536- # Default: generate network config on new instance id (first boot).
537- update_events = {'network': set([EventType.BOOT_NEW_INSTANCE])}
538-
539 # N-tuple listing default values for any metadata-related class
540 # attributes cached on an instance by a process_data runs. These attribute
541 # values are reset via clear_cached_attrs during any update_metadata call.
542@@ -176,6 +167,7 @@ class DataSource(object):
543 ('vendordata', None), ('vendordata_raw', None))
544
545 _dirty_cache = False
546+ _update_events = {}
547
548 # N-tuple of keypaths or keynames redact from instance-data.json for
549 # non-root users
550@@ -589,6 +581,24 @@ class DataSource(object):
551 def get_package_mirror_info(self):
552 return self.distro.get_package_mirror_info(data_source=self)
553
554+ # The datasource defines a set of supported EventTypes during which
555+ # the datasource can react to changes in metadata and regenerate
556+ # network configuration on metadata changes.
557+ # A datasource which supports writing network config on each system boot
558+ # would call update_events['network'].add(EventType.BOOT).
559+
560+ # Default: generate network config on new instance id (first boot).
561+ @property
562+ def update_events(self):
563+ if not self._update_events:
564+ self._update_events = {'network':
565+ set([EventType.BOOT_NEW_INSTANCE])}
566+ return self._update_events
567+
568+ @update_events.setter
569+ def update_events(self, events):
570+ self._update_events.update(events)
571+
572 def update_metadata(self, source_event_types):
573 """Refresh cached metadata if the datasource supports this event.
574
575diff --git a/cloudinit/stages.py b/cloudinit/stages.py
576index 8a06412..90c5c76 100644
577--- a/cloudinit/stages.py
578+++ b/cloudinit/stages.py
579@@ -22,9 +22,8 @@ from cloudinit.handlers.cloud_config import CloudConfigPartHandler
580 from cloudinit.handlers.jinja_template import JinjaTemplatePartHandler
581 from cloudinit.handlers.shell_script import ShellScriptPartHandler
582 from cloudinit.handlers.upstart_job import UpstartJobPartHandler
583-
584-from cloudinit.event import EventType
585-
586+from cloudinit.event import (
587+ EventType, get_allowed_events, get_update_events_config)
588 from cloudinit import cloud
589 from cloudinit import config
590 from cloudinit import distros
591@@ -644,7 +643,48 @@ class Init(object):
592 return (ncfg, loc)
593 return (self.distro.generate_fallback_config(), "fallback")
594
595+ def update_event_allowed(self, event_source_type, scope=None):
596+ # convert ds events to config
597+ ds_config = get_update_events_config(self.datasource.update_events)
598+ LOG.debug('Datasource updates cfg: %s', ds_config)
599+
600+ sys_config = self.cfg.get('updates', {})
601+ LOG.debug('System updates cfg: %s', sys_config)
602+
603+ allowed = get_allowed_events(sys_config, ds_config)
604+ LOG.debug('Allowable update events: %s', allowed)
605+
606+ if not scope:
607+ scopes = [allowed.keys()]
608+ else:
609+ scopes = [scope]
610+ LOG.debug('Possible scopes for this event: %s', scopes)
611+
612+ for evt_scope in scopes:
613+ if event_source_type in allowed.get(evt_scope, []):
614+ LOG.debug('Event Allowed: scope=%s EventType=%s',
615+ evt_scope, event_source_type)
616+ return True
617+
618+ LOG.debug('Event Denied: scopes=%s EventType=%s',
619+ scopes, event_source_type)
620+ return False
621+
622 def apply_network_config(self, bring_up):
623+ apply_network = True
624+ if self.datasource is not NULL_DATA_SOURCE:
625+ if not self.is_new_instance():
626+ if self.update_event_allowed(EventType.BOOT, scope='network'):
627+ if not self.datasource.update_metadata([EventType.BOOT]):
628+ LOG.debug(
629+ "No network config applied. Datasource failed"
630+ " update metadata on '%s' event", EventType.BOOT)
631+ apply_network = False
632+ else:
633+ LOG.debug("No network config applied. "
634+ "'%s' event not allowed", EventType.BOOT)
635+ apply_network = False
636+
637 netcfg, src = self._find_networking_config()
638 if netcfg is None:
639 LOG.info("network config is disabled by %s", src)
640@@ -656,14 +696,8 @@ class Init(object):
641 except Exception as e:
642 LOG.warning("Failed to rename devices: %s", e)
643
644- if self.datasource is not NULL_DATA_SOURCE:
645- if not self.is_new_instance():
646- if not self.datasource.update_metadata([EventType.BOOT]):
647- LOG.debug(
648- "No network config applied. Neither a new instance"
649- " nor datasource network update on '%s' event",
650- EventType.BOOT)
651- return
652+ if not apply_network:
653+ return
654
655 LOG.info("Applying network configuration from %s bringup=%s: %s",
656 src, bring_up, netcfg)
657diff --git a/cloudinit/tests/test_event.py b/cloudinit/tests/test_event.py
658new file mode 100644
659index 0000000..ad81deb
660--- /dev/null
661+++ b/cloudinit/tests/test_event.py
662@@ -0,0 +1,75 @@
663+# This file is part of cloud-init. See LICENSE file for license information.
664+
665+"""Tests related to cloudinit.event module."""
666+
667+import copy
668+import random
669+import string
670+
671+from cloudinit.event import (EventType,
672+ EventNameMap,
673+ get_allowed_events,
674+ get_update_events_config)
675+
676+from cloudinit.tests.helpers import CiTestCase
677+
678+
679+def rand_event_names():
680+ return [random.choice(list(EventNameMap.keys()))
681+ for x in range(len(EventNameMap.keys()))]
682+
683+
684+def rand_string(size=6, chars=string.ascii_lowercase):
685+ return ''.join(random.choice(chars) for x in range(size))
686+
687+
688+class TestEvent(CiTestCase):
689+ with_logs = True
690+
691+ DEFAULT_UPDATE_EVENTS = {'network': set([EventType.BOOT_NEW_INSTANCE]),
692+ 'storage': set([])}
693+ DEFAULT_UPDATES_CONFIG = {'policy-version': 1,
694+ 'network': {'when': ['boot-new-instance']},
695+ 'storage': {'when': []}}
696+
697+ def test_events_to_config(self):
698+ """validate default update_events dictionary maps to default policy"""
699+ events = copy.deepcopy(self.DEFAULT_UPDATE_EVENTS)
700+ config = get_update_events_config(events)
701+
702+ for scope, events in events.items():
703+ self.assertIn(scope, config)
704+ for evt in events:
705+ self.assertIn(evt, EventNameMap)
706+ self.assertIn(EventNameMap.get(evt),
707+ config.get(scope).get('when'))
708+
709+ self.assertEqual(sorted(config),
710+ sorted(self.DEFAULT_UPDATES_CONFIG))
711+
712+ def test_get_allowed_events_defaults_filter_datasource(self):
713+ ds_config = {
714+ 'policy-version': 1,
715+ 'network': {'when': rand_event_names()},
716+ 'storage': {'when': rand_event_names()},
717+ }
718+ allowed = get_allowed_events(self.DEFAULT_UPDATES_CONFIG, ds_config)
719+
720+ # system config filters out ds capabilities
721+ self.assertEqual(sorted(allowed), sorted(self.DEFAULT_UPDATE_EVENTS))
722+
723+ def test_get_allowed_events_uses_system_config_scopes(self):
724+ ds_config = {
725+ 'policy-version': 1,
726+ 'network': {'when': rand_event_names()},
727+ 'storage': {'when': rand_event_names()},
728+ }
729+ rand_scope = rand_string()
730+ rand_events = rand_event_names()
731+ sys_config = {'policy-version': 1, rand_scope: {'when': rand_events}}
732+
733+ self.assertNotIn(rand_scope, ds_config)
734+ allowed = get_allowed_events(sys_config, ds_config)
735+ self.assertIn(rand_scope, allowed)
736+
737+# vi: ts=4 expandtab
738diff --git a/cloudinit/tests/test_stages.py b/cloudinit/tests/test_stages.py
739index 94b6b25..037ca2f 100644
740--- a/cloudinit/tests/test_stages.py
741+++ b/cloudinit/tests/test_stages.py
742@@ -47,6 +47,10 @@ class TestInit(CiTestCase):
743 'distro': 'ubuntu', 'paths': {'cloud_dir': self.tmpdir,
744 'run_dir': self.tmpdir}}}
745 self.init.datasource = FakeDataSource(paths=self.init.paths)
746+ self.init.datasource.update_events = {
747+ 'network': set([EventType.BOOT_NEW_INSTANCE])}
748+ self.add_patch('cloudinit.stages.get_allowed_events', 'mock_allowed',
749+ return_value=self.init.datasource.update_events)
750
751 def test_wb__find_networking_config_disabled(self):
752 """find_networking_config returns no config when disabled."""
753@@ -200,11 +204,10 @@ class TestInit(CiTestCase):
754 self.init._find_networking_config = fake_network_config
755 self.init.apply_network_config(True)
756 self.init.distro.apply_network_config_names.assert_called_with(net_cfg)
757+ self.assertIn("No network config applied. "
758+ "'%s' event not allowed" % EventType.BOOT,
759+ self.logs.getvalue())
760 self.init.distro.apply_network_config.assert_not_called()
761- self.assertIn(
762- 'No network config applied. Neither a new instance'
763- " nor datasource network update on '%s' event" % EventType.BOOT,
764- self.logs.getvalue())
765
766 @mock.patch('cloudinit.distros.ubuntu.Distro')
767 def test_apply_network_on_datasource_allowed_event(self, m_ubuntu):
768@@ -222,10 +225,28 @@ class TestInit(CiTestCase):
769
770 self.init._find_networking_config = fake_network_config
771 self.init.datasource = FakeDataSource(paths=self.init.paths)
772- self.init.datasource.update_events = {'network': [EventType.BOOT]}
773+ self.init.datasource.update_events = {'network': set([EventType.BOOT])}
774 self.init.apply_network_config(True)
775 self.init.distro.apply_network_config_names.assert_called_with(net_cfg)
776 self.init.distro.apply_network_config.assert_called_with(
777 net_cfg, bring_up=True)
778
779+ def test_update_events_allowed_true_on_supported_event(self):
780+ """Update Event Allowed True if source_event supported"""
781+ self.assertTrue(
782+ self.init.update_event_allowed(EventType.BOOT_NEW_INSTANCE,
783+ scope='network'))
784+
785+ def test_update_events_allowed_false_on_unsupported_event(self):
786+ """Update Event Allowed False if source_event supported"""
787+ self.assertFalse(
788+ self.init.update_event_allowed(EventType.BOOT,
789+ scope='network'))
790+
791+ def test_update_events_allowed_false_on_unknown_scope(self):
792+ """Update Event Allowed False if source_event supported"""
793+ self.assertFalse(
794+ self.init.update_event_allowed(EventType.BOOT, scope='xkcd'))
795+
796+
797 # vi: ts=4 expandtab
798diff --git a/cloudinit/version.py b/cloudinit/version.py
799index 844a02e..1993983 100644
800--- a/cloudinit/version.py
801+++ b/cloudinit/version.py
802@@ -12,6 +12,8 @@ FEATURES = [
803 'NETWORK_CONFIG_V1',
804 # supports network config version 2 (netplan)
805 'NETWORK_CONFIG_V2',
806+ # supports network config update via hotplug
807+ 'NETWORK_HOTPLUG',
808 ]
809
810
811diff --git a/config/cloud.cfg.d/10_updates_policy.cfg b/config/cloud.cfg.d/10_updates_policy.cfg
812new file mode 100644
813index 0000000..245a2d8
814--- /dev/null
815+++ b/config/cloud.cfg.d/10_updates_policy.cfg
816@@ -0,0 +1,6 @@
817+# default policy for cloud-init for when to update system config
818+# such as network and storage configurations
819+updates:
820+ policy-version: 1
821+ network:
822+ when: ['boot-new-instance']
823diff --git a/doc/rtd/index.rst b/doc/rtd/index.rst
824index 20a99a3..23b848f 100644
825--- a/doc/rtd/index.rst
826+++ b/doc/rtd/index.rst
827@@ -36,6 +36,7 @@ initialization of a cloud instance.
828 topics/examples.rst
829 topics/boot.rst
830 topics/datasources.rst
831+ topics/events.rst
832 topics/logging.rst
833 topics/modules.rst
834 topics/merging.rst
835diff --git a/doc/rtd/topics/capabilities.rst b/doc/rtd/topics/capabilities.rst
836index 0d8b894..a40c998 100644
837--- a/doc/rtd/topics/capabilities.rst
838+++ b/doc/rtd/topics/capabilities.rst
839@@ -41,6 +41,8 @@ Currently defined feature names include:
840 see :ref:`network_config_v1` documentation for examples.
841 - ``NETWORK_CONFIG_V2`` support for v2 networking configuration,
842 see :ref:`network_config_v2` documentation for examples.
843+ - ``NETWORK_HOTPLUG`` support for configuring network on hotplug,
844+ see :ref:`events` documentation for more information.
845
846
847 CLI Interface
848@@ -237,6 +239,10 @@ likely be promoted to top-level subcommands when stable.
849 containing the jinja template header ``## template: jinja`` and renders
850 that content with any instance-data.json variables present.
851
852+ * ``cloud-init devel hotplug-hook``: Command called to handle when a new
853+ device has been added to the system dynamically. Typically called from
854+ a Linux udev rule which provides device specific values which are passed
855+ as command line options.
856
857 .. _cli_clean:
858
859diff --git a/doc/rtd/topics/datasources/smartos.rst b/doc/rtd/topics/datasources/smartos.rst
860index cb9a128..42841f6 100644
861--- a/doc/rtd/topics/datasources/smartos.rst
862+++ b/doc/rtd/topics/datasources/smartos.rst
863@@ -1,7 +1,7 @@
864 .. _datasource_smartos:
865
866-SmartOS Datasource
867-==================
868+SmartOS
869+=======
870
871 This datasource finds metadata and user-data from the SmartOS virtualization
872 platform (i.e. Joyent).
873diff --git a/doc/rtd/topics/events.rst b/doc/rtd/topics/events.rst
874new file mode 100644
875index 0000000..db8f657
876--- /dev/null
877+++ b/doc/rtd/topics/events.rst
878@@ -0,0 +1,150 @@
879+******************
880+Events and Updates
881+******************
882+
883+- Events
884+- Datasource Event Support
885+- Configuring Event Updates
886+- Examples
887+
888+.. _events:
889+
890+Events
891+======
892+
893+`Cloud-init`_ 's will fetch and apply cloud and user data configuration
894+upon serveral event types. The two most common events for `Cloud-init`_
895+are when an instance first boots and any subsequent boot thereafter (reboot).
896+In addition to boot events, `Cloud-init`_ users and vendors are interested
897+in when devices are added. `Cloud-init`_ currently supports the following
898+event types:
899+
900+- **BOOT_NEW_INSTANCE**: ``New instance first boot``
901+- **BOOT**: ``Any system boot other than 'BOOT_NEW_INSTANCE'``
902+- **HOTPLUG**: ``Dynamic add of a system device``
903+
904+Future work will include infrastructure and support for the following
905+events:
906+
907+- **METADATA_CHANGE**: ``An instance's metadata has change``
908+- **USER_REQUEST**: ``Directed request to update``
909+
910+Datasource Event Support
911+========================
912+
913+All :ref:`datasources` by default support the ``BOOT_NEW_INSTANCE`` event.
914+Each Datasource will provide a set of events that it is capable of handling.
915+Datasources may not support all event types. In some cases a system
916+may be configured to allow a particular event but may be running on
917+a platform who's datasource cannot support the event.
918+
919+.. table::
920+ :widths: auto
921+
922+ +-----------------------------+------------------+
923+ | Datasource | Supported Events |
924+ +=============================+==================+
925+ | :ref:`datasource_azure` | BOOT |
926+ +-----------------------------+------------------+
927+ | :ref:`datasource_openstack` | BOOT, HOTPLUG |
928+ +-----------------------------+------------------+
929+ | :ref:`datasource_smartos` | BOOT |
930+ +-----------------------------+------------------+
931+
932+
933+Configuring Event Updates
934+=========================
935+
936+`Cloud-init`_ has a default updates policy to handle new instance
937+events always. Vendors may want an instance to handle additional
938+events. Users have the final say and may provide update configuration
939+which can be used to enable or disable handling of specific events.
940+
941+updates
942+~~~~~~~
943+Specify update policy configuration for cloud-init to define which
944+events are allowed to be handled. This is separate from whether a
945+particular platform or datasource has the capability for such events.
946+
947+**policy-version**: *<Latest policy version, currently 1>*
948+
949+The ``policy-version`` value specifies the updates configuration
950+version number. Current version is 1, future versions may modify
951+the configuation structure.
952+
953+**scope**: *<name of the scope for event policy>*
954+
955+The ``scope`` value is a string which defines under which domain do the
956+event occur. Currently there are two known scopes: ``network`` and
957+``storage``. Scopes are defined by convention but arbitrary values
958+can be used.
959+
960+**when**: *<list of events to handle for a particular scope>*
961+
962+Each ``scope`` requires a ``when`` element to specify which events
963+are to allowed to be handled.
964+
965+
966+Examples
967+========
968+
969+default
970+~~~~~~~
971+
972+The default policy for handling new instances is found in
973+/etc/cloud/cloud.cfg.d/10_updates_policy.cfg
974+
975+.. code-block:: shell-session
976+
977+ # default policy for cloud-init for when to update system config
978+ # such as network and storage configurations
979+ updates:
980+ policy-version: 1
981+ network:
982+ when: ['boot-new-instance']
983+
984+This default policy indicates that whenever cloud-init generates a
985+``BOOT_NEW_INSTANCE`` event that the ``network`` scope will be updated.
986+This results in cloud-init applying network configuration when booting
987+a new instance.
988+
989+.. note::
990+ Removing 'boot-new-instance' from the policy will cause issues when
991+ capturing images and booting them else where as the network config
992+ will remain static.
993+
994+apply network config every boot
995+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
996+On each firsboot and every boot cloud-init will apply network configuration
997+found in the datasource.
998+
999+.. code-block:: shell-session
1000+
1001+ # apply network config on every boot
1002+ updates:
1003+ policy-version: 1
1004+ network:
1005+ when: ['boot-new-instance', 'boot']
1006+
1007+apply network config on hotplug
1008+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1009+Apply network configuration from the datasource on first boot, each boot
1010+thereafter and when new network devices are dynamically added.
1011+
1012+
1013+.. code-block:: shell-session
1014+
1015+ # apply network config on every boot and hotplug
1016+ updates:
1017+ policy-version: 1
1018+ network:
1019+ when: ['boot-new-instance', 'boot', 'hotplug']
1020+
1021+.. note::
1022+ When enabling hotplug, it's best practice to also enable the boot event.
1023+ In the case of a device removal, the network configuration will be
1024+ reconfigure on the very next boot.
1025+
1026+
1027+.. _Cloud-init: https://launchpad.net/cloud-init
1028+.. vi: textwidth=78
1029diff --git a/setup.py b/setup.py
1030index ea37efc..7b76bc4 100755
1031--- a/setup.py
1032+++ b/setup.py
1033@@ -138,6 +138,7 @@ INITSYS_FILES = {
1034 'systemd': [render_tmpl(f)
1035 for f in (glob('systemd/*.tmpl') +
1036 glob('systemd/*.service') +
1037+ glob('systemd/*.socket') +
1038 glob('systemd/*.target')) if is_f(f)],
1039 'systemd.generators': [f for f in glob('systemd/*-generator') if is_f(f)],
1040 'upstart': [f for f in glob('upstart/*') if is_f(f)],
1041@@ -243,6 +244,7 @@ data_files = [
1042 (ETC + '/cloud/cloud.cfg.d', glob('config/cloud.cfg.d/*')),
1043 (ETC + '/cloud/templates', glob('templates/*')),
1044 (USR_LIB_EXEC + '/cloud-init', ['tools/ds-identify',
1045+ 'tools/hook-hotplug',
1046 'tools/uncloud-init',
1047 'tools/write-ssh-key-fingerprints']),
1048 (USR + '/share/doc/cloud-init', [f for f in glob('doc/*') if is_f(f)]),
1049diff --git a/systemd/cloud-init-hotplugd.service b/systemd/cloud-init-hotplugd.service
1050new file mode 100644
1051index 0000000..6f231cd
1052--- /dev/null
1053+++ b/systemd/cloud-init-hotplugd.service
1054@@ -0,0 +1,11 @@
1055+[Unit]
1056+Description=cloud-init hotplug hook daemon
1057+After=cloud-init-hotplugd.socket
1058+
1059+[Service]
1060+Type=simple
1061+ExecStart=/bin/bash -c 'read args <&3; echo "args=$args"; \
1062+ exec /usr/bin/cloud-init devel hotplug-hook $args; \
1063+ exit 0'
1064+SyslogIdentifier=cloud-init-hotplugd
1065+TimeoutStopSec=5
1066diff --git a/systemd/cloud-init-hotplugd.socket b/systemd/cloud-init-hotplugd.socket
1067new file mode 100644
1068index 0000000..f8f1048
1069--- /dev/null
1070+++ b/systemd/cloud-init-hotplugd.socket
1071@@ -0,0 +1,8 @@
1072+[Unit]
1073+Description=cloud-init hotplug hook socket
1074+
1075+[Socket]
1076+ListenFIFO=/run/cloud-init/hook-hotplug-cmd
1077+
1078+[Install]
1079+WantedBy=cloud-init.target
1080diff --git a/tools/hook-hotplug b/tools/hook-hotplug
1081new file mode 100755
1082index 0000000..469c45b
1083--- /dev/null
1084+++ b/tools/hook-hotplug
1085@@ -0,0 +1,26 @@
1086+#!/bin/bash
1087+# This file is part of cloud-init. See LICENSE file for license information.
1088+
1089+# This script checks if cloud-init has hotplug hooked and if
1090+# cloud-init has finished; if so invoke cloud-init hotplug-hook
1091+
1092+is_finished() {
1093+ [ -e /run/cloud-init/result.json ]
1094+}
1095+
1096+if is_finished; then
1097+ # only hook pci devices at this time
1098+ case "${DEVPATH}" in
1099+ /devices/pci*)
1100+ # open cloud-init's hotplug-hook fifo rw
1101+ exec 3<>/run/cloud-init/hook-hotplug-cmd
1102+ env_params=(
1103+ --devpath="${DEVPATH}"
1104+ --subsystem="${SUBSYSTEM}"
1105+ --udevaction="${ACTION}"
1106+ )
1107+ # write params to cloud-init's hotplug-hook fifo
1108+ echo "--hotplug-debug ${env_params[@]}" >&3
1109+ ;;
1110+ esac
1111+fi
1112diff --git a/udev/10-cloud-init-hook-hotplug.rules b/udev/10-cloud-init-hook-hotplug.rules
1113new file mode 100644
1114index 0000000..74324f4
1115--- /dev/null
1116+++ b/udev/10-cloud-init-hook-hotplug.rules
1117@@ -0,0 +1,5 @@
1118+# Handle device adds only
1119+ACTION!="add", GOTO="cloudinit_end"
1120+LABEL="cloudinit_hook"
1121+SUBSYSTEM=="net|block", RUN+="/usr/lib/cloud-init/hook-hotplug"
1122+LABEL="cloudinit_end"

Subscribers

People subscribed via source and target branches