Merge ~smoser/cloud-init:azure-dhcp into cloud-init:master

Proposed by Scott Moser
Status: Merged
Approved by: Scott Moser
Approved revision: d010217d6961756e8c3fe95c274c2b59765c8cf9
Merged at revision: 648dbbf6b090c81e989f1ab70bf99f4de16a6a70
Proposed branch: ~smoser/cloud-init:azure-dhcp
Merge into: cloud-init:master
Diff against target: 552 lines (+277/-42)
12 files modified
cloudinit/atomic_helper.py (+25/-0)
cloudinit/cmd/main.py (+27/-18)
cloudinit/dhclient_hook.py (+50/-0)
cloudinit/sources/DataSourceAzure.py (+10/-5)
cloudinit/sources/helpers/azure.py (+87/-12)
config/cloud.cfg (+6/-0)
doc/sources/azure/README.rst (+28/-4)
setup.py (+2/-0)
tests/unittests/test_datasource/test_azure_helper.py (+12/-3)
tools/hook-dhclient (+9/-0)
tools/hook-network-manager (+9/-0)
tools/hook-rhel.sh (+12/-0)
Reviewer Review Type Date Requested Status
Brent Baude Pending
cloud-init Commiters Pending
Review via email: mp+302604@code.launchpad.net

Description of the change

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

Brent,
I've made a few small changes to what we had discussed in the bzr merge proposal.
I think this is good to go, but your test would be wonderful.

Revision history for this message
Brent Baude (bbaude) wrote :

Scott,

Looks good. Sniff tested on RHEL and Ubuntu on Azure.

There was an error fetching revisions from git servers. Please try again in a few minutes. If the problem persists, contact Launchpad support.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/atomic_helper.py b/cloudinit/atomic_helper.py
2new file mode 100644
3index 0000000..15319f7
4--- /dev/null
5+++ b/cloudinit/atomic_helper.py
6@@ -0,0 +1,25 @@
7+#!/usr/bin/python
8+# vi: ts=4 expandtab
9+
10+import json
11+import os
12+import tempfile
13+
14+
15+def atomic_write_file(path, content, mode='w'):
16+ tf = None
17+ try:
18+ tf = tempfile.NamedTemporaryFile(dir=os.path.dirname(path),
19+ delete=False, mode=mode)
20+ tf.write(content)
21+ tf.close()
22+ os.rename(tf.name, path)
23+ except Exception as e:
24+ if tf is not None:
25+ os.unlink(tf.name)
26+ raise e
27+
28+
29+def atomic_write_json(path, data):
30+ return atomic_write_file(path, json.dumps(data, indent=1,
31+ sort_keys=True) + "\n")
32diff --git a/cloudinit/cmd/main.py b/cloudinit/cmd/main.py
33index 63621c1..ba22b16 100644
34--- a/cloudinit/cmd/main.py
35+++ b/cloudinit/cmd/main.py
36@@ -25,7 +25,6 @@ import argparse
37 import json
38 import os
39 import sys
40-import tempfile
41 import time
42 import traceback
43
44@@ -47,6 +46,10 @@ from cloudinit.reporting import events
45 from cloudinit.settings import (PER_INSTANCE, PER_ALWAYS, PER_ONCE,
46 CLOUD_CONFIG)
47
48+from cloudinit.atomic_helper import atomic_write_json
49+
50+from cloudinit.dhclient_hook import LogDhclient
51+
52
53 # Pretty little cheetah formatted welcome message template
54 WELCOME_MSG_TPL = ("Cloud-init v. ${version} running '${action}' at "
55@@ -452,22 +455,10 @@ def main_single(name, args):
56 return 0
57
58
59-def atomic_write_file(path, content, mode='w'):
60- tf = None
61- try:
62- tf = tempfile.NamedTemporaryFile(dir=os.path.dirname(path),
63- delete=False, mode=mode)
64- tf.write(content)
65- tf.close()
66- os.rename(tf.name, path)
67- except Exception as e:
68- if tf is not None:
69- os.unlink(tf.name)
70- raise e
71-
72-
73-def atomic_write_json(path, data):
74- return atomic_write_file(path, json.dumps(data, indent=1) + "\n")
75+def dhclient_hook(name, args):
76+ record = LogDhclient(args)
77+ record.check_hooks_dir()
78+ record.record()
79
80
81 def status_wrapper(name, args, data_d=None, link_d=None):
82@@ -627,7 +618,6 @@ def main(sysv_args=None):
83 # This subcommand allows you to run a single module
84 parser_single = subparsers.add_parser('single',
85 help=('run a single module '))
86- parser_single.set_defaults(action=('single', main_single))
87 parser_single.add_argument("--name", '-n', action="store",
88 help="module name to run",
89 required=True)
90@@ -644,6 +634,16 @@ def main(sysv_args=None):
91 ' pass to this module'))
92 parser_single.set_defaults(action=('single', main_single))
93
94+ parser_dhclient = subparsers.add_parser('dhclient-hook',
95+ help=('run the dhclient hook'
96+ 'to record network info'))
97+ parser_dhclient.add_argument("net_action",
98+ help=('action taken on the interface'))
99+ parser_dhclient.add_argument("net_interface",
100+ help=('the network interface being acted'
101+ ' upon'))
102+ parser_dhclient.set_defaults(action=('dhclient_hook', dhclient_hook))
103+
104 args = parser.parse_args(args=sysv_args)
105
106 try:
107@@ -677,9 +677,18 @@ def main(sysv_args=None):
108 "running single module %s" % args.name)
109 report_on = args.report
110
111+ elif name == 'dhclient_hook':
112+ rname, rdesc = ("dhclient-hook",
113+ "running dhclient-hook module")
114+
115 args.reporter = events.ReportEventStack(
116 rname, rdesc, reporting_enabled=report_on)
117+
118 with args.reporter:
119 return util.log_time(
120 logfunc=LOG.debug, msg="cloud-init mode '%s'" % name,
121 get_uptime=True, func=functor, args=(name, args))
122+
123+
124+if __name__ == '__main__':
125+ main(sys.argv)
126diff --git a/cloudinit/dhclient_hook.py b/cloudinit/dhclient_hook.py
127new file mode 100644
128index 0000000..9dcbe39
129--- /dev/null
130+++ b/cloudinit/dhclient_hook.py
131@@ -0,0 +1,50 @@
132+#!/usr/bin/python
133+# vi: ts=4 expandtab
134+
135+import os
136+
137+from cloudinit.atomic_helper import atomic_write_json
138+from cloudinit import log as logging
139+from cloudinit import stages
140+
141+LOG = logging.getLogger(__name__)
142+
143+
144+class LogDhclient(object):
145+
146+ def __init__(self, cli_args):
147+ self.hooks_dir = self._get_hooks_dir()
148+ self.net_interface = cli_args.net_interface
149+ self.net_action = cli_args.net_action
150+ self.hook_file = os.path.join(self.hooks_dir,
151+ self.net_interface + ".json")
152+
153+ @staticmethod
154+ def _get_hooks_dir():
155+ i = stages.Init()
156+ return os.path.join(i.paths.get_runpath(), 'dhclient.hooks')
157+
158+ def check_hooks_dir(self):
159+ if not os.path.exists(self.hooks_dir):
160+ os.makedirs(self.hooks_dir)
161+ else:
162+ # If the action is down and the json file exists, we need to
163+ # delete the file
164+ if self.net_action is 'down' and os.path.exists(self.hook_file):
165+ os.remove(self.hook_file)
166+
167+ @staticmethod
168+ def get_vals(info):
169+ new_info = {}
170+ for k, v in info.items():
171+ if k.startswith("DHCP4_") or k.startswith("new_"):
172+ key = (k.replace('DHCP4_', '').replace('new_', '')).lower()
173+ new_info[key] = v
174+ return new_info
175+
176+ def record(self):
177+ envs = os.environ
178+ if self.hook_file is None:
179+ return
180+ atomic_write_json(self.hook_file, self.get_vals(envs))
181+ LOG.debug("Wrote dhclient options in %s", self.hook_file)
182diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py
183index 8c7e867..a251fe0 100644
184--- a/cloudinit/sources/DataSourceAzure.py
185+++ b/cloudinit/sources/DataSourceAzure.py
186@@ -20,18 +20,17 @@ import base64
187 import contextlib
188 import crypt
189 import fnmatch
190+from functools import partial
191 import os
192 import os.path
193 import time
194-import xml.etree.ElementTree as ET
195-
196 from xml.dom import minidom
197-
198-from cloudinit.sources.helpers.azure import get_metadata_from_fabric
199+import xml.etree.ElementTree as ET
200
201 from cloudinit import log as logging
202 from cloudinit.settings import PER_ALWAYS
203 from cloudinit import sources
204+from cloudinit.sources.helpers.azure import get_metadata_from_fabric
205 from cloudinit import util
206
207 LOG = logging.getLogger(__name__)
208@@ -107,6 +106,8 @@ def temporary_hostname(temp_hostname, cfg, hostname_command='hostname'):
209
210
211 class DataSourceAzureNet(sources.DataSource):
212+ FALLBACK_LEASE = '/var/lib/dhcp/dhclient.eth0.leases'
213+
214 def __init__(self, sys_cfg, distro, paths):
215 sources.DataSource.__init__(self, sys_cfg, distro, paths)
216 self.seed_dir = os.path.join(paths.seed_dir, 'azure')
217@@ -115,6 +116,8 @@ class DataSourceAzureNet(sources.DataSource):
218 self.ds_cfg = util.mergemanydict([
219 util.get_cfg_by_path(sys_cfg, DS_CFG_PATH, {}),
220 BUILTIN_DS_CONFIG])
221+ self.dhclient_lease_file = self.paths.cfgs.get('dhclient_lease',
222+ self.FALLBACK_LEASE)
223
224 def __str__(self):
225 root = sources.DataSource.__str__(self)
226@@ -226,7 +229,9 @@ class DataSourceAzureNet(sources.DataSource):
227 write_files(ddir, files, dirmode=0o700)
228
229 if self.ds_cfg['agent_command'] == '__builtin__':
230- metadata_func = get_metadata_from_fabric
231+ metadata_func = partial(get_metadata_from_fabric,
232+ fallback_lease_file=self.
233+ dhclient_lease_file)
234 else:
235 metadata_func = self.get_metadata_from_agent
236 try:
237diff --git a/cloudinit/sources/helpers/azure.py b/cloudinit/sources/helpers/azure.py
238index 63ccf10..6e43440 100644
239--- a/cloudinit/sources/helpers/azure.py
240+++ b/cloudinit/sources/helpers/azure.py
241@@ -1,3 +1,4 @@
242+import json
243 import logging
244 import os
245 import re
246@@ -6,6 +7,7 @@ import struct
247 import tempfile
248 import time
249
250+from cloudinit import stages
251 from contextlib import contextmanager
252 from xml.etree import ElementTree
253
254@@ -187,19 +189,32 @@ class WALinuxAgentShim(object):
255 ' </Container>',
256 '</Health>'])
257
258- def __init__(self):
259+ def __init__(self, fallback_lease_file=None):
260 LOG.debug('WALinuxAgentShim instantiated...')
261- self.endpoint = self.find_endpoint()
262+ self.dhcpoptions = None
263+ self._endpoint = None
264 self.openssl_manager = None
265 self.values = {}
266+ self.lease_file = fallback_lease_file
267
268 def clean_up(self):
269 if self.openssl_manager is not None:
270 self.openssl_manager.clean_up()
271
272 @staticmethod
273- def get_ip_from_lease_value(lease_value):
274- unescaped_value = lease_value.replace('\\', '')
275+ def _get_hooks_dir():
276+ _paths = stages.Init()
277+ return os.path.join(_paths.paths.get_runpath(), "dhclient.hooks")
278+
279+ @property
280+ def endpoint(self):
281+ if self._endpoint is None:
282+ self._endpoint = self.find_endpoint(self.lease_file)
283+ return self._endpoint
284+
285+ @staticmethod
286+ def get_ip_from_lease_value(fallback_lease_value):
287+ unescaped_value = fallback_lease_value.replace('\\', '')
288 if len(unescaped_value) > 4:
289 hex_string = ''
290 for hex_pair in unescaped_value.split(':'):
291@@ -213,15 +228,75 @@ class WALinuxAgentShim(object):
292 return socket.inet_ntoa(packed_bytes)
293
294 @staticmethod
295- def find_endpoint():
296- LOG.debug('Finding Azure endpoint...')
297- content = util.load_file('/var/lib/dhcp/dhclient.eth0.leases')
298- value = None
299+ def _get_value_from_leases_file(fallback_lease_file):
300+ leases = []
301+ content = util.load_file(fallback_lease_file)
302+ LOG.debug("content is {}".format(content))
303 for line in content.splitlines():
304 if 'unknown-245' in line:
305- value = line.strip(' ').split(' ', 2)[-1].strip(';\n"')
306+ # Example line from Ubuntu
307+ # option unknown-245 a8:3f:81:10;
308+ leases.append(line.strip(' ').split(' ', 2)[-1].strip(';\n"'))
309+ # Return the "most recent" one in the list
310+ if len(leases) < 1:
311+ return None
312+ else:
313+ return leases[-1]
314+
315+ @staticmethod
316+ def _load_dhclient_json():
317+ dhcp_options = {}
318+ hooks_dir = WALinuxAgentShim._get_hooks_dir()
319+ if not os.path.exists(hooks_dir):
320+ LOG.debug("%s not found.", hooks_dir)
321+ return None
322+ hook_files = [os.path.join(hooks_dir, x)
323+ for x in os.listdir(hooks_dir)]
324+ for hook_file in hook_files:
325+ try:
326+ name = os.path.basename(hook_file).replace('.json', '')
327+ dhcp_options[name] = json.loads(util.load_file((hook_file)))
328+ except ValueError:
329+ raise ValueError("%s is not valid JSON data", hook_file)
330+ return dhcp_options
331+
332+ @staticmethod
333+ def _get_value_from_dhcpoptions(dhcp_options):
334+ if dhcp_options is None:
335+ return None
336+ # the MS endpoint server is given to us as DHPC option 245
337+ _value = None
338+ for interface in dhcp_options:
339+ _value = dhcp_options[interface].get('unknown_245', None)
340+ if _value is not None:
341+ LOG.debug("Endpoint server found in dhclient options")
342+ break
343+ return _value
344+
345+ @staticmethod
346+ def find_endpoint(fallback_lease_file=None):
347+ LOG.debug('Finding Azure endpoint...')
348+ value = None
349+ # Option-245 stored in /run/cloud-init/dhclient.hooks/<ifc>.json
350+ # a dhclient exit hook that calls cloud-init-dhclient-hook
351+ dhcp_options = WALinuxAgentShim._load_dhclient_json()
352+ value = WALinuxAgentShim._get_value_from_dhcpoptions(dhcp_options)
353 if value is None:
354- raise ValueError('No endpoint found in DHCP config.')
355+ # Fallback and check the leases file if unsuccessful
356+ LOG.debug("Unable to find endpoint in dhclient logs. "
357+ " Falling back to check lease files")
358+ if fallback_lease_file is None:
359+ LOG.warn("No fallback lease file was specified.")
360+ value = None
361+ else:
362+ LOG.debug("Looking for endpoint in lease file %s",
363+ fallback_lease_file)
364+ value = WALinuxAgentShim._get_value_from_leases_file(
365+ fallback_lease_file)
366+
367+ if value is None:
368+ raise ValueError('No endpoint found.')
369+
370 endpoint_ip_address = WALinuxAgentShim.get_ip_from_lease_value(value)
371 LOG.debug('Azure endpoint found at %s', endpoint_ip_address)
372 return endpoint_ip_address
373@@ -271,8 +346,8 @@ class WALinuxAgentShim(object):
374 LOG.info('Reported ready to Azure fabric.')
375
376
377-def get_metadata_from_fabric():
378- shim = WALinuxAgentShim()
379+def get_metadata_from_fabric(fallback_lease_file=None):
380+ shim = WALinuxAgentShim(fallback_lease_file=fallback_lease_file)
381 try:
382 return shim.register_with_azure_and_fetch_data()
383 finally:
384diff --git a/config/cloud.cfg b/config/cloud.cfg
385index 2d7fb47..93ef342 100644
386--- a/config/cloud.cfg
387+++ b/config/cloud.cfg
388@@ -98,6 +98,7 @@ system_info:
389 cloud_dir: /var/lib/cloud/
390 templates_dir: /etc/cloud/templates/
391 upstart_dir: /etc/init/
392+ dhclient_lease:
393 package_mirrors:
394 - arches: [i386, amd64]
395 failsafe:
396@@ -114,3 +115,8 @@ system_info:
397 primary: http://ports.ubuntu.com/ubuntu-ports
398 security: http://ports.ubuntu.com/ubuntu-ports
399 ssh_svcname: ssh
400+datasource:
401+ Azure:
402+ set_hostname: False
403+ agent_command: __builtin__
404+
405diff --git a/doc/sources/azure/README.rst b/doc/sources/azure/README.rst
406index 8239d1f..48f3cc7 100644
407--- a/doc/sources/azure/README.rst
408+++ b/doc/sources/azure/README.rst
409@@ -9,10 +9,34 @@ Azure Platform
410 The azure cloud-platform provides initial data to an instance via an attached
411 CD formated in UDF. That CD contains a 'ovf-env.xml' file that provides some
412 information. Additional information is obtained via interaction with the
413-"endpoint". The ip address of the endpoint is advertised to the instance
414-inside of dhcp option 245. On ubuntu, that can be seen in
415-/var/lib/dhcp/dhclient.eth0.leases as a colon delimited hex value (example:
416-``option unknown-245 64:41:60:82;`` is 100.65.96.130)
417+"endpoint".
418+
419+To find the endpoint, we now leverage the dhcp client's ability to log its
420+known values on exit. The endpoint server is special DHCP option 245.
421+Depending on your networking stack, this can be done
422+by calling a script in /etc/dhcp/dhclient-exit-hooks or a file in
423+/etc/NetworkManager/dispatcher.d. Both of these call a sub-command
424+'dhclient_hook' of cloud-init itself. This sub-command will write the client
425+information in json format to /run/cloud-init/dhclient.hook/<interface>.json.
426+
427+In order for cloud-init to leverage this method to find the endpoint, the
428+cloud.cfg file must contain:
429+
430+datasource:
431+ Azure:
432+ set_hostname: False
433+ agent_command: __builtin__
434+
435+If those files are not available, the fallback is to check the leases file
436+for the endpoint server (again option 245).
437+
438+You can define the path to the lease file with the 'dhclient_lease' configuration
439+value under system_info: and paths:. For example:
440+
441+ dhclient_lease: /var/lib/dhcp/dhclient.eth0.leases
442+
443+If no configuration value is provided, the dhclient_lease value will fallback to
444+/var/lib/dhcp/dhclient.eth0.leases.
445
446 walinuxagent
447 ------------
448diff --git a/setup.py b/setup.py
449index 4abbb67..bbadd7b 100755
450--- a/setup.py
451+++ b/setup.py
452@@ -176,6 +176,8 @@ else:
453 (ETC + '/cloud', glob('config/*.cfg')),
454 (ETC + '/cloud/cloud.cfg.d', glob('config/cloud.cfg.d/*')),
455 (ETC + '/cloud/templates', glob('templates/*')),
456+ (ETC + '/NetworkManager/dispatcher.d/', ['tools/hook-network-manager']),
457+ (ETC + '/dhcp/dhclient-exit-hooks.d/', ['tools/hook-dhclient']),
458 (USR_LIB_EXEC + '/cloud-init', ['tools/uncloud-init',
459 'tools/write-ssh-key-fingerprints']),
460 (USR + '/share/doc/cloud-init', [f for f in glob('doc/*') if is_f(f)]),
461diff --git a/tests/unittests/test_datasource/test_azure_helper.py b/tests/unittests/test_datasource/test_azure_helper.py
462index 65202ff..64523e1 100644
463--- a/tests/unittests/test_datasource/test_azure_helper.py
464+++ b/tests/unittests/test_datasource/test_azure_helper.py
465@@ -54,13 +54,17 @@ class TestFindEndpoint(TestCase):
466 self.load_file = patches.enter_context(
467 mock.patch.object(azure_helper.util, 'load_file'))
468
469+ self.dhcp_options = patches.enter_context(
470+ mock.patch.object(azure_helper.WALinuxAgentShim,
471+ '_load_dhclient_json'))
472+
473 def test_missing_file(self):
474- self.load_file.side_effect = IOError
475- self.assertRaises(IOError,
476+ self.assertRaises(ValueError,
477 azure_helper.WALinuxAgentShim.find_endpoint)
478
479 def test_missing_special_azure_line(self):
480 self.load_file.return_value = ''
481+ self.dhcp_options.return_value = {'eth0': {'key': 'value'}}
482 self.assertRaises(ValueError,
483 azure_helper.WALinuxAgentShim.find_endpoint)
484
485@@ -72,13 +76,18 @@ class TestFindEndpoint(TestCase):
486 ' option unknown-245 {0};'.format(encoded_address),
487 '}'])
488
489+ def test_from_dhcp_client(self):
490+ self.dhcp_options.return_value = {"eth0": {"unknown_245": "5:4:3:2"}}
491+ self.assertEqual('5.4.3.2',
492+ azure_helper.WALinuxAgentShim.find_endpoint(None))
493+
494 def test_latest_lease_used(self):
495 encoded_addresses = ['5:4:3:2', '4:3:2:1']
496 file_content = '\n'.join([self._build_lease_content(encoded_address)
497 for encoded_address in encoded_addresses])
498 self.load_file.return_value = file_content
499 self.assertEqual(encoded_addresses[-1].replace(':', '.'),
500- azure_helper.WALinuxAgentShim.find_endpoint())
501+ azure_helper.WALinuxAgentShim.find_endpoint("foobar"))
502
503
504 class TestExtractIpAddressFromLeaseValue(TestCase):
505diff --git a/tools/hook-dhclient b/tools/hook-dhclient
506new file mode 100755
507index 0000000..d099979
508--- /dev/null
509+++ b/tools/hook-dhclient
510@@ -0,0 +1,9 @@
511+#!/bin/sh
512+# This script writes DHCP lease information into the cloud-init run directory
513+# It is sourced, not executed. For more information see dhclient-script(8).
514+
515+case "$reason" in
516+ BOUND) cloud-init dhclient-hook up "$interface";;
517+ DOWN|RELEASE|REBOOT|STOP|EXPIRE)
518+ cloud-init dhclient-hook down "$interface";;
519+esac
520diff --git a/tools/hook-network-manager b/tools/hook-network-manager
521new file mode 100755
522index 0000000..447b134
523--- /dev/null
524+++ b/tools/hook-network-manager
525@@ -0,0 +1,9 @@
526+#!/bin/sh
527+# This script hooks into NetworkManager(8) via its scripts
528+# arguments are 'interface-name' and 'action'
529+#
530+
531+case "$1:$2" in
532+ *:up) exec cloud-init dhclient-hook up "$1";;
533+ *:down) exec cloud-init dhclient-hook down "$1";;
534+esac
535diff --git a/tools/hook-rhel.sh b/tools/hook-rhel.sh
536new file mode 100755
537index 0000000..5e963a8
538--- /dev/null
539+++ b/tools/hook-rhel.sh
540@@ -0,0 +1,12 @@
541+#!/bin/sh
542+# Current versions of RHEL and CentOS do not honor the directory
543+# /etc/dhcp/dhclient-exit-hooks.d so this file can be placed in
544+# /etc/dhcp/dhclient.d instead
545+
546+hook-rhel_config(){
547+ cloud-init dhclient-hook up "$interface"
548+}
549+
550+hook-rhel_restore(){
551+ cloud-init dhclient-hook down "$interface"
552+}

Subscribers

People subscribed via source and target branches