Merge ~raharper/cloud-init:ds-ovf-use-util-find-devs-with into cloud-init:master

Proposed by Ryan Harper
Status: Merged
Approved by: Scott Moser
Approved revision: 6e2771370102fbed46fba7c077fa9c328d02ecaa
Merged at revision: da6562e21d0b17a0957adc0c5a2c9da076e0d219
Proposed branch: ~raharper/cloud-init:ds-ovf-use-util-find-devs-with
Merge into: cloud-init:master
Diff against target: 309 lines (+221/-27)
3 files modified
cloudinit/sources/DataSourceOVF.py (+47/-27)
cloudinit/tests/helpers.py (+10/-0)
tests/unittests/test_datasource/test_ovf.py (+164/-0)
Reviewer Review Type Date Requested Status
Scott Moser Approve
Server Team CI bot continuous-integration Approve
Review via email: mp+330995@code.launchpad.net

Description of the change

DataSourceOVF: use util.find_devs_with(TYPE=iso9660)

DataSourceOVF attempts to find iso files via walking os.listdir('/dev/')
which is far too wide. This approach is too invasive and can sometimes
race with systemd attempting to fsck and mount devices.

Instead, utilize cloudinit.util.find_devs_with to filter devices by
criteria (which uses blkid under the covers). This should result in fewer
attempts to mount block devices which are not actual iso filesystems.

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

I'm pretty sure that we end up trying to mount everything with:
   mount -t iso966o <device> </tmp/mountpoint>

so i'd have thought that that would fail really early, i'm not sure how this would cause issues with other mounts, unless there was actually iso9660 filesystems on those devices. which would be odd.

also, please file a bug

Revision history for this message
Scott Moser (smoser) wrote :

also seems quite possible that just changing the 'default_regex' to drop 'xvd.*' would probably fix ec2. that explains why we only see this on ec2 and not elsewhere (other than places that have 'hd' disks, which is unlikely at this point in the world).

you can/should/might-as-well keep the regex in play, further filtering results of blkid. by doing so, you safely filter out other devices that were never considered before.

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

As it turns out, two parallel instances of /bin/mount pointing to the same device will cause one to fail; it appears that there is some sort of locking/ref-counting during a mount operation that results in one of the two mount processes getting EBUSY as a result.

This sort of race is recreatable outside of cloud-init:

# rw mount loop
while true; do
    umount -f ${MNT_POINT} |:
    mount ${DISK} ${MNT_POINT} -t ext4 -o defaults || {
     echo "`date +%s.%N`: mount failed on COUNT=$COUNT";
        exit 1
    }
    echo "`date +%s.%N`: COUNT=$COUNT" > ${MNT_POINT}/lasttouch
    COUNT=$(($COUNT + 1))
done

# ro mount loop
while true; do
    dd if=${DISK} bs=512 count=1 of=/dev/null
    # we expect this to fail, that's OK
    mount -o ro,sync -t iso9660 ${DISK} ${MNT_POINT} |:
    echo "`date +%s.%N`: READ COUNT=$COUNT"
    COUNT=$(($COUNT + 1))
done

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

I've kept the regex filter as suggested and added unittests.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:2699208b16ad63b924eee748ff3d39510efc1559
https://jenkins.ubuntu.com/server/job/cloud-init-ci/334/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/334/rebuild

review: Approve (continuous-integration)
Revision history for this message
Ryan Harper (raharper) wrote :

> As it turns out, two parallel instances of /bin/mount pointing
> to the same device will cause one to fail; it appears that there
> is some sort of locking/ref-counting during a mount operation
> that results in one of the two mount processes getting EBUSY as a result.

In particular, when we open the block device, the kernel sets
FMODE_EXCL flag which prevents additional opens of the same device
where the filesystem is of a different type.

This is expected behavior by the kernel (and our setup).

Revision history for this message
Scott Moser (smoser) wrote :

some small comments.

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

I'll add the os.path.basename(); should we do that in both places?

Revision history for this message
Scott Moser (smoser) wrote :

clean up the comment i guess. we can use os.path.basename in both places.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:6e2771370102fbed46fba7c077fa9c328d02ecaa
https://jenkins.ubuntu.com/server/job/cloud-init-ci/336/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/336/rebuild

review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

thanks! Approve.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/sources/DataSourceOVF.py b/cloudinit/sources/DataSourceOVF.py
2index 24b45d5..ccebf11 100644
3--- a/cloudinit/sources/DataSourceOVF.py
4+++ b/cloudinit/sources/DataSourceOVF.py
5@@ -375,26 +375,56 @@ def get_ovf_env(dirname):
6 return (None, False)
7
8
9-# Transport functions take no input and return
10-# a 3 tuple of content, path, filename
11-def transport_iso9660(require_iso=True):
12+def maybe_cdrom_device(devname):
13+ """Test if devname matches known list of devices which may contain iso9660
14+ filesystems.
15
16- # default_regex matches values in
17- # /lib/udev/rules.d/60-cdrom_id.rules
18- # KERNEL!="sr[0-9]*|hd[a-z]|xvd*", GOTO="cdrom_end"
19- envname = "CLOUD_INIT_CDROM_DEV_REGEX"
20- default_regex = "^(sr[0-9]+|hd[a-z]|xvd.*)"
21+ Be helpful in accepting either knames (with no leading /dev/) or full path
22+ names, but do not allow paths outside of /dev/, like /dev/foo/bar/xxx.
23+ """
24+ if not devname:
25+ return False
26+ elif not isinstance(devname, util.string_types):
27+ raise ValueError("Unexpected input for devname: %s" % devname)
28+
29+ # resolve '..' and multi '/' elements
30+ devname = os.path.normpath(devname)
31
32- devname_regex = os.environ.get(envname, default_regex)
33+ # drop leading '/dev/'
34+ if devname.startswith("/dev/"):
35+ # partition returns tuple (before, partition, after)
36+ devname = devname.partition("/dev/")[-1]
37+
38+ # ignore leading slash (/sr0), else fail on / in name (foo/bar/xvdc)
39+ if devname.startswith("/"):
40+ devname = devname.split("/")[-1]
41+ elif devname.count("/") > 0:
42+ return False
43+
44+ # if empty string
45+ if not devname:
46+ return False
47+
48+ # default_regex matches values in /lib/udev/rules.d/60-cdrom_id.rules
49+ # KERNEL!="sr[0-9]*|hd[a-z]|xvd*", GOTO="cdrom_end"
50+ default_regex = r"^(sr[0-9]+|hd[a-z]|xvd.*)"
51+ devname_regex = os.environ.get("CLOUD_INIT_CDROM_DEV_REGEX", default_regex)
52 cdmatch = re.compile(devname_regex)
53
54+ return cdmatch.match(devname) is not None
55+
56+
57+# Transport functions take no input and return
58+# a 3 tuple of content, path, filename
59+def transport_iso9660(require_iso=True):
60+
61 # Go through mounts to see if it was already mounted
62 mounts = util.mounts()
63 for (dev, info) in mounts.items():
64 fstype = info['fstype']
65 if fstype != "iso9660" and require_iso:
66 continue
67- if cdmatch.match(dev[5:]) is None: # take off '/dev/'
68+ if not maybe_cdrom_device(dev):
69 continue
70 mp = info['mountpoint']
71 (fname, contents) = get_ovf_env(mp)
72@@ -406,29 +436,19 @@ def transport_iso9660(require_iso=True):
73 else:
74 mtype = None
75
76- devs = os.listdir("/dev/")
77- devs.sort()
78+ # generate a list of devices with mtype filesystem, filter by regex
79+ devs = [dev for dev in
80+ util.find_devs_with("TYPE=%s" % mtype if mtype else None)
81+ if maybe_cdrom_device(dev)]
82 for dev in devs:
83- fullp = os.path.join("/dev/", dev)
84-
85- if (fullp in mounts or
86- not cdmatch.match(dev) or os.path.isdir(fullp)):
87- continue
88-
89- try:
90- # See if we can read anything at all...??
91- util.peek_file(fullp, 512)
92- except IOError:
93- continue
94-
95 try:
96- (fname, contents) = util.mount_cb(fullp, get_ovf_env, mtype=mtype)
97+ (fname, contents) = util.mount_cb(dev, get_ovf_env, mtype=mtype)
98 except util.MountFailedError:
99- LOG.debug("%s not mountable as iso9660", fullp)
100+ LOG.debug("%s not mountable as iso9660", dev)
101 continue
102
103 if contents is not False:
104- return (contents, fullp, fname)
105+ return (contents, dev, fname)
106
107 return (False, None, None)
108
109diff --git a/cloudinit/tests/helpers.py b/cloudinit/tests/helpers.py
110index 28e2662..6f88a5b 100644
111--- a/cloudinit/tests/helpers.py
112+++ b/cloudinit/tests/helpers.py
113@@ -104,6 +104,16 @@ class TestCase(unittest2.TestCase):
114 super(TestCase, self).setUp()
115 self.reset_global_state()
116
117+ def add_patch(self, target, attr, **kwargs):
118+ """Patches specified target object and sets it as attr on test
119+ instance also schedules cleanup"""
120+ if 'autospec' not in kwargs:
121+ kwargs['autospec'] = True
122+ m = mock.patch(target, **kwargs)
123+ p = m.start()
124+ self.addCleanup(m.stop)
125+ setattr(self, attr, p)
126+
127
128 class CiTestCase(TestCase):
129 """This is the preferred test case base class unless user
130diff --git a/tests/unittests/test_datasource/test_ovf.py b/tests/unittests/test_datasource/test_ovf.py
131index 9dbf4dd..700da86 100644
132--- a/tests/unittests/test_datasource/test_ovf.py
133+++ b/tests/unittests/test_datasource/test_ovf.py
134@@ -5,6 +5,7 @@
135 # This file is part of cloud-init. See LICENSE file for license information.
136
137 import base64
138+from collections import OrderedDict
139
140 from cloudinit.tests import helpers as test_helpers
141
142@@ -70,4 +71,167 @@ class TestReadOvfEnv(test_helpers.TestCase):
143 self.assertEqual({'password': "passw0rd"}, cfg)
144 self.assertIsNone(ud)
145
146+
147+class TestTransportIso9660(test_helpers.CiTestCase):
148+
149+ def setUp(self):
150+ super(TestTransportIso9660, self).setUp()
151+ self.add_patch('cloudinit.util.find_devs_with',
152+ 'm_find_devs_with')
153+ self.add_patch('cloudinit.util.mounts', 'm_mounts')
154+ self.add_patch('cloudinit.util.mount_cb', 'm_mount_cb')
155+ self.add_patch('cloudinit.sources.DataSourceOVF.get_ovf_env',
156+ 'm_get_ovf_env')
157+ self.m_get_ovf_env.return_value = ('myfile', 'mycontent')
158+
159+ def test_find_already_mounted(self):
160+ """Check we call get_ovf_env from on matching mounted devices"""
161+ mounts = {
162+ '/dev/sr9': {
163+ 'fstype': 'iso9660',
164+ 'mountpoint': 'wark/media/sr9',
165+ 'opts': 'ro',
166+ }
167+ }
168+ self.m_mounts.return_value = mounts
169+
170+ (contents, fullp, fname) = dsovf.transport_iso9660()
171+ self.assertEqual("mycontent", contents)
172+ self.assertEqual("/dev/sr9", fullp)
173+ self.assertEqual("myfile", fname)
174+
175+ def test_find_already_mounted_skips_non_iso9660(self):
176+ """Check we call get_ovf_env ignoring non iso9660"""
177+ mounts = {
178+ '/dev/xvdb': {
179+ 'fstype': 'vfat',
180+ 'mountpoint': 'wark/foobar',
181+ 'opts': 'defaults,noatime',
182+ },
183+ '/dev/xvdc': {
184+ 'fstype': 'iso9660',
185+ 'mountpoint': 'wark/media/sr9',
186+ 'opts': 'ro',
187+ }
188+ }
189+ # We use an OrderedDict here to ensure we check xvdb before xvdc
190+ # as we're not mocking the regex matching, however, if we place
191+ # an entry in the results then we can be reasonably sure that
192+ # we're skipping an entry which fails to match.
193+ self.m_mounts.return_value = (
194+ OrderedDict(sorted(mounts.items(), key=lambda t: t[0])))
195+
196+ (contents, fullp, fname) = dsovf.transport_iso9660()
197+ self.assertEqual("mycontent", contents)
198+ self.assertEqual("/dev/xvdc", fullp)
199+ self.assertEqual("myfile", fname)
200+
201+ def test_find_already_mounted_matches_kname(self):
202+ """Check we dont regex match on basename of the device"""
203+ mounts = {
204+ '/dev/foo/bar/xvdc': {
205+ 'fstype': 'iso9660',
206+ 'mountpoint': 'wark/media/sr9',
207+ 'opts': 'ro',
208+ }
209+ }
210+ # we're skipping an entry which fails to match.
211+ self.m_mounts.return_value = mounts
212+
213+ (contents, fullp, fname) = dsovf.transport_iso9660()
214+ self.assertEqual(False, contents)
215+ self.assertIsNone(fullp)
216+ self.assertIsNone(fname)
217+
218+ def test_mount_cb_called_on_blkdevs_with_iso9660(self):
219+ """Check we call mount_cb on blockdevs with iso9660 only"""
220+ self.m_mounts.return_value = {}
221+ self.m_find_devs_with.return_value = ['/dev/sr0']
222+ self.m_mount_cb.return_value = ("myfile", "mycontent")
223+
224+ (contents, fullp, fname) = dsovf.transport_iso9660()
225+
226+ self.m_mount_cb.assert_called_with(
227+ "/dev/sr0", dsovf.get_ovf_env, mtype="iso9660")
228+ self.assertEqual("mycontent", contents)
229+ self.assertEqual("/dev/sr0", fullp)
230+ self.assertEqual("myfile", fname)
231+
232+ def test_mount_cb_called_on_blkdevs_with_iso9660_check_regex(self):
233+ """Check we call mount_cb on blockdevs with iso9660 and match regex"""
234+ self.m_mounts.return_value = {}
235+ self.m_find_devs_with.return_value = [
236+ '/dev/abc', '/dev/my-cdrom', '/dev/sr0']
237+ self.m_mount_cb.return_value = ("myfile", "mycontent")
238+
239+ (contents, fullp, fname) = dsovf.transport_iso9660()
240+
241+ self.m_mount_cb.assert_called_with(
242+ "/dev/sr0", dsovf.get_ovf_env, mtype="iso9660")
243+ self.assertEqual("mycontent", contents)
244+ self.assertEqual("/dev/sr0", fullp)
245+ self.assertEqual("myfile", fname)
246+
247+ def test_mount_cb_not_called_no_matches(self):
248+ """Check we don't call mount_cb if nothing matches"""
249+ self.m_mounts.return_value = {}
250+ self.m_find_devs_with.return_value = ['/dev/vg/myovf']
251+
252+ (contents, fullp, fname) = dsovf.transport_iso9660()
253+
254+ self.assertEqual(0, self.m_mount_cb.call_count)
255+ self.assertEqual(False, contents)
256+ self.assertIsNone(fullp)
257+ self.assertIsNone(fname)
258+
259+ def test_mount_cb_called_require_iso_false(self):
260+ """Check we call mount_cb on blockdevs with require_iso=False"""
261+ self.m_mounts.return_value = {}
262+ self.m_find_devs_with.return_value = ['/dev/xvdz']
263+ self.m_mount_cb.return_value = ("myfile", "mycontent")
264+
265+ (contents, fullp, fname) = dsovf.transport_iso9660(require_iso=False)
266+
267+ self.m_mount_cb.assert_called_with(
268+ "/dev/xvdz", dsovf.get_ovf_env, mtype=None)
269+ self.assertEqual("mycontent", contents)
270+ self.assertEqual("/dev/xvdz", fullp)
271+ self.assertEqual("myfile", fname)
272+
273+ def test_maybe_cdrom_device_none(self):
274+ """Test maybe_cdrom_device returns False for none/empty input"""
275+ self.assertFalse(dsovf.maybe_cdrom_device(None))
276+ self.assertFalse(dsovf.maybe_cdrom_device(''))
277+
278+ def test_maybe_cdrom_device_non_string_exception(self):
279+ """Test maybe_cdrom_device raises ValueError on non-string types"""
280+ with self.assertRaises(ValueError):
281+ dsovf.maybe_cdrom_device({'a': 'eleven'})
282+
283+ def test_maybe_cdrom_device_false_on_multi_dir_paths(self):
284+ """Test maybe_cdrom_device is false on /dev[/.*]/* paths"""
285+ self.assertFalse(dsovf.maybe_cdrom_device('/dev/foo/sr0'))
286+ self.assertFalse(dsovf.maybe_cdrom_device('foo/sr0'))
287+ self.assertFalse(dsovf.maybe_cdrom_device('../foo/sr0'))
288+ self.assertFalse(dsovf.maybe_cdrom_device('../foo/sr0'))
289+
290+ def test_maybe_cdrom_device_true_on_hd_partitions(self):
291+ """Test maybe_cdrom_device is false on /dev/hd[a-z][0-9]+ paths"""
292+ self.assertTrue(dsovf.maybe_cdrom_device('/dev/hda1'))
293+ self.assertTrue(dsovf.maybe_cdrom_device('hdz9'))
294+
295+ def test_maybe_cdrom_device_true_on_valid_relative_paths(self):
296+ """Test maybe_cdrom_device normalizes paths"""
297+ self.assertTrue(dsovf.maybe_cdrom_device('/dev/wark/../sr9'))
298+ self.assertTrue(dsovf.maybe_cdrom_device('///sr0'))
299+ self.assertTrue(dsovf.maybe_cdrom_device('/sr0'))
300+ self.assertTrue(dsovf.maybe_cdrom_device('//dev//hda'))
301+
302+ def test_maybe_cdrom_device_true_on_xvd_partitions(self):
303+ """Test maybe_cdrom_device returns true on xvd*"""
304+ self.assertTrue(dsovf.maybe_cdrom_device('/dev/xvda'))
305+ self.assertTrue(dsovf.maybe_cdrom_device('/dev/xvda1'))
306+ self.assertTrue(dsovf.maybe_cdrom_device('xvdza1'))
307+
308+#
309 # vi: ts=4 expandtab

Subscribers

People subscribed via source and target branches