Merge lp:~raharper/curtin/trunk.lp1635560 into lp:~curtin-dev/curtin/trunk

Proposed by Ryan Harper
Status: Merged
Merged at revision: 446
Proposed branch: lp:~raharper/curtin/trunk.lp1635560
Merge into: lp:~curtin-dev/curtin/trunk
Diff against target: 349 lines (+112/-51)
8 files modified
curtin/block/__init__.py (+7/-3)
curtin/commands/apply_net.py (+2/-2)
curtin/util.py (+13/-3)
tests/unittests/helpers.py (+24/-20)
tests/unittests/test_apt_source.py (+1/-1)
tests/unittests/test_block.py (+11/-10)
tests/unittests/test_util.py (+32/-1)
tests/vmtests/__init__.py (+22/-11)
To merge this branch: bzr merge lp:~raharper/curtin/trunk.lp1635560
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Needs Fixing
curtin developers Pending
Review via email: mp+314645@code.launchpad.net

Commit message

content decoding in load_file, apply_net raise exception on errors

This patch series fixes two issues. First, subcommands of the apply_net
command were exiting non-zero but we failed to raise and exception
which let curtin hide an error. We've modified apply_net to re-raise
the exception when it occurs. Additionally update vmtest to look for
stack-traces in the installation log and mark a test failed if it
detects one; this should prevent future cases from re-occurring.

The second error is when loading a file with and encoding, load_file
did not handle this case. Merge in a version of load_file from
cloud-init which already handles this case. Introduce new
unittests to validate the function as it touches some block related
code where we read partition data directly.

Finally, the mock_open feature of unittest.mock only supports
binary data in version 2.0.0 or newer, so skip this unittest on
systems without a new-enough mock; note this does not affect
the function of the code on the same release, only the unittest.

Description of the change

content decoding in load_file, apply_net raise exception on errors

This patch series fixes two issues. First, subcommands of the apply_net
command were exiting non-zero but we failed to raise and exception
which let curtin hide an error. We've modified apply_net to re-raise
the exception when it occurs. Additionally update vmtest to look for
stack-traces in the installation log and mark a test failed if it
detects one; this should prevent future cases from re-occurring.

The second error is when loading a file with and encoding, load_file
did not handle this case. Merge in a version of load_file from
cloud-init which already handles this case. Introduce new
unittests to validate the function as it touches some block related
code where we read partition data directly.

Finally, the mock_open feature of unittest.mock only supports
binary data in version 2.0.0 or newer, so skip this unittest on
systems without a new-enough mock; note this does not affect
the function of the code on the same release, only the unittest.

To post a comment you must log in.
lp:~raharper/curtin/trunk.lp1635560 updated
435. By Ryan Harper

merge from trunk

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
lp:~raharper/curtin/trunk.lp1635560 updated
436. By Ryan Harper

merge from trunk

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

I'm kind of worreid about a behavior change in default usage of load_file.
Previously load_file without a mode argument would open in 'r' mode. It would would always return a string, and would raise exception if the read in non-binary mode failed.

Now we're changing that to
 open in rb mode
 raise exception if decode() fails.

I'm afraid their might be subtle differences between what we had:
  open(file, "r").read()
and the new:
  open(file, "rb").read().decode("utf-8", "replace")

I don't have an example, but I'm afraid there might be differences.

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

On Fri, Jan 20, 2017 at 12:37 PM, Scott Moser <email address hidden> wrote:

> I'm kind of worreid about a behavior change in default usage of load_file.
> Previously load_file without a mode argument would open in 'r' mode. It
> would would always return a string, and would raise exception if the read
> in non-binary mode failed.
>
> Now we're changing that to
> open in rb mode
> raise exception if decode() fails.
>
> I'm afraid their might be subtle differences between what we had:
> open(file, "r").read()
> and the new:
> open(file, "rb").read().decode("utf-8", "replace")
>
> I don't have an example, but I'm afraid there might be differences.
>

Agreed, though this is how we do this in cloud-init.
I've found places in code where we do use load_file with the expected
non-decoding case
and have run vmtests to verify that all that still works.

>
> Diff comments:
>
> >
> > === modified file 'curtin/util.py'
> > --- curtin/util.py 2016-11-30 16:00:39 +0000
> > +++ curtin/util.py 2017-01-19 17:21:37 +0000
> > @@ -321,11 +321,23 @@
> > os.chmod(filename, mode)
> >
> >
> > -def load_file(path, mode="r", read_len=None, offset=0):
> > +def load_file(path, mode="rb", read_len=None, offset=0, decode=True):
> > with open(path, mode) as fp:
> > if offset:
> > fp.seek(offset)
> > - return fp.read(read_len) if read_len else fp.read()
> > + contents = fp.read(read_len) if read_len else fp.read()
> > +
> > + if decode:
>
> maybe:
> if decode and 'b' in mode
>

OK.

>
> > + return decode_binary(contents)
> > + else:
> > + return contents
> > +
> > +
>
> the decode_binary in cloud-init is someewhat of a hack, and i'd much
> rather not have it there.
> Instead, I'd rather have the caller know what they have and decode if they
> need it rather than the 'isinstance' test.
>

Well, the instance check is the workaround of not using six for byte vs.
string between python 2 and 3.
I think it makes as-is; the point of util is handle some of these
complexities to simplify the code elsewhere.

> I'm ok with decode_binary, but i'd prefer it not to do the isinstance
> chec,.
>

Hrm, I not sure this works without the check across py2 and py3 .

>
> > +def decode_binary(blob, encoding='utf-8', errors='replace'):
> > + # Converts a binary type into a text type using given encoding.
> > + if isinstance(blob, string_types):
> > + return blob
> > + return blob.decode(encoding, errors=errors)
> >
> >
> > def file_size(path):
>
>
> --
> https://code.launchpad.net/~raharper/curtin/trunk.lp1635560/+merge/314645
> You are the owner of lp:~raharper/curtin/trunk.lp1635560.
>

lp:~raharper/curtin/trunk.lp1635560 updated
437. By Ryan Harper

vmtest: make install-log error checking more obvious

438. By Ryan Harper

Modify known error message to not trip install failure detection

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
lp:~raharper/curtin/trunk.lp1635560 updated
439. By Ryan Harper

util: only call decode_binary when needed, decode_binary only decodes binary

440. By Ryan Harper

merge from trunk

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
lp:~raharper/curtin/trunk.lp1635560 updated
441. By Ryan Harper

merge from trunk

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

I guess I approve, with the one question inline.
I do think our load_file is kind of wierd still though..
cloud-init takes a 'decode' parameter, but does not take a open param.
Here, though, we take both mode and decode.

They seem to do mostly the same thing (and if they dont, i'd rather handle that specifically).

Ie, best case scenario is that:
  load_file("file", mode="rb", decode=True) == load_file("file", mode="r", decode=False).
The worse cases is if that is subtly different.

Can we just drop the mode parameter and alway suse 'rb' ?

Then the caller gets a string if they pass decode=True, and gets bytes otherwise.

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

On Mon, Jan 30, 2017 at 10:26 AM, Scott Moser <email address hidden> wrote:

> I guess I approve, with the one question inline.
> I do think our load_file is kind of wierd still though..
> cloud-init takes a 'decode' parameter, but does not take a open param.
>

mode

> Here, though, we take both mode and decode.
>
> They seem to do mostly the same thing (and if they dont, i'd rather handle
> that specifically).
>
> Ie, best case scenario is that:
> load_file("file", mode="rb", decode=True) == load_file("file", mode="r",
> decode=False).
> The worse cases is if that is subtly different.
>

These are worth unittests to confirm; I'll add those;

>
> Can we just drop the mode parameter and alway suse 'rb' ?
>
> Then the caller gets a string if they pass decode=True, and gets bytes
> otherwise.
>

The one case, (for which we don't have a user at the moment) is if someone
wants to do
something with decoding differently than what the default is then they'll
need mode='rb'
and decode=False;

In general, what decision can we make when we get mode='r' and decode=True ?
Was that default or did the caller really specify that?

If we don't care about that specifically; and I don't know that we need to,
then it
does seem like we can use decode boolean and drop mode.

>
>
>
> Diff comments:
>
> > === modified file 'curtin/block/__init__.py'
> > --- curtin/block/__init__.py 2016-11-18 15:12:19 +0000
> > +++ curtin/block/__init__.py 2017-01-27 21:00:47 +0000
> > @@ -375,10 +375,10 @@
> > cmd = ['blockdev', '--rereadpt'] + devices
> > try:
> > util.subp(cmd, capture=True)
> > - except util.ProcessExecutionError as e:
> > + except util.ProcessExecutionError:
> > # FIXME: its less than ideal to swallow this error, but until
> > # we fix LP: #1489521 we kind of need to.
> > - LOG.warn("rescanning devices failed: %s", e)
> > + LOG.warn("Error rescanning devices, possibly known issue LP:
> #1489521")
>
> is there a reason not to log the error ?
>

Currently we have a comment that indicated that we don't care about the
error at this time
The message getting logged tripped the vmtest check_install_log regex which
indicated the
deployment failed. If we don't care about this error, then I think we can
skip the logging here.

> if you dont log the error, then the exit code and stderr and stdout get
> completely lost.
>

If we want to log the exit-code and stderr,stdout we can; it was the
python stacktrace
that got dumped via printing the exception that triggered the failure of a
test.

> >
> > udevadm_settle()
> >
>
>
> --
> https://code.launchpad.net/~raharper/curtin/trunk.lp1635560/+merge/314645
> You are the owner of lp:~raharper/curtin/trunk.lp1635560.
>

lp:~raharper/curtin/trunk.lp1635560 updated
442. By Ryan Harper

util.load_file: set mode based on decode parameter

443. By Ryan Harper

util.load_file: drop mode parameter, always use 'rb'

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Ryan Harper (raharper) wrote :

Re-run vmtest on diglett after revno 443:

----------------------------------------------------------------------
Ran 1015 tests in 8278.084s

OK (SKIP=1)
Mon, 30 Jan 2017 14:18:28 -0600: vmtest end [0] in 8280s

lp:~raharper/curtin/trunk.lp1635560 updated
444. By Ryan Harper

merge from trunk

445. By Ryan Harper

Add comment when logging expected error; don't pass decode=False in block_info

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'curtin/block/__init__.py'
2--- curtin/block/__init__.py 2017-02-01 15:10:13 +0000
3+++ curtin/block/__init__.py 2017-02-06 19:55:25 +0000
4@@ -392,7 +392,11 @@
5 except util.ProcessExecutionError as e:
6 # FIXME: its less than ideal to swallow this error, but until
7 # we fix LP: #1489521 we kind of need to.
8- LOG.warn("rescanning devices failed: %s", e)
9+ LOG.warn("Error rescanning devices, possibly known issue LP: #1489521")
10+ # Reformatting the execption output so as to not trigger
11+ # vmtest scanning for Unexepected errors in install logfile
12+ LOG.warn("cmd: %s\nstdout:%s\nstderr:%s\nexit_code:%s", e.cmd,
13+ e.stdout, e.stderr, e.exit_code)
14
15 udevadm_settle()
16
17@@ -702,7 +706,7 @@
18 # this signature must be at 0x1fe
19 # https://en.wikipedia.org/wiki/Master_boot_record#Sector_layout
20 return (is_block_device(device) and util.file_size(device) >= 0x200 and
21- (util.load_file(device, mode='rb', read_len=2, offset=0x1fe) ==
22+ (util.load_file(device, decode=False, read_len=2, offset=0x1fe) ==
23 b'\x55\xAA'))
24
25
26@@ -720,7 +724,7 @@
27 sector_size = get_blockdev_sector_size(device)[0]
28 return (is_block_device(device) and
29 util.file_size(device) >= 2 * sector_size and
30- (util.load_file(device, mode='rb', read_len=8,
31+ (util.load_file(device, decode=False, read_len=8,
32 offset=sector_size) == b'EFI PART'))
33
34
35
36=== modified file 'curtin/commands/apply_net.py'
37--- curtin/commands/apply_net.py 2016-08-29 18:27:32 +0000
38+++ curtin/commands/apply_net.py 2017-02-06 19:55:25 +0000
39@@ -160,7 +160,7 @@
40 except:
41 msg = bmsg + " %s exists, but could not be read." % cfg
42 LOG.exception(msg)
43- return
44+ raise
45
46
47 def _maybe_remove_legacy_eth0(target,
48@@ -194,7 +194,7 @@
49 except:
50 msg = bmsg + " %s exists, but could not be read." % cfg
51 LOG.exception(msg)
52- return
53+ raise
54
55 LOG.warn(msg)
56
57
58=== modified file 'curtin/util.py'
59--- curtin/util.py 2017-02-02 22:52:01 +0000
60+++ curtin/util.py 2017-02-06 19:55:25 +0000
61@@ -337,11 +337,21 @@
62 os.chmod(filename, mode)
63
64
65-def load_file(path, mode="r", read_len=None, offset=0):
66- with open(path, mode) as fp:
67+def load_file(path, read_len=None, offset=0, decode=True):
68+ with open(path, "rb") as fp:
69 if offset:
70 fp.seek(offset)
71- return fp.read(read_len) if read_len else fp.read()
72+ contents = fp.read(read_len) if read_len else fp.read()
73+
74+ if decode:
75+ return decode_binary(contents)
76+ else:
77+ return contents
78+
79+
80+def decode_binary(blob, encoding='utf-8', errors='replace'):
81+ # Converts a binary type into a text type using given encoding.
82+ return blob.decode(encoding, errors=errors)
83
84
85 def file_size(path):
86
87=== modified file 'tests/unittests/helpers.py'
88--- tests/unittests/helpers.py 2016-09-16 18:54:28 +0000
89+++ tests/unittests/helpers.py 2017-02-06 19:55:25 +0000
90@@ -14,28 +14,32 @@
91 #
92 # You should have received a copy of the GNU Affero General Public License
93 # along with Curtin. If not, see <http://www.gnu.org/licenses/>.
94+
95+import contextlib
96+import imp
97+import importlib
98 import mock
99
100
101-class mocked_open(object):
102- # older versions of mock can't really mock the builtin 'open' easily.
103- def __init__(self):
104- self.mocked = None
105-
106- def __enter__(self):
107- if self.mocked:
108- return self.mocked.start()
109-
110- py2_p = '__builtin__.open'
111- py3_p = 'builtins.open'
112+def builtin_module_name():
113+ options = ('builtins', '__builtin__')
114+ for name in options:
115 try:
116- self.mocked = mock.patch(py2_p, new_callable=mock.mock_open())
117- return self.mocked.start()
118+ imp.find_module(name)
119 except ImportError:
120- self.mocked = mock.patch(py3_p, new_callable=mock.mock_open())
121- return self.mocked.start()
122-
123- def __exit__(self, etype, value, trace):
124- if self.mocked:
125- self.mocked.stop()
126- self.mocked = None
127+ continue
128+ else:
129+ print('importing and returning: %s' % name)
130+ importlib.import_module(name)
131+ return name
132+
133+
134+@contextlib.contextmanager
135+def simple_mocked_open(content=None):
136+ if not content:
137+ content = ''
138+ m_open = mock.mock_open(read_data=content)
139+ mod_name = builtin_module_name()
140+ m_patch = '{}.open'.format(mod_name)
141+ with mock.patch(m_patch, m_open, create=True):
142+ yield m_open
143
144=== modified file 'tests/unittests/test_apt_source.py'
145--- tests/unittests/test_apt_source.py 2017-02-02 22:52:01 +0000
146+++ tests/unittests/test_apt_source.py 2017-02-06 19:55:25 +0000
147@@ -42,7 +42,7 @@
148 load file and return content after decoding
149 """
150 try:
151- content = util.load_file(filename, mode="r")
152+ content = util.load_file(filename, decode=True)
153 except Exception as error:
154 print('failed to load file content for test: %s' % error)
155 raise
156
157=== modified file 'tests/unittests/test_block.py'
158--- tests/unittests/test_block.py 2017-02-01 00:37:15 +0000
159+++ tests/unittests/test_block.py 2017-02-06 19:55:25 +0000
160@@ -8,7 +8,7 @@
161
162 from collections import OrderedDict
163
164-from .helpers import mocked_open
165+from .helpers import simple_mocked_open
166 from curtin import util
167 from curtin import block
168
169@@ -210,7 +210,8 @@
170 myfile = self.tfile("def_zero")
171 util.write_file(myfile, flen * b'\1', omode="wb")
172 block.wipe_file(myfile)
173- found = util.load_file(myfile, mode="rb")
174+ with open(myfile, mode="rb") as fh:
175+ found = fh.read()
176 self.assertEqual(found, flen * b'\0')
177
178 def test_reader_used(self):
179@@ -223,7 +224,8 @@
180 # populate with nulls
181 util.write_file(myfile, flen * b'\0', omode="wb")
182 block.wipe_file(myfile, reader=reader, buflen=flen)
183- found = util.load_file(myfile, mode="rb")
184+ with open(myfile, mode="rb") as fh:
185+ found = fh.read()
186 self.assertEqual(found, flen * b'\1')
187
188 def test_reader_twice(self):
189@@ -239,7 +241,8 @@
190 myfile = self.tfile("reader_twice")
191 util.write_file(myfile, flen * b'\xff', omode="wb")
192 block.wipe_file(myfile, reader=reader, buflen=20)
193- found = util.load_file(myfile, mode="rb")
194+ with open(myfile, mode="rb") as fh:
195+ found = fh.read()
196 self.assertEqual(found, expected)
197
198 def test_reader_fhandle(self):
199@@ -346,15 +349,13 @@
200
201 @mock.patch('curtin.block.wipe_file')
202 def test_wipe_zero(self, mock_wipe_file):
203- with mocked_open() as mock_open:
204+ with simple_mocked_open():
205 block.wipe_volume(self.dev, mode='zero')
206 mock_wipe_file.assert_called_with(self.dev)
207- mock_open.return_value = mock.MagicMock()
208
209 @mock.patch('curtin.block.wipe_file')
210 def test_wipe_random(self, mock_wipe_file):
211- with mocked_open() as mock_open:
212- mock_open.return_value = mock.MagicMock()
213+ with simple_mocked_open() as mock_open:
214 block.wipe_volume(self.dev, mode='random')
215 mock_open.assert_called_with('/dev/urandom', 'rb')
216 mock_wipe_file.assert_called_with(
217@@ -436,8 +437,8 @@
218 gpt_content_4k = b'\x00' * 0x800 + b'EFI PART' + b'\x00' * (0x800 - 8)
219 null_content = b'\x00' * 0xf00
220
221- def _test_util_load_file(self, content, device, mode, read_len, offset):
222- return (bytes if 'b' in mode else str)(content[offset:offset+read_len])
223+ def _test_util_load_file(self, content, device, read_len, offset, decode):
224+ return (bytes if not decode else str)(content[offset:offset+read_len])
225
226 @mock.patch('curtin.block.check_dos_signature')
227 @mock.patch('curtin.block.check_efi_signature')
228
229=== modified file 'tests/unittests/test_util.py'
230--- tests/unittests/test_util.py 2016-08-29 21:32:21 +0000
231+++ tests/unittests/test_util.py 2017-02-06 19:55:25 +0000
232@@ -1,4 +1,4 @@
233-from unittest import TestCase
234+from unittest import TestCase, skipIf
235 import mock
236 import os
237 import stat
238@@ -6,6 +6,7 @@
239 import tempfile
240
241 from curtin import util
242+from .helpers import simple_mocked_open
243
244
245 class TestLogTimer(TestCase):
246@@ -460,4 +461,34 @@
247 m_subp.assert_called_with(cmd, target=target)
248
249
250+class TestLoadFile(TestCase):
251+ """Test utility 'load_file'"""
252+
253+ def test_load_file_simple(self):
254+ fname = 'test.cfg'
255+ contents = "#curtin-config"
256+ with simple_mocked_open(content=contents) as m_open:
257+ loaded_contents = util.load_file(fname, decode=False)
258+ self.assertEqual(contents, loaded_contents)
259+ m_open.assert_called_with(fname, 'rb')
260+
261+ @skipIf(mock.__version__ < '2.0.0', "mock version < 2.0.0")
262+ def test_load_file_handles_utf8(self):
263+ fname = 'test.cfg'
264+ contents = b'd\xc3\xa9j\xc8\xa7'
265+ with simple_mocked_open(content=contents) as m_open:
266+ with open(fname, 'rb') as f:
267+ self.assertEqual(f.read(), contents)
268+ m_open.assert_called_with(fname, 'rb')
269+
270+ @skipIf(mock.__version__ < '2.0.0', "mock version < 2.0.0")
271+ @mock.patch('curtin.util.decode_binary')
272+ def test_load_file_respects_decode_false(self, mock_decode):
273+ fname = 'test.cfg'
274+ contents = b'start \xc3\xa9 end'
275+ with simple_mocked_open(contents):
276+ loaded_contents = util.load_file(fname, decode=False)
277+ self.assertEqual(type(loaded_contents), bytes)
278+ self.assertEqual(loaded_contents, contents)
279+
280 # vi: ts=4 expandtab syntax=python
281
282=== modified file 'tests/vmtests/__init__.py'
283--- tests/vmtests/__init__.py 2017-01-27 16:13:17 +0000
284+++ tests/vmtests/__init__.py 2017-02-06 19:55:25 +0000
285@@ -546,10 +546,11 @@
286 install_log = lfh.read().decode('utf-8', errors='replace')
287 errmsg, errors = check_install_log(install_log)
288 if errmsg:
289+ logger.error('Found error: ' + errmsg)
290 for e in errors:
291- logger.error(e)
292- logger.error(errmsg)
293- raise Exception(cls.__name__ + ":" + errmsg)
294+ logger.error('Context:\n' + e)
295+ raise Exception(cls.__name__ + ":" + errmsg +
296+ '\n'.join(errors))
297 else:
298 logger.info('Install OK')
299 else:
300@@ -912,6 +913,14 @@
301 raise exc
302
303
304+def find_error_context(err_match, contents, nrchars=200):
305+ context_start = err_match.start() - nrchars
306+ context_end = err_match.end() + nrchars
307+ # extract contents, split into lines, drop the first and last partials
308+ # recombine and return
309+ return "\n".join(contents[context_start:context_end].splitlines()[1:-1])
310+
311+
312 def check_install_log(install_log):
313 # look if install is OK via curtin 'Installation ok"
314 # if we dont find that, scan for known error messages and report
315@@ -925,17 +934,18 @@
316 'Installation\ failed',
317 'ImportError: No module named.*',
318 'Unexpected error while running command',
319- 'E: Unable to locate package.*']))
320+ 'E: Unable to locate package.*',
321+ 'Traceback.*most recent call last.*:']))
322
323 install_is_ok = re.findall(install_pass, install_log)
324+ # always scan for errors
325+ found_errors = re.finditer(install_fail, install_log)
326 if len(install_is_ok) == 0:
327- errors = re.findall(install_fail, install_log)
328- if len(errors) > 0:
329- for e in errors:
330- logger.error(e)
331- errmsg = ('Errors during curtin installer')
332- else:
333- errmsg = ('Failed to verify Installation is OK')
334+ errmsg = ('Failed to verify Installation is OK')
335+
336+ for e in found_errors:
337+ errors.append(find_error_context(e, install_log))
338+ errmsg = ('Errors during curtin installer')
339
340 return errmsg, errors
341
342@@ -1011,6 +1021,7 @@
343 { shutdown -P now "Shutting down on centos"; }
344 [ "$(lsb_release -sc)" = "precise" ] &&
345 { shutdown -P now "Shutting down on precise"; }
346+ exit 0;
347 """)
348
349 scripts = ([collect_prep] + collect_scripts + [collect_post] +

Subscribers

People subscribed via source and target branches