Don't execute syspreps if the fs is not mounted rw
[snf-image-creator] / image_creator / bundle_volume.py
1 # Copyright 2012 GRNET S.A. All rights reserved.
2 #
3 # Redistribution and use in source and binary forms, with or
4 # without modification, are permitted provided that the following
5 # conditions are met:
6 #
7 #   1. Redistributions of source code must retain the above
8 #      copyright notice, this list of conditions and the following
9 #      disclaimer.
10 #
11 #   2. Redistributions in binary form must reproduce the above
12 #      copyright notice, this list of conditions and the following
13 #      disclaimer in the documentation and/or other materials
14 #      provided with the distribution.
15 #
16 # THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17 # OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19 # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23 # USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24 # AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27 # POSSIBILITY OF SUCH DAMAGE.
28 #
29 # The views and conclusions contained in the software and
30 # documentation are those of the authors and should not be
31 # interpreted as representing official policies, either expressed
32 # or implied, of GRNET S.A.
33
34 import os
35 import re
36 import tempfile
37 import uuid
38 from collections import namedtuple
39
40 import parted
41
42 from image_creator.rsync import Rsync
43 from image_creator.util import get_command
44 from image_creator.util import FatalError
45 from image_creator.util import try_fail_repeat
46 from image_creator.util import free_space
47 from image_creator.gpt import GPTPartitionTable
48
49 findfs = get_command('findfs')
50 dd = get_command('dd')
51 dmsetup = get_command('dmsetup')
52 losetup = get_command('losetup')
53 mount = get_command('mount')
54 umount = get_command('umount')
55 blkid = get_command('blkid')
56 tune2fs = get_command('tune2fs')
57
58 MKFS_OPTS = {'ext2': ['-F'],
59              'ext3': ['-F'],
60              'ext4': ['-F'],
61              'reiserfs': ['-ff'],
62              'btrfs': [],
63              'minix': [],
64              'xfs': ['-f'],
65              'jfs': ['-f'],
66              'ntfs': ['-F'],
67              'msdos': [],
68              'vfat': []}
69
70
71 class BundleVolume(object):
72     """This class can be used to create an image out of the running system"""
73
74     def __init__(self, out, meta, tmp=None):
75         """Create an instance of the BundleVolume class."""
76         self.out = out
77         self.meta = meta
78         self.tmp = tmp
79
80         self.out.output('Searching for root device ...', False)
81         root = self._get_root_partition()
82
83         if root.startswith("UUID=") or root.startswith("LABEL="):
84             root = findfs(root).stdout.strip()
85
86         if not re.match('/dev/[hsv]d[a-z][1-9]*$', root):
87             raise FatalError("Don't know how to handle root device: %s" % root)
88
89         out.success(root)
90
91         disk_file = re.split('[0-9]', root)[0]
92         device = parted.Device(disk_file)
93         self.disk = parted.Disk(device)
94
95     def _read_fstable(self, f):
96         """Use this generator to iterate over the lines of and fstab file"""
97
98         if not os.path.isfile(f):
99             raise FatalError("Unable to open: `%s'. File is missing." % f)
100
101         FileSystemTableEntry = namedtuple('FileSystemTableEntry',
102                                           'dev mpoint fs opts freq passno')
103         with open(f) as table:
104             for line in iter(table):
105                 entry = line.split('#')[0].strip().split()
106                 if len(entry) != 6:
107                     continue
108                 yield FileSystemTableEntry(*entry)
109
110     def _get_root_partition(self):
111         """Return the fstab entry accosiated with the root filesystem"""
112         for entry in self._read_fstable('/etc/fstab'):
113             if entry.mpoint == '/':
114                 return entry.dev
115
116         raise FatalError("Unable to find root device in /etc/fstab")
117
118     def _is_mpoint(self, path):
119         """Check if a directory is currently a mount point"""
120         for entry in self._read_fstable('/proc/mounts'):
121             if entry.mpoint == path:
122                 return True
123         return False
124
125     def _get_mount_options(self, device):
126         """Return the mount entry associated with a mounted device"""
127         for entry in self._read_fstable('/proc/mounts'):
128             if not entry.dev.startswith('/'):
129                 continue
130
131             if os.path.realpath(entry.dev) == os.path.realpath(device):
132                 return entry
133
134         return None
135
136     def _create_partition_table(self, image):
137         """Copy the partition table of the host system into the image"""
138
139         # Copy the MBR and the space between the MBR and the first partition.
140         # In msdos partition tables Grub Stage 1.5 is located there.
141         # In gpt partition tables the Primary GPT Header is there.
142         first_sector = self.disk.getPrimaryPartitions()[0].geometry.start
143
144         dd('if=%s' % self.disk.device.path, 'of=%s' % image,
145            'bs=%d' % self.disk.device.sectorSize,
146            'count=%d' % first_sector, 'conv=notrunc')
147
148         if self.disk.type == 'gpt':
149             # Copy the Secondary GPT Header
150             table = GPTPartitionTable(self.disk.device.path)
151             dd('if=%s' % self.disk.device.path, 'of=%s' % image,
152                'bs=%d' % self.disk.device.sectorSize, 'conv=notrunc',
153                'seek=%d' % table.primary.last_usable_lba,
154                'skip=%d' % table.primary.last_usable_lba)
155
156         # Create the Extended boot records (EBRs) in the image
157         extended = self.disk.getExtendedPartition()
158         if not extended:
159             return
160
161         # Extended boot records precede the logical partitions they describe
162         logical = self.disk.getLogicalPartitions()
163         start = extended.geometry.start
164         for i in range(len(logical)):
165             end = logical[i].geometry.start - 1
166             dd('if=%s' % self.disk.device.path, 'of=%s' % image,
167                'count=%d' % (end - start + 1), 'conv=notrunc',
168                'seek=%d' % start, 'skip=%d' % start)
169             start = logical[i].geometry.end + 1
170
171     def _get_partitions(self, disk):
172         """Returns a list with the partitions of the provided disk"""
173         Partition = namedtuple('Partition', 'num start end type fs')
174
175         partitions = []
176         for p in disk.partitions:
177             num = p.number
178             start = p.geometry.start
179             end = p.geometry.end
180             ptype = p.type
181             fs = p.fileSystem.type if p.fileSystem is not None else ''
182             partitions.append(Partition(num, start, end, ptype, fs))
183
184         return partitions
185
186     def _shrink_partitions(self, image):
187         """Remove the last partition of the image if it is a swap partition and
188         shrink the partition before that. Make sure it can still host all the
189         files the corresponding host file system hosts
190         """
191         new_end = self.disk.device.length
192
193         image_disk = parted.Disk(parted.Device(image))
194
195         is_extended = lambda p: p.type == parted.PARTITION_EXTENDED
196         is_logical = lambda p: p.type == parted.PARTITION_LOGICAL
197
198         partitions = self._get_partitions(self.disk)
199
200         last = partitions[-1]
201         if last.fs == 'linux-swap(v1)':
202             MB = 2 ** 20
203             size = (last.end - last.start + 1) * self.disk.device.sectorSize
204             self.meta['SWAP'] = "%d:%s" % (last.num, (size + MB - 1) // MB)
205
206             image_disk.deletePartition(
207                 image_disk.getPartitionBySector(last.start))
208             image_disk.commitToDevice()
209
210             if is_logical(last) and last.num == 5:
211                 extended = image_disk.getExtendedPartition()
212                 image_disk.deletePartition(extended)
213                 image_disk.commitToDevice()
214                 partitions.remove(filter(is_extended, partitions)[0])
215
216             partitions.remove(last)
217             last = partitions[-1]
218
219             new_end = last.end
220
221         mount_options = self._get_mount_options(
222             self.disk.getPartitionBySector(last.start).path)
223         if mount_options is not None:
224             stat = os.statvfs(mount_options.mpoint)
225             # Shrink the last partition. The new size should be the size of the
226             # occupied blocks
227             blcks = stat.f_blocks - stat.f_bavail
228             new_size = (blcks * stat.f_frsize) // self.disk.device.sectorSize
229
230             # Add 10% just to be on the safe side
231             part_end = last.start + (new_size * 11) // 10
232             # Align to 2048
233             part_end = ((part_end + 2047) // 2048) * 2048
234
235             # Make sure the partition starts where the old partition started.
236             constraint = parted.Constraint(device=image_disk.device)
237             constraint.startRange = parted.Geometry(device=image_disk.device,
238                                                     start=last.start, length=1)
239
240             image_disk.setPartitionGeometry(
241                 image_disk.getPartitionBySector(last.start), constraint,
242                 start=last.start, end=part_end)
243             image_disk.commitToDevice()
244
245             # Parted may have changed this for better alignment
246             part_end = image_disk.getPartitionBySector(last.start).geometry.end
247             last = last._replace(end=part_end)
248             partitions[-1] = last
249
250             new_end = part_end
251
252             if last.type == parted.PARTITION_LOGICAL:
253                 # Fix the extended partition
254                 image_disk.minimizeExtendedPartition()
255
256         return (new_end, self._get_partitions(image_disk))
257
258     def _map_partition(self, dev, num, start, end):
259         """Map a partition into a block device using the device mapper"""
260         name = os.path.basename(dev) + "_" + uuid.uuid4().hex
261         tablefd, table = tempfile.mkstemp()
262         try:
263             size = end - start + 1
264             os.write(tablefd, "0 %d linear %s %d" % (size, dev, start))
265             dmsetup('create', "%sp%d" % (name, num), table)
266         finally:
267             os.unlink(table)
268
269         return "/dev/mapper/%sp%d" % (name, num)
270
271     def _unmap_partition(self, dev):
272         """Unmap a previously mapped partition"""
273         if not os.path.exists(dev):
274             return
275
276         try_fail_repeat(dmsetup, 'remove', dev.split('/dev/mapper/')[1])
277
278     def _mount(self, target, devs):
279         """Mount a list of filesystems in mountpoints relative to target"""
280         devs.sort(key=lambda d: d[1])
281         for dev, mpoint, options in devs:
282             absmpoint = os.path.abspath(target + mpoint)
283             if not os.path.exists(absmpoint):
284                 os.makedirs(absmpoint)
285
286             if len(options) > 0:
287                 mount(dev, absmpoint, '-o', ",".join(options))
288             else:
289                 mount(dev, absmpoint)
290
291     def _umount_all(self, target):
292         """Unmount all filesystems that are mounted under the directory target
293         """
294         mpoints = []
295         for entry in self._read_fstable('/proc/mounts'):
296             if entry.mpoint.startswith(os.path.abspath(target)):
297                     mpoints.append(entry.mpoint)
298
299         mpoints.sort()
300         for mpoint in reversed(mpoints):
301             try_fail_repeat(umount, mpoint)
302
303     def _to_exclude(self):
304         """Find which directories to exclude during the image copy. This is
305         accompliced by checking which directories serve as mount points for
306         virtual file systems
307         """
308         excluded = ['/tmp', '/var/tmp']
309         if self.tmp is not None:
310             excluded.append(self.tmp)
311         local_filesystems = MKFS_OPTS.keys() + ['rootfs']
312         for entry in self._read_fstable('/proc/mounts'):
313             if entry.fs in local_filesystems:
314                 continue
315
316             mpoint = entry.mpoint
317             if mpoint in excluded:
318                 continue
319
320             descendants = filter(
321                 lambda p: p.startswith(mpoint + '/'), excluded)
322             if len(descendants):
323                 for d in descendants:
324                     excluded.remove(d)
325                 excluded.append(mpoint)
326                 continue
327
328             dirname = mpoint
329             basename = ''
330             found_ancestor = False
331             while dirname != '/':
332                 (dirname, basename) = os.path.split(dirname)
333                 if dirname in excluded:
334                     found_ancestor = True
335                     break
336
337             if not found_ancestor:
338                 excluded.append(mpoint)
339
340         return excluded
341
342     def _replace_uuids(self, target, new_uuid):
343         """Replace UUID references in various files. This is needed after
344         copying system files of the host into a new filesystem
345         """
346
347         files = ['/etc/fstab',
348                  '/boot/grub/grub.cfg',
349                  '/boot/grub/menu.lst',
350                  '/boot/grub/grub.conf']
351
352         orig = {}
353         for p in self.disk.partitions:
354             if p.number in new_uuid.keys():
355                 orig[p.number] = \
356                     blkid('-s', 'UUID', '-o', 'value', p.path).stdout.strip()
357
358         for f in map(lambda f: target + f, files):
359             if not os.path.exists(f):
360                 continue
361
362             with open(f, 'r') as src:
363                 lines = src.readlines()
364             with open(f, 'w') as dest:
365                 for line in lines:
366                     for i, uuid in new_uuid.items():
367                         line = re.sub(orig[i], uuid, line)
368                     dest.write(line)
369
370     def _create_filesystems(self, image, partitions):
371         """Fill the image with data. Host file systems that are not currently
372         mounted are binary copied into the image. For mounted file systems, a
373         file system level copy is performed.
374         """
375
376         filesystem = {}
377         orig_dev = {}
378         for p in self.disk.partitions:
379             filesystem[p.number] = self._get_mount_options(p.path)
380             orig_dev[p.number] = p.path
381
382         unmounted = filter(lambda p: filesystem[p.num] is None, partitions)
383         mounted = filter(lambda p: filesystem[p.num] is not None, partitions)
384
385         # For partitions that are not mounted right now, we can simply dd them
386         # into the image.
387         for p in unmounted:
388             self.out.output('Cloning partition %d ... ' % p.num, False)
389             dd('if=%s' % self.disk.device.path, 'of=%s' % image,
390                'count=%d' % (p.end - p.start + 1), 'conv=notrunc',
391                'seek=%d' % p.start, 'skip=%d' % p.start)
392             self.out.success("done")
393
394         loop = str(losetup('-f', '--show', image)).strip()
395
396         # Recreate mounted file systems
397         mapped = {}
398         try:
399             for p in mounted:
400                 i = p.num
401                 mapped[i] = self._map_partition(loop, i, p.start, p.end)
402
403             new_uuid = {}
404             # Create the file systems
405             for i, dev in mapped.iteritems():
406                 fs = filesystem[i].fs
407                 self.out.output('Creating %s filesystem on partition %d ... ' %
408                                 (fs, i), False)
409                 get_command('mkfs.%s' % fs)(*(MKFS_OPTS[fs] + [dev]))
410
411                 # For ext[234] enable the default mount options
412                 if re.match('^ext[234]$', fs):
413                     mopts = filter(
414                         lambda p: p.startswith('Default mount options:'),
415                         tune2fs('-l', orig_dev[i]).splitlines()
416                         )[0].split(':')[1].strip().split()
417
418                     if not (len(mopts) == 1 and mopts[0] == '(none)'):
419                         for opt in mopts:
420                             tune2fs('-o', opt, dev)
421
422                 self.out.success('done')
423                 new_uuid[i] = blkid(
424                     '-s', 'UUID', '-o', 'value', dev).stdout.strip()
425
426             target = tempfile.mkdtemp()
427             devs = []
428             for i in mapped.keys():
429                 fs = filesystem[i].fs
430                 mpoint = filesystem[i].mpoint
431                 opts = []
432                 for opt in filesystem[i].opts.split(','):
433                     if opt in ('acl', 'user_xattr'):
434                         opts.append(opt)
435                 devs.append((mapped[i], mpoint, opts))
436             try:
437                 self._mount(target, devs)
438
439                 excluded = self._to_exclude()
440
441                 rsync = Rsync(self.out)
442
443                 for excl in excluded + [image]:
444                     rsync.exclude(excl)
445
446                 rsync.archive().hard_links().xattrs().sparse().acls()
447                 rsync.run('/', target, 'host', 'temporary image')
448
449                 # Create missing mountpoints. Since they are mountpoints, we
450                 # cannot determine the ownership and the mode of the real
451                 # directory. Make them inherit those properties from their
452                 # parent dir
453                 for excl in excluded:
454                     dirname = os.path.dirname(excl)
455                     stat = os.stat(dirname)
456                     os.mkdir(target + excl)
457                     os.chmod(target + excl, stat.st_mode)
458                     os.chown(target + excl, stat.st_uid, stat.st_gid)
459
460                 # /tmp and /var/tmp are special cases. We exclude then even if
461                 # they aren't mountpoints. Restore their permissions.
462                 for excl in ('/tmp', '/var/tmp'):
463                     if self._is_mpoint(excl):
464                         os.chmod(target + excl, 041777)
465                         os.chown(target + excl, 0, 0)
466                     else:
467                         stat = os.stat(excl)
468                         os.chmod(target + excl, stat.st_mode)
469                         os.chown(target + excl, stat.st_uid, stat.st_gid)
470
471                 # We need to replace the old UUID referencies with the new
472                 # ones in grub configuration files and /etc/fstab for file
473                 # systems that have been recreated.
474                 self._replace_uuids(target, new_uuid)
475
476             finally:
477                 self._umount_all(target)
478                 os.rmdir(target)
479         finally:
480             for dev in mapped.values():
481                 self._unmap_partition(dev)
482             losetup('-d', loop)
483
484     def create_image(self, image):
485         """Given an image filename, this method will create an image out of the
486         running system.
487         """
488
489         size = self.disk.device.length * self.disk.device.sectorSize
490
491         # Create sparse file to host the image
492         fd = os.open(image, os.O_WRONLY | os.O_CREAT)
493         try:
494             os.ftruncate(fd, size)
495         finally:
496             os.close(fd)
497
498         self._create_partition_table(image)
499         end_sector, partitions = self._shrink_partitions(image)
500
501         if self.disk.type == 'gpt':
502             old_size = size
503             size = (end_sector + 1) * self.disk.device.sectorSize
504             ptable = GPTPartitionTable(image)
505             size = ptable.shrink(size, old_size)
506         else:
507             # Alighn to 2048
508             end_sector = ((end_sector + 2047) // 2048) * 2048
509             size = (end_sector + 1) * self.disk.device.sectorSize
510
511         # Truncate image to the new size.
512         fd = os.open(image, os.O_RDWR)
513         try:
514             os.ftruncate(fd, size)
515         finally:
516             os.close(fd)
517
518         # Check if the available space is enough to host the image
519         dirname = os.path.dirname(image)
520         self.out.output("Examining available space ...", False)
521         if free_space(dirname) <= size:
522             raise FatalError("Not enough space under %s to host the temporary "
523                              "image" % dirname)
524         self.out.success("sufficient")
525
526         self._create_filesystems(image, partitions)
527
528         return image
529
530 # vim: set sta sts=4 shiftwidth=4 sw=4 et ai :