Statistics
| Branch: | Tag: | Revision:

root / image_creator / bundle_volume.py @ 5b2ee8c2

History | View | Annotate | Download (19.6 kB)

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 :