Merge lp:~daniel-thewatkins/cloud-init/smartos-v2-metadata into lp:~cloud-init-dev/cloud-init/trunk

Proposed by Dan Watkins on 2015-03-04
Status: Merged
Merged at revision: 1085
Proposed branch: lp:~daniel-thewatkins/cloud-init/smartos-v2-metadata
Merge into: lp:~cloud-init-dev/cloud-init/trunk
Diff against target: 414 lines (+241/-90)
2 files modified
cloudinit/sources/DataSourceSmartOS.py (+69/-21)
tests/unittests/test_datasource/test_smartos.py (+172/-69)
To merge this branch: bzr merge lp:~daniel-thewatkins/cloud-init/smartos-v2-metadata
Reviewer Review Type Date Requested Status
cloud-init commiters 2015-03-04 Pending
Review via email: mp+251775@code.launchpad.net

Description of the Change

Update SmartOS data source to use v2 metadata.

This has been tested on Joyent and appears to work.

To post a comment you must log in.
1074. By Scott Moser on 2015-03-04

pull in 'snappy' support

This allows config to disable some of the config modules that were
failing and logging WARN on snapy. Also adds the snappy module
and changes the syslog perms to take a list of user:groups rather
than just a single.

1075. By Scott Moser on 2015-03-04

Add util.message_from_string to wrap email.message_from_string.

This is to work-around the fact that email.message_from_string uses
cStringIO in Python 2.6, which can't handle Unicode.

1076. By Scott Moser on 2015-03-04

Fix hang caused by HTTPretty on Python 3.4.2.

HTTPretty can causes hangs on Python 3.4.2 (and maybe Python 3.4.1), due
to a Python bug (fixed in Python 3.4.3). This works around the problem
in the appropriate Python versions.

See https://github.com/gabrielfalcao/HTTPretty/pull/193 and
https://github.com/gabrielfalcao/HTTPretty/issues/221 for details.

1077. By Scott Moser on 2015-03-05

DataSourceMAAS: adjust local timestamp in case of clock skew

This functionality has been introduced to fix LP: #978127, but was lost
while migrating cloud-init to python3.

1078. By Scott Moser on 2015-03-05

snappy: disable by default

this does 2 things actually
a.) disables snappy by default, and adds checks to filesystem to enable it
    this way it runs on snappy systems, but not on others.
b.) removes the 'render2env' that was mostly spike code.

1079. By Scott Moser on 2015-03-10

DataSourceMAAS: remove debug statement

1080. By Scott Moser on 2015-03-10

DataSourceMAAS: fix timestamp error in oauthlib

oddly enough, the timestamp you pass into oauthlib must be a None
or a string. If not, raises ValueError:
  Only unicode objects are escapable. Got 1426021488 of type <class 'int'>

1081. By Oleg Strikov on 2015-03-11

userdata-handlers: python3-related fixes on do-not-process-this-part path

Cloud-init crashed when received multipart userdata object with
'application/octet-stream' part or some other 'application/*' part
except archived ones (x-gzip and friends). These parts are not
processed by cloud-init and result only in a message in the log.
We used some non-python3-friendly techniques while generating
this log message which was a reason for the crash.

Joshua Harlow (harlowja) :
1082. By Scott Moser on 2015-03-16

emit_upstart: fix use of undeclared variable

1083. By Scott Moser on 2015-03-17

SmartOS: fixes for python3 reading from serial device.

We were hitting exceptions when writing to the SmartOS serial console and, once
that was fixed, we were hanging permanently waiting for b"." == "." to be true.

This fixes both of those issues.

1084. By Scott Moser on 2015-03-17

systemd: update config and final to run even if init jobs fail

1085. By Dan Watkins on 2015-03-25

Organise imports in test_smartos.py.

1086. By Dan Watkins on 2015-03-25

Convert DataSourceSmartOS to use v2 metadata.

1087. By Dan Watkins on 2015-03-25

Refactor tests to assume JoyentMetadataClient is correct.

We are treating JoyentMetadataClient as a unit which the data source
depends on, so we mock it out instead of providing a fake implementation
of it.

1088. By Dan Watkins on 2015-03-25

Add logging to JoyentMetadataClient.

1089. By Dan Watkins on 2015-03-25

Ensure that the serial console is always closed.

1090. By Dan Watkins on 2015-03-25

Switch logging from info to debug level.

1091. By Dan Watkins on 2015-03-25

Add link to Joyent metadata specification.

1092. By Dan Watkins on 2015-03-25

Compile SmartOS line-parsing regex once.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'cloudinit/sources/DataSourceSmartOS.py'
2--- cloudinit/sources/DataSourceSmartOS.py 2015-03-13 10:18:12 +0000
3+++ cloudinit/sources/DataSourceSmartOS.py 2015-03-25 17:59:53 +0000
4@@ -29,9 +29,12 @@
5 # http://us-east.manta.joyent.com/jmc/public/mdata/datadict.html
6 # Comments with "@datadictionary" are snippets of the definition
7
8-import base64
9 import binascii
10+import contextlib
11 import os
12+import random
13+import re
14+
15 import serial
16
17 from cloudinit import log as logging
18@@ -301,6 +304,65 @@
19 return ser
20
21
22+class JoyentMetadataFetchException(Exception):
23+ pass
24+
25+
26+class JoyentMetadataClient(object):
27+ """
28+ A client implementing v2 of the Joyent Metadata Protocol Specification.
29+
30+ The full specification can be found at
31+ http://eng.joyent.com/mdata/protocol.html
32+ """
33+ line_regex = re.compile(
34+ r'V2 (?P<length>\d+) (?P<checksum>[0-9a-f]+)'
35+ r' (?P<body>(?P<request_id>[0-9a-f]+) (?P<status>SUCCESS|NOTFOUND)'
36+ r'( (?P<payload>.+))?)')
37+
38+ def __init__(self, serial):
39+ self.serial = serial
40+
41+ def _checksum(self, body):
42+ return '{0:08x}'.format(
43+ binascii.crc32(body.encode('utf-8')) & 0xffffffff)
44+
45+ def _get_value_from_frame(self, expected_request_id, frame):
46+ frame_data = self.line_regex.match(frame).groupdict()
47+ if int(frame_data['length']) != len(frame_data['body']):
48+ raise JoyentMetadataFetchException(
49+ 'Incorrect frame length given ({0} != {1}).'.format(
50+ frame_data['length'], len(frame_data['body'])))
51+ expected_checksum = self._checksum(frame_data['body'])
52+ if frame_data['checksum'] != expected_checksum:
53+ raise JoyentMetadataFetchException(
54+ 'Invalid checksum (expected: {0}; got {1}).'.format(
55+ expected_checksum, frame_data['checksum']))
56+ if frame_data['request_id'] != expected_request_id:
57+ raise JoyentMetadataFetchException(
58+ 'Request ID mismatch (expected: {0}; got {1}).'.format(
59+ expected_request_id, frame_data['request_id']))
60+ if not frame_data.get('payload', None):
61+ LOG.debug('No value found.')
62+ return None
63+ value = util.b64d(frame_data['payload'])
64+ LOG.debug('Value "%s" found.', value)
65+ return value
66+
67+ def get_metadata(self, metadata_key):
68+ LOG.debug('Fetching metadata key "%s"...', metadata_key)
69+ request_id = '{0:08x}'.format(random.randint(0, 0xffffffff))
70+ message_body = '{0} GET {1}'.format(request_id,
71+ util.b64e(metadata_key))
72+ msg = 'V2 {0} {1} {2}\n'.format(
73+ len(message_body), self._checksum(message_body), message_body)
74+ LOG.debug('Writing "%s" to serial port.', msg)
75+ self.serial.write(msg.encode('ascii'))
76+ response = self.serial.readline().decode('ascii')
77+ LOG.debug('Read "%s" from serial port.', response)
78+ return self._get_value_from_frame(request_id, response)
79+
80+
81 def query_data(noun, seed_device, seed_timeout, strip=False, default=None,
82 b64=None):
83 """Makes a request to via the serial console via "GET <NOUN>"
84@@ -314,34 +376,20 @@
85 encoded, so this method relies on being told if the data is base64 or
86 not.
87 """
88-
89 if not noun:
90 return False
91
92- ser = get_serial(seed_device, seed_timeout)
93- request_line = "GET %s\n" % noun.rstrip()
94- ser.write(request_line.encode('ascii'))
95- status = str(ser.readline()).rstrip()
96- response = []
97- eom_found = False
98+ with contextlib.closing(get_serial(seed_device, seed_timeout)) as ser:
99+ client = JoyentMetadataClient(ser)
100+ response = client.get_metadata(noun)
101
102- if 'SUCCESS' not in status:
103- ser.close()
104+ if response is None:
105 return default
106
107- while not eom_found:
108- m = ser.readline().decode('ascii')
109- if m.rstrip() == ".":
110- eom_found = True
111- else:
112- response.append(m)
113-
114- ser.close()
115-
116 if b64 is None:
117 b64 = query_data('b64-%s' % noun, seed_device=seed_device,
118- seed_timeout=seed_timeout, b64=False,
119- default=False, strip=True)
120+ seed_timeout=seed_timeout, b64=False,
121+ default=False, strip=True)
122 b64 = util.is_true(b64)
123
124 resp = None
125
126=== modified file 'tests/unittests/test_datasource/test_smartos.py'
127--- tests/unittests/test_datasource/test_smartos.py 2015-03-13 10:18:12 +0000
128+++ tests/unittests/test_datasource/test_smartos.py 2015-03-25 17:59:53 +0000
129@@ -24,20 +24,30 @@
130
131 from __future__ import print_function
132
133-from cloudinit import helpers as c_helpers
134-from cloudinit.sources import DataSourceSmartOS
135-from cloudinit.util import b64e
136-from .. import helpers
137 import os
138 import os.path
139 import re
140 import shutil
141+import stat
142 import tempfile
143-import stat
144 import uuid
145-
146-import six
147-
148+from binascii import crc32
149+
150+import serial
151+import six
152+
153+import six
154+
155+from cloudinit import helpers as c_helpers
156+from cloudinit.sources import DataSourceSmartOS
157+from cloudinit.util import b64e
158+
159+from .. import helpers
160+
161+try:
162+ from unittest import mock
163+except ImportError:
164+ import mock
165
166 MOCK_RETURNS = {
167 'hostname': 'test-host',
168@@ -56,63 +66,15 @@
169 DMI_DATA_RETURN = (str(uuid.uuid4()), 'smartdc')
170
171
172-class MockSerial(object):
173- """Fake a serial terminal for testing the code that
174- interfaces with the serial"""
175-
176- port = None
177-
178- def __init__(self, mockdata):
179- self.last = None
180- self.last = None
181- self.new = True
182- self.count = 0
183- self.mocked_out = []
184- self.mockdata = mockdata
185-
186- def open(self):
187- return True
188-
189- def close(self):
190- return True
191-
192- def isOpen(self):
193- return True
194-
195- def write(self, line):
196- if not isinstance(line, six.binary_type):
197- raise TypeError("Should be writing binary lines.")
198- line = line.decode('ascii').replace('GET ', '')
199- self.last = line.rstrip()
200-
201- def readline(self):
202- if self.new:
203- self.new = False
204- if self.last in self.mockdata:
205- line = 'SUCCESS\n'
206- else:
207- line = 'NOTFOUND %s\n' % self.last
208-
209- elif self.last in self.mockdata:
210- if not self.mocked_out:
211- self.mocked_out = [x for x in self._format_out()]
212-
213- if len(self.mocked_out) > self.count:
214- self.count += 1
215- line = self.mocked_out[self.count - 1]
216- return line.encode('ascii')
217-
218- def _format_out(self):
219- if self.last in self.mockdata:
220- _mret = self.mockdata[self.last]
221- try:
222- for l in _mret.splitlines():
223- yield "%s\n" % l.rstrip()
224- except:
225- yield "%s\n" % _mret.rstrip()
226-
227- yield '.'
228- yield '\n'
229+def get_mock_client(mockdata):
230+ class MockMetadataClient(object):
231+
232+ def __init__(self, serial):
233+ pass
234+
235+ def get_metadata(self, metadata_key):
236+ return mockdata.get(metadata_key)
237+ return MockMetadataClient
238
239
240 class TestSmartOSDataSource(helpers.FilesystemMockingTestCase):
241@@ -160,9 +122,6 @@
242 if dmi_data is None:
243 dmi_data = DMI_DATA_RETURN
244
245- def _get_serial(*_):
246- return MockSerial(mockdata)
247-
248 def _dmi_data():
249 return dmi_data
250
251@@ -179,7 +138,9 @@
252 sys_cfg['datasource']['SmartOS'] = ds_cfg
253
254 self.apply_patches([(mod, 'LEGACY_USER_D', self.legacy_user_d)])
255- self.apply_patches([(mod, 'get_serial', _get_serial)])
256+ self.apply_patches([(mod, 'get_serial', mock.MagicMock())])
257+ self.apply_patches([
258+ (mod, 'JoyentMetadataClient', get_mock_client(mockdata))])
259 self.apply_patches([(mod, 'dmi_data', _dmi_data)])
260 self.apply_patches([(os, 'uname', _os_uname)])
261 self.apply_patches([(mod, 'device_exists', lambda d: True)])
262@@ -448,6 +409,18 @@
263 self.assertEqual(dsrc.device_name_to_device('FOO'),
264 mydscfg['disk_aliases']['FOO'])
265
266+ @mock.patch('cloudinit.sources.DataSourceSmartOS.JoyentMetadataClient')
267+ @mock.patch('cloudinit.sources.DataSourceSmartOS.get_serial')
268+ def test_serial_console_closed_on_error(self, get_serial, metadata_client):
269+ class OurException(Exception):
270+ pass
271+ metadata_client.side_effect = OurException
272+ try:
273+ DataSourceSmartOS.query_data('noun', 'device', 0)
274+ except OurException:
275+ pass
276+ self.assertEqual(1, get_serial.return_value.close.call_count)
277+
278
279 def apply_patches(patches):
280 ret = []
281@@ -458,3 +431,133 @@
282 setattr(ref, name, replace)
283 ret.append((ref, name, orig))
284 return ret
285+
286+
287+class TestJoyentMetadataClient(helpers.FilesystemMockingTestCase):
288+
289+ def setUp(self):
290+ super(TestJoyentMetadataClient, self).setUp()
291+ self.serial = mock.MagicMock(spec=serial.Serial)
292+ self.request_id = 0xabcdef12
293+ self.metadata_value = 'value'
294+ self.response_parts = {
295+ 'command': 'SUCCESS',
296+ 'crc': 'b5a9ff00',
297+ 'length': 17 + len(b64e(self.metadata_value)),
298+ 'payload': b64e(self.metadata_value),
299+ 'request_id': '{0:08x}'.format(self.request_id),
300+ }
301+
302+ def make_response():
303+ payload = ''
304+ if self.response_parts['payload']:
305+ payload = ' {0}'.format(self.response_parts['payload'])
306+ del self.response_parts['payload']
307+ return (
308+ 'V2 {length} {crc} {request_id} {command}{payload}\n'.format(
309+ payload=payload, **self.response_parts).encode('ascii'))
310+ self.serial.readline.side_effect = make_response
311+ self.patched_funcs.enter_context(
312+ mock.patch('cloudinit.sources.DataSourceSmartOS.random.randint',
313+ mock.Mock(return_value=self.request_id)))
314+
315+ def _get_client(self):
316+ return DataSourceSmartOS.JoyentMetadataClient(self.serial)
317+
318+ def assertEndsWith(self, haystack, prefix):
319+ self.assertTrue(haystack.endswith(prefix),
320+ "{0} does not end with '{1}'".format(
321+ repr(haystack), prefix))
322+
323+ def assertStartsWith(self, haystack, prefix):
324+ self.assertTrue(haystack.startswith(prefix),
325+ "{0} does not start with '{1}'".format(
326+ repr(haystack), prefix))
327+
328+ def test_get_metadata_writes_a_single_line(self):
329+ client = self._get_client()
330+ client.get_metadata('some_key')
331+ self.assertEqual(1, self.serial.write.call_count)
332+ written_line = self.serial.write.call_args[0][0]
333+ self.assertEndsWith(written_line, b'\n')
334+ self.assertEqual(1, written_line.count(b'\n'))
335+
336+ def _get_written_line(self, key='some_key'):
337+ client = self._get_client()
338+ client.get_metadata(key)
339+ return self.serial.write.call_args[0][0]
340+
341+ def test_get_metadata_writes_bytes(self):
342+ self.assertIsInstance(self._get_written_line(), six.binary_type)
343+
344+ def test_get_metadata_line_starts_with_v2(self):
345+ self.assertStartsWith(self._get_written_line(), b'V2')
346+
347+ def test_get_metadata_uses_get_command(self):
348+ parts = self._get_written_line().decode('ascii').strip().split(' ')
349+ self.assertEqual('GET', parts[4])
350+
351+ def test_get_metadata_base64_encodes_argument(self):
352+ key = 'my_key'
353+ parts = self._get_written_line(key).decode('ascii').strip().split(' ')
354+ self.assertEqual(b64e(key), parts[5])
355+
356+ def test_get_metadata_calculates_length_correctly(self):
357+ parts = self._get_written_line().decode('ascii').strip().split(' ')
358+ expected_length = len(' '.join(parts[3:]))
359+ self.assertEqual(expected_length, int(parts[1]))
360+
361+ def test_get_metadata_uses_appropriate_request_id(self):
362+ parts = self._get_written_line().decode('ascii').strip().split(' ')
363+ request_id = parts[3]
364+ self.assertEqual(8, len(request_id))
365+ self.assertEqual(request_id, request_id.lower())
366+
367+ def test_get_metadata_uses_random_number_for_request_id(self):
368+ line = self._get_written_line()
369+ request_id = line.decode('ascii').strip().split(' ')[3]
370+ self.assertEqual('{0:08x}'.format(self.request_id), request_id)
371+
372+ def test_get_metadata_checksums_correctly(self):
373+ parts = self._get_written_line().decode('ascii').strip().split(' ')
374+ expected_checksum = '{0:08x}'.format(
375+ crc32(' '.join(parts[3:]).encode('utf-8')) & 0xffffffff)
376+ checksum = parts[2]
377+ self.assertEqual(expected_checksum, checksum)
378+
379+ def test_get_metadata_reads_a_line(self):
380+ client = self._get_client()
381+ client.get_metadata('some_key')
382+ self.assertEqual(1, self.serial.readline.call_count)
383+
384+ def test_get_metadata_returns_valid_value(self):
385+ client = self._get_client()
386+ value = client.get_metadata('some_key')
387+ self.assertEqual(self.metadata_value, value)
388+
389+ def test_get_metadata_throws_exception_for_incorrect_length(self):
390+ self.response_parts['length'] = 0
391+ client = self._get_client()
392+ self.assertRaises(DataSourceSmartOS.JoyentMetadataFetchException,
393+ client.get_metadata, 'some_key')
394+
395+ def test_get_metadata_throws_exception_for_incorrect_crc(self):
396+ self.response_parts['crc'] = 'deadbeef'
397+ client = self._get_client()
398+ self.assertRaises(DataSourceSmartOS.JoyentMetadataFetchException,
399+ client.get_metadata, 'some_key')
400+
401+ def test_get_metadata_throws_exception_for_request_id_mismatch(self):
402+ self.response_parts['request_id'] = 'deadbeef'
403+ client = self._get_client()
404+ client._checksum = lambda _: self.response_parts['crc']
405+ self.assertRaises(DataSourceSmartOS.JoyentMetadataFetchException,
406+ client.get_metadata, 'some_key')
407+
408+ def test_get_metadata_returns_None_if_value_not_found(self):
409+ self.response_parts['payload'] = ''
410+ self.response_parts['command'] = 'NOTFOUND'
411+ self.response_parts['length'] = 17
412+ client = self._get_client()
413+ client._checksum = lambda _: self.response_parts['crc']
414+ self.assertIsNone(client.get_metadata('some_key'))