Merge lp:~rcj/cloud-init/lp1540965 into lp:~cloud-init-dev/cloud-init/trunk

Proposed by Robert C Jennings on 2016-02-04
Status: Merged
Merged at revision: 1159
Proposed branch: lp:~rcj/cloud-init/lp1540965
Merge into: lp:~cloud-init-dev/cloud-init/trunk
Diff against target: 614 lines (+221/-138)
3 files modified
cloudinit/sources/DataSourceSmartOS.py (+159/-108)
doc/examples/cloud-config-datasources.txt (+7/-0)
tests/unittests/test_datasource/test_smartos.py (+55/-30)
To merge this branch: bzr merge lp:~rcj/cloud-init/lp1540965
Reviewer Review Type Date Requested Status
Scott Moser 2016-02-04 Pending
Review via email: mp+285097@code.launchpad.net
To post a comment you must log in.
Scott Moser (smoser) wrote :

please see comments in line.
looks fine, but i dont want the test-requirements.txt change unless you have justification for it that i did not understand.

Robert C Jennings (rcj) wrote :

I removed the test-requirements.txt change it was unnecessary.

lp:~rcj/cloud-init/lp1540965 updated on 2016-02-04
1158. By Robert C Jennings on 2016-02-04

SmartOS: Add support for Joyent LX-Brand Zones (LP: #1540965)

LX-brand zones on Joyent's SmartOS use a different metadata source
(socket file) than the KVM-based SmartOS virtualization (serial port).
This patch adds support for recognizing the different flavors of
virtualization on SmartOS and setting up a metadata source file object.
After the file object is created, the rest of the code for the datasource

Robert C Jennings (rcj) wrote :

I overwrote my prior commit but I have added comments below for the changes from the last commit.

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-25 17:59:42 +0000
3+++ cloudinit/sources/DataSourceSmartOS.py 2016-02-04 21:52:54 +0000
4@@ -20,10 +20,13 @@
5 # Datasource for provisioning on SmartOS. This works on Joyent
6 # and public/private Clouds using SmartOS.
7 #
8-# SmartOS hosts use a serial console (/dev/ttyS1) on Linux Guests.
9+# SmartOS hosts use a serial console (/dev/ttyS1) on KVM Linux Guests
10 # The meta-data is transmitted via key/value pairs made by
11 # requests on the console. For example, to get the hostname, you
12 # would send "GET hostname" on /dev/ttyS1.
13+# For Linux Guests running in LX-Brand Zones on SmartOS hosts
14+# a socket (/native/.zonecontrol/metadata.sock) is used instead
15+# of a serial console.
16 #
17 # Certain behavior is defined by the DataDictionary
18 # http://us-east.manta.joyent.com/jmc/public/mdata/datadict.html
19@@ -34,6 +37,8 @@
20 import os
21 import random
22 import re
23+import socket
24+import stat
25
26 import serial
27
28@@ -46,6 +51,7 @@
29
30 SMARTOS_ATTRIB_MAP = {
31 # Cloud-init Key : (SmartOS Key, Strip line endings)
32+ 'instance-id': ('sdc:uuid', True),
33 'local-hostname': ('hostname', True),
34 'public-keys': ('root_authorized_keys', True),
35 'user-script': ('user-script', False),
36@@ -76,6 +82,7 @@
37 #
38 BUILTIN_DS_CONFIG = {
39 'serial_device': '/dev/ttyS1',
40+ 'metadata_sockfile': '/native/.zonecontrol/metadata.sock',
41 'seed_timeout': 60,
42 'no_base64_decode': ['root_authorized_keys',
43 'motd_sys_info',
44@@ -83,6 +90,7 @@
45 'user-data',
46 'user-script',
47 'sdc:datacenter_name',
48+ 'sdc:uuid',
49 ],
50 'base64_keys': [],
51 'base64_all': False,
52@@ -150,17 +158,27 @@
53 def __init__(self, sys_cfg, distro, paths):
54 sources.DataSource.__init__(self, sys_cfg, distro, paths)
55 self.is_smartdc = None
56-
57 self.ds_cfg = util.mergemanydict([
58 self.ds_cfg,
59 util.get_cfg_by_path(sys_cfg, DS_CFG_PATH, {}),
60 BUILTIN_DS_CONFIG])
61
62 self.metadata = {}
63- self.cfg = BUILTIN_CLOUD_CONFIG
64
65- self.seed = self.ds_cfg.get("serial_device")
66- self.seed_timeout = self.ds_cfg.get("serial_timeout")
67+ # SDC LX-Brand Zones lack dmidecode (no /dev/mem) but
68+ # report 'BrandZ virtual linux' as the kernel version
69+ if os.uname()[3].lower() == 'brandz virtual linux':
70+ LOG.debug("Host is SmartOS, guest in Zone")
71+ self.is_smartdc = True
72+ self.smartos_type = 'lx-brand'
73+ self.cfg = {}
74+ self.seed = self.ds_cfg.get("metadata_sockfile")
75+ else:
76+ self.is_smartdc = True
77+ self.smartos_type = 'kvm'
78+ self.seed = self.ds_cfg.get("serial_device")
79+ self.cfg = BUILTIN_CLOUD_CONFIG
80+ self.seed_timeout = self.ds_cfg.get("serial_timeout")
81 self.smartos_no_base64 = self.ds_cfg.get('no_base64_decode')
82 self.b64_keys = self.ds_cfg.get('base64_keys')
83 self.b64_all = self.ds_cfg.get('base64_all')
84@@ -170,12 +188,49 @@
85 root = sources.DataSource.__str__(self)
86 return "%s [seed=%s]" % (root, self.seed)
87
88+ def _get_seed_file_object(self):
89+ if not self.seed:
90+ raise AttributeError("seed device is not set")
91+
92+ if self.smartos_type == 'lx-brand':
93+ if not stat.S_ISSOCK(os.stat(self.seed).st_mode):
94+ LOG.debug("Seed %s is not a socket", self.seed)
95+ return None
96+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
97+ sock.connect(self.seed)
98+ return sock.makefile('rwb')
99+ else:
100+ if not stat.S_ISCHR(os.stat(self.seed).st_mode):
101+ LOG.debug("Seed %s is not a character device")
102+ return None
103+ ser = serial.Serial(self.seed, timeout=self.seed_timeout)
104+ if not ser.isOpen():
105+ raise SystemError("Unable to open %s" % self.seed)
106+ return ser
107+ return None
108+
109+ def _set_provisioned(self):
110+ '''Mark the instance provisioning state as successful.
111+
112+ When run in a zone, the host OS will look for /var/svc/provisioning
113+ to be renamed as /var/svc/provision_success. This should be done
114+ after meta-data is successfully retrieved and from this point
115+ the host considers the provision of the zone to be a success and
116+ keeps the zone running.
117+ '''
118+
119+ LOG.debug('Instance provisioning state set as successful')
120+ svc_path = '/var/svc'
121+ if os.path.exists('/'.join([svc_path, 'provisioning'])):
122+ os.rename('/'.join([svc_path, 'provisioning']),
123+ '/'.join([svc_path, 'provision_success']))
124+
125 def get_data(self):
126 md = {}
127 ud = ""
128
129 if not device_exists(self.seed):
130- LOG.debug("No serial device '%s' found for SmartOS datasource",
131+ LOG.debug("No metadata device '%s' found for SmartOS datasource",
132 self.seed)
133 return False
134
135@@ -185,29 +240,36 @@
136 LOG.debug("Disabling SmartOS datasource on arm (LP: #1243287)")
137 return False
138
139- dmi_info = dmi_data()
140- if dmi_info is False:
141- LOG.debug("No dmidata utility found")
142- return False
143-
144- system_uuid, system_type = tuple(dmi_info)
145- if 'smartdc' not in system_type.lower():
146- LOG.debug("Host is not on SmartOS. system_type=%s", system_type)
147- return False
148- self.is_smartdc = True
149- md['instance-id'] = system_uuid
150-
151- b64_keys = self.query('base64_keys', strip=True, b64=False)
152- if b64_keys is not None:
153- self.b64_keys = [k.strip() for k in str(b64_keys).split(',')]
154-
155- b64_all = self.query('base64_all', strip=True, b64=False)
156- if b64_all is not None:
157- self.b64_all = util.is_true(b64_all)
158-
159- for ci_noun, attribute in SMARTOS_ATTRIB_MAP.items():
160- smartos_noun, strip = attribute
161- md[ci_noun] = self.query(smartos_noun, strip=strip)
162+ # SDC KVM instances will provide dmi data, LX-brand does not
163+ if self.smartos_type == 'kvm':
164+ dmi_info = dmi_data()
165+ if dmi_info is False:
166+ LOG.debug("No dmidata utility found")
167+ return False
168+
169+ system_type = dmi_info
170+ if 'smartdc' not in system_type.lower():
171+ LOG.debug("Host is not on SmartOS. system_type=%s",
172+ system_type)
173+ return False
174+ LOG.debug("Host is SmartOS, guest in KVM")
175+
176+ seed_obj = self._get_seed_file_object()
177+ if seed_obj is None:
178+ LOG.debug('Seed file object not found.')
179+ return False
180+ with contextlib.closing(seed_obj) as seed:
181+ b64_keys = self.query('base64_keys', seed, strip=True, b64=False)
182+ if b64_keys is not None:
183+ self.b64_keys = [k.strip() for k in str(b64_keys).split(',')]
184+
185+ b64_all = self.query('base64_all', seed, strip=True, b64=False)
186+ if b64_all is not None:
187+ self.b64_all = util.is_true(b64_all)
188+
189+ for ci_noun, attribute in SMARTOS_ATTRIB_MAP.items():
190+ smartos_noun, strip = attribute
191+ md[ci_noun] = self.query(smartos_noun, seed, strip=strip)
192
193 # @datadictionary: This key may contain a program that is written
194 # to a file in the filesystem of the guest on each boot and then
195@@ -240,7 +302,7 @@
196
197 # Handle the cloud-init regular meta
198 if not md['local-hostname']:
199- md['local-hostname'] = system_uuid
200+ md['local-hostname'] = md['instance-id']
201
202 ud = None
203 if md['user-data']:
204@@ -257,6 +319,8 @@
205 self.metadata = util.mergemanydict([md, self.metadata])
206 self.userdata_raw = ud
207 self.vendordata_raw = md['vendor-data']
208+
209+ self._set_provisioned()
210 return True
211
212 def device_name_to_device(self, name):
213@@ -268,16 +332,59 @@
214 def get_instance_id(self):
215 return self.metadata['instance-id']
216
217- def query(self, noun, strip=False, default=None, b64=None):
218+ def query(self, noun, seed_file, strip=False, default=None, b64=None):
219 if b64 is None:
220 if noun in self.smartos_no_base64:
221 b64 = False
222 elif self.b64_all or noun in self.b64_keys:
223 b64 = True
224
225- return query_data(noun=noun, strip=strip, seed_device=self.seed,
226- seed_timeout=self.seed_timeout, default=default,
227- b64=b64)
228+ return self._query_data(noun, seed_file, strip=strip,
229+ default=default, b64=b64)
230+
231+ def _query_data(self, noun, seed_file, strip=False,
232+ default=None, b64=None):
233+ """Makes a request via "GET <NOUN>"
234+
235+ In the response, the first line is the status, while subsequent
236+ lines are is the value. A blank line with a "." is used to
237+ indicate end of response.
238+
239+ If the response is expected to be base64 encoded, then set
240+ b64encoded to true. Unfortantely, there is no way to know if
241+ something is 100% encoded, so this method relies on being told
242+ if the data is base64 or not.
243+ """
244+
245+ if not noun:
246+ return False
247+
248+ response = JoyentMetadataClient(seed_file).get_metadata(noun)
249+
250+ if response is None:
251+ return default
252+
253+ if b64 is None:
254+ b64 = self._query_data('b64-%s' % noun, seed_file, b64=False,
255+ default=False, strip=True)
256+ b64 = util.is_true(b64)
257+
258+ resp = None
259+ if b64 or strip:
260+ resp = "".join(response).rstrip()
261+ else:
262+ resp = "".join(response)
263+
264+ if b64:
265+ try:
266+ return util.b64d(resp)
267+ # Bogus input produces different errors in Python 2 and 3;
268+ # catch both.
269+ except (TypeError, binascii.Error):
270+ LOG.warn("Failed base64 decoding key '%s'", noun)
271+ return resp
272+
273+ return resp
274
275
276 def device_exists(device):
277@@ -285,25 +392,6 @@
278 return os.path.exists(device)
279
280
281-def get_serial(seed_device, seed_timeout):
282- """This is replaced in unit testing, allowing us to replace
283- serial.Serial with a mocked class.
284-
285- The timeout value of 60 seconds should never be hit. The value
286- is taken from SmartOS own provisioning tools. Since we are reading
287- each line individually up until the single ".", the transfer is
288- usually very fast (i.e. microseconds) to get the response.
289- """
290- if not seed_device:
291- raise AttributeError("seed_device value is not set")
292-
293- ser = serial.Serial(seed_device, timeout=seed_timeout)
294- if not ser.isOpen():
295- raise SystemError("Unable to open %s" % seed_device)
296-
297- return ser
298-
299-
300 class JoyentMetadataFetchException(Exception):
301 pass
302
303@@ -320,8 +408,8 @@
304 r' (?P<body>(?P<request_id>[0-9a-f]+) (?P<status>SUCCESS|NOTFOUND)'
305 r'( (?P<payload>.+))?)')
306
307- def __init__(self, serial):
308- self.serial = serial
309+ def __init__(self, metasource):
310+ self.metasource = metasource
311
312 def _checksum(self, body):
313 return '{0:08x}'.format(
314@@ -356,67 +444,30 @@
315 util.b64e(metadata_key))
316 msg = 'V2 {0} {1} {2}\n'.format(
317 len(message_body), self._checksum(message_body), message_body)
318- LOG.debug('Writing "%s" to serial port.', msg)
319- self.serial.write(msg.encode('ascii'))
320- response = self.serial.readline().decode('ascii')
321- LOG.debug('Read "%s" from serial port.', response)
322+ LOG.debug('Writing "%s" to metadata transport.', msg)
323+ self.metasource.write(msg.encode('ascii'))
324+ self.metasource.flush()
325+
326+ response = bytearray()
327+ response.extend(self.metasource.read(1))
328+ while response[-1:] != b'\n':
329+ response.extend(self.metasource.read(1))
330+ response = response.rstrip().decode('ascii')
331+ LOG.debug('Read "%s" from metadata transport.', response)
332+
333+ if 'SUCCESS' not in response:
334+ return None
335+
336 return self._get_value_from_frame(request_id, response)
337
338
339-def query_data(noun, seed_device, seed_timeout, strip=False, default=None,
340- b64=None):
341- """Makes a request to via the serial console via "GET <NOUN>"
342-
343- In the response, the first line is the status, while subsequent lines
344- are is the value. A blank line with a "." is used to indicate end of
345- response.
346-
347- If the response is expected to be base64 encoded, then set b64encoded
348- to true. Unfortantely, there is no way to know if something is 100%
349- encoded, so this method relies on being told if the data is base64 or
350- not.
351- """
352- if not noun:
353- return False
354-
355- with contextlib.closing(get_serial(seed_device, seed_timeout)) as ser:
356- client = JoyentMetadataClient(ser)
357- response = client.get_metadata(noun)
358-
359- if response is None:
360- return default
361-
362- if b64 is None:
363- b64 = query_data('b64-%s' % noun, seed_device=seed_device,
364- seed_timeout=seed_timeout, b64=False,
365- default=False, strip=True)
366- b64 = util.is_true(b64)
367-
368- resp = None
369- if b64 or strip:
370- resp = "".join(response).rstrip()
371- else:
372- resp = "".join(response)
373-
374- if b64:
375- try:
376- return util.b64d(resp)
377- # Bogus input produces different errors in Python 2 and 3; catch both.
378- except (TypeError, binascii.Error):
379- LOG.warn("Failed base64 decoding key '%s'", noun)
380- return resp
381-
382- return resp
383-
384-
385 def dmi_data():
386- sys_uuid = util.read_dmi_data("system-uuid")
387 sys_type = util.read_dmi_data("system-product-name")
388
389- if not sys_uuid or not sys_type:
390+ if not sys_type:
391 return None
392
393- return (sys_uuid.lower(), sys_type)
394+ return sys_type
395
396
397 def write_boot_content(content, content_f, link=None, shebang=False,
398
399=== modified file 'doc/examples/cloud-config-datasources.txt'
400--- doc/examples/cloud-config-datasources.txt 2013-12-12 19:22:14 +0000
401+++ doc/examples/cloud-config-datasources.txt 2016-02-04 21:52:54 +0000
402@@ -51,12 +51,19 @@
403 policy: on # [can be 'on', 'off' or 'force']
404
405 SmartOS:
406+ # For KVM guests:
407 # Smart OS datasource works over a serial console interacting with
408 # a server on the other end. By default, the second serial console is the
409 # device. SmartOS also uses a serial timeout of 60 seconds.
410 serial_device: /dev/ttyS1
411 serial_timeout: 60
412
413+ # For LX-Brand Zones guests:
414+ # Smart OS datasource works over a socket interacting with
415+ # the host on the other end. By default, the socket file is in
416+ # the native .zoncontrol directory.
417+ metadata_sockfile: /native/.zonecontrol/metadata.sock
418+
419 # a list of keys that will not be base64 decoded even if base64_all
420 no_base64_decode: ['root_authorized_keys', 'motd_sys_info',
421 'iptables_disable']
422
423=== modified file 'tests/unittests/test_datasource/test_smartos.py'
424--- tests/unittests/test_datasource/test_smartos.py 2015-05-01 09:38:56 +0000
425+++ tests/unittests/test_datasource/test_smartos.py 2016-02-04 21:52:54 +0000
426@@ -31,6 +31,7 @@
427 import stat
428 import tempfile
429 import uuid
430+import unittest
431 from binascii import crc32
432
433 import serial
434@@ -56,12 +57,13 @@
435 'cloud-init:user-data': '\n'.join(['#!/bin/sh', '/bin/true', '']),
436 'sdc:datacenter_name': 'somewhere2',
437 'sdc:operator-script': '\n'.join(['bin/true', '']),
438+ 'sdc:uuid': str(uuid.uuid4()),
439 'sdc:vendor-data': '\n'.join(['VENDOR_DATA', '']),
440 'user-data': '\n'.join(['something', '']),
441 'user-script': '\n'.join(['/bin/true', '']),
442 }
443
444-DMI_DATA_RETURN = (str(uuid.uuid4()), 'smartdc')
445+DMI_DATA_RETURN = 'smartdc'
446
447
448 def get_mock_client(mockdata):
449@@ -111,7 +113,8 @@
450 ret = apply_patches(patches)
451 self.unapply += ret
452
453- def _get_ds(self, sys_cfg=None, ds_cfg=None, mockdata=None, dmi_data=None):
454+ def _get_ds(self, sys_cfg=None, ds_cfg=None, mockdata=None, dmi_data=None,
455+ is_lxbrand=False):
456 mod = DataSourceSmartOS
457
458 if mockdata is None:
459@@ -124,9 +127,13 @@
460 return dmi_data
461
462 def _os_uname():
463- # LP: #1243287. tests assume this runs, but running test on
464- # arm would cause them all to fail.
465- return ('LINUX', 'NODENAME', 'RELEASE', 'VERSION', 'x86_64')
466+ if not is_lxbrand:
467+ # LP: #1243287. tests assume this runs, but running test on
468+ # arm would cause them all to fail.
469+ return ('LINUX', 'NODENAME', 'RELEASE', 'VERSION', 'x86_64')
470+ else:
471+ return ('LINUX', 'NODENAME', 'RELEASE', 'BRANDZ VIRTUAL LINUX',
472+ 'X86_64')
473
474 if sys_cfg is None:
475 sys_cfg = {}
476@@ -136,7 +143,6 @@
477 sys_cfg['datasource']['SmartOS'] = ds_cfg
478
479 self.apply_patches([(mod, 'LEGACY_USER_D', self.legacy_user_d)])
480- self.apply_patches([(mod, 'get_serial', mock.MagicMock())])
481 self.apply_patches([
482 (mod, 'JoyentMetadataClient', get_mock_client(mockdata))])
483 self.apply_patches([(mod, 'dmi_data', _dmi_data)])
484@@ -144,6 +150,7 @@
485 self.apply_patches([(mod, 'device_exists', lambda d: True)])
486 dsrc = mod.DataSourceSmartOS(sys_cfg, distro=None,
487 paths=self.paths)
488+ self.apply_patches([(dsrc, '_get_seed_file_object', mock.MagicMock())])
489 return dsrc
490
491 def test_seed(self):
492@@ -151,14 +158,29 @@
493 dsrc = self._get_ds()
494 ret = dsrc.get_data()
495 self.assertTrue(ret)
496+ self.assertEquals('kvm', dsrc.smartos_type)
497 self.assertEquals('/dev/ttyS1', dsrc.seed)
498
499+ def test_seed_lxbrand(self):
500+ # default seed should be /dev/ttyS1
501+ dsrc = self._get_ds(is_lxbrand=True)
502+ ret = dsrc.get_data()
503+ self.assertTrue(ret)
504+ self.assertEquals('lx-brand', dsrc.smartos_type)
505+ self.assertEquals('/native/.zonecontrol/metadata.sock', dsrc.seed)
506+
507 def test_issmartdc(self):
508 dsrc = self._get_ds()
509 ret = dsrc.get_data()
510 self.assertTrue(ret)
511 self.assertTrue(dsrc.is_smartdc)
512
513+ def test_issmartdc_lxbrand(self):
514+ dsrc = self._get_ds(is_lxbrand=True)
515+ ret = dsrc.get_data()
516+ self.assertTrue(ret)
517+ self.assertTrue(dsrc.is_smartdc)
518+
519 def test_no_base64(self):
520 ds_cfg = {'no_base64_decode': ['test_var1'], 'all_base': True}
521 dsrc = self._get_ds(ds_cfg=ds_cfg)
522@@ -169,7 +191,8 @@
523 dsrc = self._get_ds(mockdata=MOCK_RETURNS)
524 ret = dsrc.get_data()
525 self.assertTrue(ret)
526- self.assertEquals(DMI_DATA_RETURN[0], dsrc.metadata['instance-id'])
527+ self.assertEquals(MOCK_RETURNS['sdc:uuid'],
528+ dsrc.metadata['instance-id'])
529
530 def test_root_keys(self):
531 dsrc = self._get_ds(mockdata=MOCK_RETURNS)
532@@ -407,18 +430,6 @@
533 self.assertEqual(dsrc.device_name_to_device('FOO'),
534 mydscfg['disk_aliases']['FOO'])
535
536- @mock.patch('cloudinit.sources.DataSourceSmartOS.JoyentMetadataClient')
537- @mock.patch('cloudinit.sources.DataSourceSmartOS.get_serial')
538- def test_serial_console_closed_on_error(self, get_serial, metadata_client):
539- class OurException(Exception):
540- pass
541- metadata_client.side_effect = OurException
542- try:
543- DataSourceSmartOS.query_data('noun', 'device', 0)
544- except OurException:
545- pass
546- self.assertEqual(1, get_serial.return_value.close.call_count)
547-
548
549 def apply_patches(patches):
550 ret = []
551@@ -447,14 +458,25 @@
552 }
553
554 def make_response():
555- payload = ''
556- if self.response_parts['payload']:
557- payload = ' {0}'.format(self.response_parts['payload'])
558- del self.response_parts['payload']
559- return (
560- 'V2 {length} {crc} {request_id} {command}{payload}\n'.format(
561- payload=payload, **self.response_parts).encode('ascii'))
562- self.serial.readline.side_effect = make_response
563+ payloadstr = ''
564+ if 'payload' in self.response_parts:
565+ payloadstr = ' {0}'.format(self.response_parts['payload'])
566+ return ('V2 {length} {crc} {request_id} '
567+ '{command}{payloadstr}\n'.format(
568+ payloadstr=payloadstr,
569+ **self.response_parts).encode('ascii'))
570+
571+ self.metasource_data = None
572+
573+ def read_response(length):
574+ if not self.metasource_data:
575+ self.metasource_data = make_response()
576+ self.metasource_data_len = len(self.metasource_data)
577+ resp = self.metasource_data[:length]
578+ self.metasource_data = self.metasource_data[length:]
579+ return resp
580+
581+ self.serial.read.side_effect = read_response
582 self.patched_funcs.enter_context(
583 mock.patch('cloudinit.sources.DataSourceSmartOS.random.randint',
584 mock.Mock(return_value=self.request_id)))
585@@ -477,7 +499,9 @@
586 client.get_metadata('some_key')
587 self.assertEqual(1, self.serial.write.call_count)
588 written_line = self.serial.write.call_args[0][0]
589- self.assertEndsWith(written_line, b'\n')
590+ print(type(written_line))
591+ self.assertEndsWith(written_line.decode('ascii'),
592+ b'\n'.decode('ascii'))
593 self.assertEqual(1, written_line.count(b'\n'))
594
595 def _get_written_line(self, key='some_key'):
596@@ -489,7 +513,8 @@
597 self.assertIsInstance(self._get_written_line(), six.binary_type)
598
599 def test_get_metadata_line_starts_with_v2(self):
600- self.assertStartsWith(self._get_written_line(), b'V2')
601+ foo = self._get_written_line()
602+ self.assertStartsWith(foo.decode('ascii'), b'V2'.decode('ascii'))
603
604 def test_get_metadata_uses_get_command(self):
605 parts = self._get_written_line().decode('ascii').strip().split(' ')
606@@ -526,7 +551,7 @@
607 def test_get_metadata_reads_a_line(self):
608 client = self._get_client()
609 client.get_metadata('some_key')
610- self.assertEqual(1, self.serial.readline.call_count)
611+ self.assertEqual(self.metasource_data_len, self.serial.read.call_count)
612
613 def test_get_metadata_returns_valid_value(self):
614 client = self._get_client()