import json import os import subprocess import re import logging from shutil import move, copyfileobj from zipfile import ZipFile, is_zipfile from zlib import decompressobj, MAX_WBITS from bz2 import BZ2Decompressor import requests logger = logging.getLogger(__name__) re_qemu_img = re.compile(r'(file format: (?P<format>(qcow2|raw))|' r'virtual size: \w+ \((?P<size>[0-9]+) bytes\)|' r'backing file: \S+ \(actual path: (?P<base>\S+)\))$') class Disk(object): ''' Storage driver DISK object. Handle qcow2, raw and iso images. TYPES, CREATE_TYPES, SNAPSHOT_TYPES are hand managed restrictions. ''' TYPES = ['snapshot', 'normal'] FORMATS = ['qcow2', 'raw', 'iso'] CREATE_FORMATS = ['qcow2', 'raw'] def __init__(self, dir, name, format, type, size, base_name): # TODO: tests self.name = name self.dir = os.path.realpath(dir) if format not in self.FORMATS: raise Exception('Invalid format: %s' % format) self.format = format if type not in self.TYPES: raise Exception('Invalid type: %s' % format) self.type = type self.size = int(size) self.base_name = base_name @classmethod def deserialize(cls, desc): logging.info(desc) if isinstance(desc, basestring): desc = json.loads(desc) return cls(**desc) def get_desc(self): return { 'name': self.name, 'dir': self.dir, 'format': self.format, 'size': self.size, 'base_name': self.base_name, } def get_path(self): return os.path.realpath(self.dir + '/' + self.name) def get_base(self): return os.path.realpath(self.dir + '/' + self.base_name) def __unicode__(self): return u'%s %s %s %s' % (self.get_path(), self.format, self.size, self.get_base()) @classmethod def get(cls, dir, name): ''' Create disk from path ''' path = os.path.realpath(dir + '/' + name) output = subprocess.check_output(['qemu-img', 'info', path]) type = 'normal' base = None for line in output.split('\n'): m = re_qemu_img.search(line) if m: res = m.groupdict() if res.get('format', None) is not None: format = res['format'] if res.get('size', None) is not None: size = res['size'] if res.get('base', None) is not None: base = os.path.basename(res['base']) type = 'snapshot' return Disk(dir, name, format, size, base, type) def create(self): ''' Creating new image format specified at self.format. self.format van be "qcow2-normal" ''' # Check if type is avaliable to create if self.format not in self.CREATE_FORMATS: raise Exception('Invalid format: %s' % self.format) if self.type != 'normal': raise Exception('Invalid type: %s' % self.format) # Check for file if already exist if os.path.isfile(self.get_path()): raise Exception('File already exists: %s' % self.get_path()) # Build list of Strings as command parameters cmdline = ['qemu-img', 'create', '-f', self.format, self.get_path(), str(self.size)] logging.info("Create file: %s " % cmdline) # Call subprocess subprocess.check_output(cmdline) def download(self, task, url, parent_id=None): # noqa ''' Download image from url. ''' disk_path = self.get_path() logger.info("Downloading image from %s to %s", url, disk_path) r = requests.get(url, stream=True) if r.status_code == 200: class AbortException(Exception): pass if parent_id is None: parent_id = task.request.id percent_size = float(r.headers['content-length']) / 100 percent = 0 actual_size = 0 chunk_size = 256 * 1024 ext = url.split('.')[-1].lower() if ext == 'gz': decompressor = decompressobj(16 + MAX_WBITS) # undocumented zlib feature http://stackoverflow.com/a/2424549 elif ext == 'bz2': decompressor = BZ2Decompressor() clen = max(int(r.headers.get('content-length', 700000000)), 1) percent = 0 try: with open(disk_path, 'wb') as f: for chunk in r.iter_content(chunk_size=chunk_size): if task.is_aborted(): raise AbortException() if ext in ('gz', 'bz'): chunk = decompressor.decompress(chunk) if chunk: f.write(chunk) actual_size += chunk_size if actual_size > (percent_size * percent): percent += 1 task.update_state( task_id=parent_id, state=task.AsyncResult(parent_id).state, meta={'size': actual_size, 'percent': percent}) if ext == 'gz': f.write(decompressor.flush()) f.flush() self.size = os.path.getsize(disk_path) logger.debug("Download finished %s (%s bytes)", self.name, self.size) except AbortException: # Cleanup file: os.unlink(disk_path) logger.info("Download %s aborted %s removed.", url, disk_path) except: os.unlink(disk_path) logger.error("Download %s failed, %s removed.", url, disk_path) raise else: if ext == 'zip' and is_zipfile(disk_path): task.update_state( task_id=parent_id, state=task.AsyncResult(parent_id).state, meta={'size': actual_size, 'extracting': 'zip', 'percent': 99}) self.extract_iso_from_zip(disk_path) def extract_iso_from_zip(self, disk_path): with ZipFile(disk_path, 'r') as z: isos = z.namelist() if len(isos) != 1: isos = [i for i in isos if i.lower().endswith('.iso')] if len(isos) == 1: logger.info('Unzipping %s started.', disk_path) f = open(disk_path + '~', 'wb') zf = z.open(isos[0]) with zf, f: copyfileobj(zf, f) f.flush() move(disk_path + '~', disk_path) else: logger.info("Extracting %s failed, keeping original.", disk_path) def snapshot(self): ''' Creating qcow2 snapshot with base image. ''' # Check if snapshot type and qcow2 format matchmatch if self.format not in ['qcow2', 'iso']: raise Exception('Invalid format: %s' % self.format) if self.type != 'snapshot': raise Exception('Invalid type: %s' % self.format) # Check if file already exists if os.path.isfile(self.get_path()): raise Exception('File already exists: %s' % self.get_path()) # Check if base file exist if not os.path.isfile(self.get_base()): raise Exception('Image Base does not exists: %s' % self.get_base()) # Build list of Strings as command parameters if self.format == 'iso': cmdline = ['ln', '-s', self.get_base(), self.get_path()] else: cmdline = ['qemu-img', 'create', '-b', self.get_base(), '-f', self.format, self.get_path()] # Call subprocess subprocess.check_output(cmdline) def merge(self, new_disk): ''' Merging a new_disk from the actual disk and its base. ''' # Check if snapshot type match if self.format != 'qcow2': raise Exception('Invalid format: %s' % self.format) # Check if file already exists if os.path.isfile(new_disk.get_path()): raise Exception('File already exists: %s' % self.get_path()) # Check if base file exist if not os.path.isfile(self.get_path()): raise Exception('Original image does not exists: %s' % self.get_base()) cmdline = ['qemu-img', 'convert', self.get_path(), '-O', new_disk.format, new_disk.get_path()] # Call subprocess subprocess.check_output(cmdline) def delete(self): ''' Delete file ''' if os.path.isfile(self.get_path()): os.unlink(self.get_path()) @classmethod def list(cls, dir): return [cls.get(dir, file) for file in os.listdir(dir)]