Merge lp:~barry/ubuntu-system-image/lp1277589-udm into lp:~registry/ubuntu-system-image/client

Proposed by Barry Warsaw
Status: Merged
Merged at revision: 234
Proposed branch: lp:~barry/ubuntu-system-image/lp1277589-udm
Merge into: lp:~registry/ubuntu-system-image/client
Diff against target: 540 lines (+203/-16)
11 files modified
NEWS.rst (+3/-0)
systemimage/api.py (+4/-0)
systemimage/dbus.py (+35/-3)
systemimage/download.py (+35/-1)
systemimage/service.py (+2/-1)
systemimage/testing/controller.py (+6/-2)
systemimage/testing/helpers.py (+8/-5)
systemimage/testing/nose.py (+10/-1)
systemimage/tests/data/config_03.ini (+1/-1)
systemimage/tests/data/index_24.json (+36/-0)
systemimage/tests/test_dbus.py (+63/-2)
To merge this branch: bzr merge lp:~barry/ubuntu-system-image/lp1277589-udm
Reviewer Review Type Date Requested Status
Registry Administrators Pending
Review via email: mp+207026@code.launchpad.net

Description of the change

More fixes for concurrency protection.

To post a comment you must log in.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'NEWS.rst'
2--- NEWS.rst 2014-02-14 22:47:33 +0000
3+++ NEWS.rst 2014-02-18 20:26:47 +0000
4@@ -17,6 +17,9 @@
5 * Return the empty string from `ApplyUpdate()` D-Bus method. This restores
6 the original API (patch merged from Ubuntu package, given by Didier
7 Roche). (LP: #1260768)
8+ * Request ubuntu-download-manager to download all files to temporary
9+ destinations, then atomically rename them into place. This avoids
10+ clobbering by multiple processes and mimic changes coming in u-d-m.
11
12 2.0.5 (2014-01-30)
13 ==================
14
15=== modified file 'systemimage/api.py'
16--- systemimage/api.py 2014-02-13 18:16:17 +0000
17+++ systemimage/api.py 2014-02-18 20:26:47 +0000
18@@ -73,6 +73,10 @@
19 self._update = None
20 self._callback = callback
21
22+ def __repr__(self):
23+ return '<Mediator at 0x{:x} | State at 0x{:x}>'.format(
24+ id(self), id(self._state))
25+
26 def cancel(self):
27 self._state.downloader.cancel()
28
29
30=== modified file 'systemimage/dbus.py'
31--- systemimage/dbus.py 2014-02-14 22:47:33 +0000
32+++ systemimage/dbus.py 2014-02-18 20:26:47 +0000
33@@ -23,6 +23,7 @@
34
35 import os
36 import sys
37+import logging
38 import traceback
39
40 from dbus.service import Object, method, signal
41@@ -35,6 +36,7 @@
42
43
44 EMPTYSTRING = ''
45+log = logging.getLogger('systemimage')
46
47
48 class Loop:
49@@ -69,6 +71,7 @@
50 super().__init__(bus, object_path)
51 self._loop = loop
52 self._api = Mediator(self._progress_callback)
53+ log.info('Mediator created {}', self._api)
54 self._checking = Lock()
55 self._update = None
56 self._downloading = False
57@@ -79,17 +82,22 @@
58
59 def _check_for_update(self):
60 # Asynchronous method call.
61+ log.info('Checking for update')
62 self._update = self._api.check_for_update()
63 # Do we have an update and can we auto-download it?
64 downloading = False
65 if self._update.is_available:
66 settings = Settings()
67 auto = settings.get('auto_download')
68+ log.info('Update available; auto-download: {}', auto)
69 if auto in ('1', '2'):
70 # XXX When we have access to the download service, we can
71 # check if we're on the wifi (auto == '1').
72- GLib.timeout_add(50, self._download)
73+ GLib.timeout_add(50, self._download, self._checking.release)
74 downloading = True
75+ else:
76+ log.info('release checking lock from _check_for_update()')
77+ self._checking.release()
78 self.UpdateAvailableStatus(
79 self._update.is_available,
80 downloading,
81@@ -100,7 +108,6 @@
82 # array of dictionaries data type. LP: #1215586
83 #self._update.descriptions,
84 "")
85- self._checking.release()
86 # Stop GLib from calling this method again.
87 return False
88
89@@ -122,12 +129,16 @@
90 """
91 self._loop.keepalive()
92 # Check-and-acquire the lock.
93+ log.info('test and acquire checking lock')
94 if not self._checking.acquire(blocking=False):
95 # Check is already in progress, so there's nothing more to do.
96+ log.info('checking lock not acquired')
97 return
98+ log.info('checking lock acquired')
99 # We've now acquired the lock. Reset any failure or in-progress
100 # state. Get a new mediator to reset any of its state.
101 self._api = Mediator(self._progress_callback)
102+ log.info('Mediator recreated {}', self._api)
103 self._failure_count = 0
104 self._last_error = ''
105 # Arrange for the actual check to happen in a little while, so that
106@@ -142,21 +153,25 @@
107 eta = 0
108 self.UpdateProgress(percentage, eta)
109
110- def _download(self):
111+ def _download(self, release_checking=None):
112 if self._downloading and self._paused:
113 self._api.resume()
114 self._paused = False
115+ log.info('Download previously paused')
116 return
117 if (self._downloading # Already in progress.
118 or self._update is None # Not yet checked.
119 or not self._update.is_available # No update available.
120 ):
121+ log.info('Download already in progress or not available')
122 return
123 if self._failure_count > 0:
124 self._failure_count += 1
125 self.UpdateFailed(self._failure_count, self._last_error)
126+ log.info('Update failure count: {}', self._failure_count)
127 return
128 self._downloading = True
129+ log.info('Update is downloading')
130 try:
131 # Always start by sending a UpdateProgress(0, 0). This is
132 # enough to get the u/i's attention.
133@@ -168,13 +183,20 @@
134 # value, but not the traceback.
135 self._last_error = EMPTYSTRING.join(
136 traceback.format_exception_only(*sys.exc_info()[:2]))
137+ log.info('Update failed: {}', self._last_error)
138 self.UpdateFailed(self._failure_count, self._last_error)
139 else:
140+ log.info('Update downloaded')
141 self.UpdateDownloaded()
142 self._failure_count = 0
143 self._last_error = ''
144 self._rebootable = True
145 self._downloading = False
146+ log.info('release checking lock from _download()')
147+ if release_checking is not None:
148+ # We were auto-downloading, so we now have to release the checking
149+ # lock. If we were manually downloading, there would be no lock.
150+ release_checking()
151 # Stop GLib from calling this method again.
152 return False
153
154@@ -297,31 +319,40 @@
155 #descriptions,
156 error_reason):
157 """Signal sent in response to a CheckForUpdate()."""
158+ log.info('EMIT UpdateAvailableStatus({}, {}, {}, {}, {}, {})',
159+ is_available, downloading, available_version, update_size,
160+ last_update_date, repr(error_reason))
161 self._loop.keepalive()
162
163 @signal('com.canonical.SystemImage', signature='id')
164 def UpdateProgress(self, percentage, eta):
165 """Download progress."""
166+ log.info('EMIT UpdateProgress({}, {})', percentage, eta)
167 self._loop.keepalive()
168
169 @signal('com.canonical.SystemImage')
170 def UpdateDownloaded(self):
171 """The update has been successfully downloaded."""
172+ log.info('EMIT UpdateDownloaded()')
173 self._loop.keepalive()
174
175 @signal('com.canonical.SystemImage', signature='is')
176 def UpdateFailed(self, consecutive_failure_count, last_reason):
177 """The update failed for some reason."""
178+ log.info('EMIT UpdateFailed({}, {})',
179+ consecutive_failure_count, repr(last_reason))
180 self._loop.keepalive()
181
182 @signal('com.canonical.SystemImage', signature='i')
183 def UpdatePaused(self, percentage):
184 """The download got paused."""
185+ log.info('EMIT UpdatePaused({})', percentage)
186 self._loop.keepalive()
187
188 @signal('com.canonical.SystemImage', signature='ss')
189 def SettingChanged(self, key, new_value):
190 """A setting value has change."""
191+ log.info('EMIT SettingChanged({}, {})', repr(key), repr(new_value))
192 self._loop.keepalive()
193
194 @signal('com.canonical.SystemImage', signature='b')
195@@ -329,3 +360,4 @@
196 """The system is rebooting."""
197 # We don't need to keep the loop alive since we're probably just going
198 # to shutdown anyway.
199+ log.info('EMIT Rebooting({})', status)
200
201=== modified file 'systemimage/download.py'
202--- systemimage/download.py 2014-02-13 18:16:17 +0000
203+++ systemimage/download.py 2014-02-18 20:26:47 +0000
204@@ -22,12 +22,16 @@
205 ]
206
207
208+import os
209 import dbus
210 import logging
211+import tempfile
212
213+from contextlib import ExitStack
214 from io import StringIO
215 from pprint import pformat
216 from systemimage.config import config
217+from systemimage.helpers import safe_remove
218 from systemimage.reactor import Reactor
219
220
221@@ -191,8 +195,25 @@
222 for url, dst in downloads:
223 print('\t{} -> {}'.format(url, dst), file=fp)
224 log.info('{}'.format(fp.getvalue()))
225+ # As a workaround for LP: #1277589, ask u-d-m to download the files to
226+ # .tmp files, and if they succeed, then atomically move them into
227+ # their real location.
228+ renames = []
229+ requests = []
230+ for url, dst in downloads:
231+ head, tail = os.path.split(dst)
232+ fd, path = tempfile.mkstemp(suffix='.tmp', prefix='', dir=head)
233+ os.close(fd)
234+ renames.append((path, dst))
235+ requests.append((url, path, ''))
236+ # mkstemp() creates the file system path, but if the files exist when
237+ # the group download is requested, ubuntu-download-manager will
238+ # complain and return an error. So, delete all temporary files now so
239+ # udm has a clear path to download to.
240+ for path, dst in renames:
241+ os.remove(path)
242 object_path = iface.createDownloadGroup(
243- [(url, dst, '') for url, dst in downloads],
244+ requests, # The temporary requests.
245 '', # No hashes yet.
246 False, # Don't allow GSM yet.
247 # https://bugs.freedesktop.org/show_bug.cgi?id=55594
248@@ -220,6 +241,19 @@
249 raise Canceled
250 if reactor.timed_out:
251 raise TimeoutError
252+ # Now that everything succeeded, rename the temporary files. Just to
253+ # be extra cautious, set up a context manager to safely remove all
254+ # temporary files in case of an error. If there are no errors, then
255+ # there will be nothing to remove.
256+ with ExitStack() as resources:
257+ for tmp, dst in renames:
258+ resources.callback(safe_remove, tmp)
259+ for tmp, dst in renames:
260+ os.rename(tmp, dst)
261+ # We only get here if all the renames succeeded, so there will be
262+ # no temporary files to remove, so we can throw away the new
263+ # ExitStack, which holds all the removals.
264+ resources.pop_all()
265
266 def cancel(self):
267 """Cancel any current downloads."""
268
269=== modified file 'systemimage/service.py'
270--- systemimage/service.py 2014-02-14 20:10:42 +0000
271+++ systemimage/service.py 2014-02-18 20:26:47 +0000
272@@ -30,7 +30,7 @@
273 from dbus.mainloop.glib import DBusGMainLoop
274 from pkg_resources import resource_string as resource_bytes
275 from systemimage.config import config
276-from systemimage.dbus import Loop, Service
277+from systemimage.dbus import Loop
278 from systemimage.helpers import makedirs
279 from systemimage.logging import initialize
280 from systemimage.main import DEFAULT_CONFIG_FILE
281@@ -114,6 +114,7 @@
282 config.dbus_service = get_service(
283 testing_mode, system_bus, '/Service', loop)
284 else:
285+ from systemimage.dbus import Service
286 config.dbus_service = Service(system_bus, '/Service', loop)
287 try:
288 loop.run()
289
290=== modified file 'systemimage/testing/controller.py'
291--- systemimage/testing/controller.py 2014-02-13 18:16:17 +0000
292+++ systemimage/testing/controller.py 2014-02-18 20:26:47 +0000
293@@ -125,7 +125,7 @@
294 class Controller:
295 """Start and stop D-Bus service under test."""
296
297- def __init__(self):
298+ def __init__(self, logfile=None):
299 # Non-public.
300 self._stack = ExitStack()
301 self._stoppers = []
302@@ -148,11 +148,15 @@
303 # We need a client.ini file for the subprocess.
304 ini_tmpdir = self._stack.enter_context(temporary_directory())
305 ini_vardir = self._stack.enter_context(temporary_directory())
306+ ini_logfile = (os.path.join(ini_tmpdir, 'client.log')
307+ if logfile is None
308+ else logfile)
309 self.ini_path = os.path.join(self.tmpdir, 'client.ini')
310 template = resource_bytes(
311 'systemimage.tests.data', 'config_03.ini').decode('utf-8')
312 with open(self.ini_path, 'w', encoding='utf-8') as fp:
313- print(template.format(tmpdir=ini_tmpdir, vardir=ini_vardir),
314+ print(template.format(tmpdir=ini_tmpdir, vardir=ini_vardir,
315+ logfile=ini_logfile),
316 file=fp)
317
318 def _configure_services(self):
319
320=== modified file 'systemimage/testing/helpers.py'
321--- systemimage/testing/helpers.py 2014-02-13 21:44:16 +0000
322+++ systemimage/testing/helpers.py 2014-02-18 20:26:47 +0000
323@@ -347,7 +347,7 @@
324 setup_keyring_txz(keyring + '.gpg', signing_kr, json_data, dst)
325
326
327-def setup_index(index, todir, keyring):
328+def setup_index(index, todir, keyring, write_callback=None):
329 for image in get_index(index).images:
330 for filerec in image.files:
331 path = (filerec.path[1:]
332@@ -355,10 +355,13 @@
333 else filerec.path)
334 dst = os.path.join(todir, path)
335 makedirs(os.path.dirname(dst))
336- contents = EMPTYSTRING.join(
337- os.path.splitext(filerec.path)[0].split('/'))
338- with open(dst, 'w', encoding='utf-8') as fp:
339- fp.write(contents)
340+ if write_callback is None:
341+ contents = EMPTYSTRING.join(
342+ os.path.splitext(filerec.path)[0].split('/'))
343+ with open(dst, 'w', encoding='utf-8') as fp:
344+ fp.write(contents)
345+ else:
346+ write_callback(dst)
347 # Sign with the specified signing key.
348 sign(dst, keyring)
349
350
351=== modified file 'systemimage/testing/nose.py'
352--- systemimage/testing/nose.py 2014-02-14 20:12:08 +0000
353+++ systemimage/testing/nose.py 2014-02-18 20:26:47 +0000
354@@ -75,15 +75,24 @@
355 super().__init__()
356 self.patterns = []
357 self.verbosity = 0
358+ self.log_file = None
359 self.addArgument(self.patterns, 'P', 'pattern',
360 'Add a test matching pattern')
361 def bump(ignore):
362 self.verbosity += 1
363 self.addFlag(bump, 'V', 'Verbosity',
364 'Increase system-image verbosity')
365+ def set_log_file(path):
366+ self.log_file = path[0]
367+ self.addOption(set_log_file, 'L', 'logfile',
368+ 'Set the log file for the test run',
369+ nargs=1)
370
371 @configuration
372 def startTestRun(self, event):
373+ from systemimage.config import config
374+ if self.log_file is not None:
375+ config.system.logfile = self.log_file
376 DBusGMainLoop(set_as_default=True)
377 initialize(verbosity=self.verbosity)
378 # We need to set up the dbus service controller, since all the tests
379@@ -92,7 +101,7 @@
380 # individual services, and we can write new dbus configuration files
381 # and HUP the dbus-launch to re-read them, but we cannot change bus
382 # addresses after the initial one is set.
383- SystemImagePlugin.controller = Controller()
384+ SystemImagePlugin.controller = Controller(self.log_file)
385 SystemImagePlugin.controller.start()
386 atexit.register(SystemImagePlugin.controller.stop)
387
388
389=== modified file 'systemimage/tests/data/config_03.ini'
390--- systemimage/tests/data/config_03.ini 2013-11-12 19:57:39 +0000
391+++ systemimage/tests/data/config_03.ini 2014-02-18 20:26:47 +0000
392@@ -14,7 +14,7 @@
393 timeout: 1s
394 build_file: {tmpdir}/ubuntu-build
395 tempdir: {tmpdir}/tmp
396-logfile: {tmpdir}/client.log
397+logfile: {logfile}
398 loglevel: info
399 settings_db: {vardir}/settings.db
400
401
402=== added file 'systemimage/tests/data/index_24.json'
403--- systemimage/tests/data/index_24.json 1970-01-01 00:00:00 +0000
404+++ systemimage/tests/data/index_24.json 2014-02-18 20:26:47 +0000
405@@ -0,0 +1,36 @@
406+{
407+ "global": {
408+ "generated_at": "Thu Aug 01 08:01:00 UTC 2013"
409+ },
410+ "images": [
411+ {
412+ "description": "Full",
413+ "files": [
414+ {
415+ "checksum": "5b05b298e974f3b9e40f0a1a8188f50984a4f18fb329e050324296632d3d9dfc",
416+ "order": 3,
417+ "path": "/3/4/5.txt",
418+ "signature": "/3/4/5.txt.asc",
419+ "size": 104857600
420+ },
421+ {
422+ "checksum": "5b05b298e974f3b9e40f0a1a8188f50984a4f18fb329e050324296632d3d9dfc",
423+ "order": 1,
424+ "path": "/4/5/6.txt",
425+ "signature": "/4/5/6.txt.asc",
426+ "size": 104857600
427+ },
428+ {
429+ "checksum": "5b05b298e974f3b9e40f0a1a8188f50984a4f18fb329e050324296632d3d9dfc",
430+ "order": 2,
431+ "path": "/5/6/7.txt",
432+ "signature": "/5/6/7.txt.asc",
433+ "size": 104857600
434+ }
435+ ],
436+ "type": "full",
437+ "version": 1600,
438+ "bootme": true
439+ }
440+ ]
441+}
442
443=== modified file 'systemimage/tests/test_dbus.py'
444--- systemimage/tests/test_dbus.py 2014-02-14 17:10:38 +0000
445+++ systemimage/tests/test_dbus.py 2014-02-18 20:26:47 +0000
446@@ -23,6 +23,7 @@
447 'TestDBusGetSet',
448 'TestDBusInfo',
449 'TestDBusInfoNoDetails',
450+ 'TestDBusLP1277589',
451 'TestDBusMockFailApply',
452 'TestDBusMockFailPause',
453 'TestDBusMockFailResume',
454@@ -122,6 +123,23 @@
455 self.quit()
456
457
458+class DoubleCheckingReactor(Reactor):
459+ def __init__(self, iface):
460+ super().__init__(dbus.SystemBus())
461+ self.iface = iface
462+ self.uas_signals = []
463+ self.react_to('UpdateAvailableStatus')
464+ self.react_to('UpdateDownloaded')
465+ self.schedule(self.iface.CheckForUpdate)
466+
467+ def _do_UpdateAvailableStatus(self, signal, path, *args, **kws):
468+ self.uas_signals.append(args)
469+ self.schedule(self.iface.CheckForUpdate)
470+
471+ def _do_UpdateDownloaded(self, *args, **kws):
472+ self.quit()
473+
474+
475 class _TestBase(unittest.TestCase):
476 """Base class for all DBus testing."""
477
478@@ -262,13 +280,14 @@
479 safe_remove(self.reboot_log)
480 super().tearDown()
481
482- def _prepare_index(self, index_file):
483+ def _prepare_index(self, index_file, write_callback=None):
484 serverdir = SystemImagePlugin.controller.serverdir
485 index_path = os.path.join(serverdir, 'stable', 'nexus7', 'index.json')
486 head, tail = os.path.split(index_path)
487 copy(index_file, head, tail)
488 sign(index_path, 'device-signing.gpg')
489- setup_index(index_file, serverdir, 'device-signing.gpg')
490+ setup_index(index_file, serverdir, 'device-signing.gpg',
491+ write_callback)
492
493 def _touch_build(self, version):
494 # Unlike the touch_build() helper, this one uses our own config object
495@@ -1542,3 +1561,45 @@
496 update 5.txt 5.txt.asc
497 unmount system
498 """)
499+
500+
501+class TestDBusLP1277589(_LiveTesting):
502+ def test_multiple_check_for_updates(self):
503+ # Log analysis of LP: #1277589 appears to show the following scenario,
504+ # reproduced in this test case:
505+ #
506+ # * Automatic updates are enabled.
507+ # * No image signing or image master keys are present.
508+ # * A full update is checked.
509+ # - A new image master key and image signing key is downloaded.
510+ # - Update is available
511+ #
512+ # Start by creating some big files which will take a while to
513+ # download.
514+ def write_callback(dst):
515+ # Write a 100 MiB sized file.
516+ with open(dst, 'wb') as fp:
517+ for i in range(25600):
518+ fp.write(b'x' * 4096)
519+ self._prepare_index('index_24.json', write_callback)
520+ # Create a reactor that will exit when the UpdateDownloaded signal is
521+ # received. We're going to issue a CheckForUpdate with automatic
522+ # updates enabled. As soon as we receive the UpdateAvailableStatus
523+ # signal, we'll immediately issue *another* CheckForUpdate, which
524+ # should run while the auto-download is working.
525+ #
526+ # At the end, we should not get another UpdateAvailableStatus signal,
527+ # but we should get the UpdateDownloaded signal.
528+ reactor = DoubleCheckingReactor(self.iface)
529+ reactor.run()
530+ self.assertEqual(len(reactor.uas_signals), 1)
531+ (is_available, downloading, available_version, update_size,
532+ last_update_date,
533+ #descriptions,
534+ error_reason) = reactor.uas_signals[0]
535+ self.assertTrue(is_available)
536+ self.assertTrue(downloading)
537+ self.assertEqual(available_version, '1600')
538+ self.assertEqual(update_size, 314572800)
539+ self.assertEqual(last_update_date, 'Unknown')
540+ self.assertEqual(error_reason, '')

Subscribers

People subscribed via source and target branches