#!/usr/bin/env python3 # # vmtools.py - part of the FDroid server tools # Copyright (C) 2017 Michael Poehn # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from os.path import isdir, isfile, basename, abspath, expanduser import os import json import shutil import subprocess import textwrap import logging from .common import FDroidException import threading lock = threading.Lock() def get_clean_builder(serverdir): if not os.path.isdir(serverdir): if os.path.islink(serverdir): os.unlink(serverdir) logging.info("buildserver path does not exists, creating %s", serverdir) os.makedirs(serverdir) vagrantfile = os.path.join(serverdir, 'Vagrantfile') if not os.path.isfile(vagrantfile): with open(vagrantfile, 'w') as f: f.write( textwrap.dedent( """\ # generated file, do not change. Vagrant.configure("2") do |config| config.vm.box = "buildserver" config.vm.synced_folder ".", "/vagrant", disabled: true end """ ) ) vm = get_build_vm(serverdir) logging.info('destroying buildserver before build') vm.destroy() logging.info('starting buildserver') vm.up() try: sshinfo = vm.sshinfo() except FDroidBuildVmException: # workaround because libvirt sometimes likes to forget # about ssh connection info even thou the vm is running vm.halt() vm.up() sshinfo = vm.sshinfo() return sshinfo def _check_call(cmd, cwd=None): logging.debug(' '.join(cmd)) return subprocess.check_call(cmd, shell=False, cwd=cwd) def _check_output(cmd, cwd=None): logging.debug(' '.join(cmd)) return subprocess.check_output(cmd, shell=False, cwd=cwd) def get_build_vm(srvdir, provider=None): """No summary. Factory function for getting FDroidBuildVm instances. This function tries to figure out what hypervisor should be used and creates an object for controlling a build VM. Parameters ---------- srvdir path to a directory which contains a Vagrantfile provider optionally this parameter allows specifiying an specific vagrant provider. Returns ------- FDroidBuildVm instance. """ abssrvdir = abspath(srvdir) # use supplied provider if provider: if provider == 'libvirt': logging.debug('build vm provider \'libvirt\' selected') return LibvirtBuildVm(abssrvdir) elif provider == 'virtualbox': logging.debug('build vm provider \'virtualbox\' selected') return VirtualboxBuildVm(abssrvdir) else: logging.warning('build vm provider not supported: \'%s\'', provider) # try guessing provider from installed software kvm_installed = shutil.which('kvm') is not None kvm_installed |= shutil.which('qemu') is not None kvm_installed |= shutil.which('qemu-kvm') is not None vbox_installed = shutil.which('VBoxHeadless') is not None if kvm_installed and vbox_installed: logging.debug('both kvm and vbox are installed.') elif kvm_installed: logging.debug( 'libvirt is the sole installed and supported vagrant provider, selecting \'libvirt\'' ) return LibvirtBuildVm(abssrvdir) elif vbox_installed: logging.debug( 'virtualbox is the sole installed and supported vagrant provider, selecting \'virtualbox\'' ) return VirtualboxBuildVm(abssrvdir) else: logging.debug( 'could not confirm that either virtualbox or kvm/libvirt are installed' ) # try guessing provider from .../srvdir/.vagrant internals vagrant_libvirt_path = os.path.join( abssrvdir, '.vagrant', 'machines', 'default', 'libvirt' ) has_libvirt_machine = ( isdir(vagrant_libvirt_path) and len(os.listdir(vagrant_libvirt_path)) > 0 ) vagrant_virtualbox_path = os.path.join( abssrvdir, '.vagrant', 'machines', 'default', 'virtualbox' ) has_vbox_machine = ( isdir(vagrant_virtualbox_path) and len(os.listdir(vagrant_virtualbox_path)) > 0 ) if has_libvirt_machine and has_vbox_machine: logging.info( 'build vm provider lookup found virtualbox and libvirt, defaulting to \'virtualbox\'' ) return VirtualboxBuildVm(abssrvdir) elif has_libvirt_machine: logging.debug('build vm provider lookup found \'libvirt\'') return LibvirtBuildVm(abssrvdir) elif has_vbox_machine: logging.debug('build vm provider lookup found \'virtualbox\'') return VirtualboxBuildVm(abssrvdir) # try guessing provider from available buildserver boxes available_boxes = [] import vagrant boxes = vagrant.Vagrant().box_list() for box in boxes: if box.name == "buildserver": available_boxes.append(box.provider) if "libvirt" in available_boxes and "virtualbox" in available_boxes: logging.info( 'basebox lookup found virtualbox and libvirt boxes, defaulting to \'virtualbox\'' ) return VirtualboxBuildVm(abssrvdir) elif "libvirt" in available_boxes: logging.info('\'libvirt\' buildserver box available, using that') return LibvirtBuildVm(abssrvdir) elif "virtualbox" in available_boxes: logging.info('\'virtualbox\' buildserver box available, using that') return VirtualboxBuildVm(abssrvdir) else: logging.error('No available \'buildserver\' box. Cannot proceed') os._exit(1) class FDroidBuildVmException(FDroidException): pass class FDroidBuildVm: """Abstract base class for working with FDroids build-servers. Use the factory method `fdroidserver.vmtools.get_build_vm()` for getting correct instances of this class. This is intended to be a hypervisor independent, fault tolerant wrapper around the vagrant functions we use. """ def __init__(self, srvdir, provider=None): """Create new server class.""" self.provider = provider self.srvdir = srvdir self.srvname = basename(srvdir) + '_default' self.vgrntfile = os.path.join(srvdir, 'Vagrantfile') self.srvuuid = self._vagrant_fetch_uuid() if not isdir(srvdir): raise FDroidBuildVmException( "Can not init vagrant, directory %s not present" % (srvdir) ) if not isfile(self.vgrntfile): raise FDroidBuildVmException( "Can not init vagrant, '%s' not present" % (self.vgrntfile) ) import vagrant self.vgrnt = vagrant.Vagrant( root=srvdir, out_cm=vagrant.stdout_cm, err_cm=vagrant.stdout_cm ) def up(self, provision=True): global lock with lock: try: self.vgrnt.up(provision=provision, provider=self.provider) self.srvuuid = self._vagrant_fetch_uuid() except subprocess.CalledProcessError as e: statusline = "" try: # try to get some additional info about the vagrant vm status = self.vgrnt.status() if len(status) > 0: statusline = "VM status: name={n}, state={s}, provider={p}"\ .format(n=status[0].name, s=status[0].state, p=status[0].provider) except subprocess.CalledProcessError: pass raise FDroidBuildVmException(value="could not bring up vm '{vmname}'" .format(vmname=self.srvname), detail="{err}\n{statline}" .format(err=str(e), statline=statusline) ) from e def suspend(self): global lock with lock: logging.info('suspending buildserver') try: self.vgrnt.suspend() except subprocess.CalledProcessError as e: raise FDroidBuildVmException("could not suspend vm '%s'" % self.srvname) from e def halt(self): global lock with lock: self.vgrnt.halt(force=True) def destroy(self): """Remove every trace of this VM from the system. This includes deleting: * hypervisor specific definitions * vagrant state informations (eg. `.vagrant` folder) * images related to this vm """ logging.info("destroying vm '%s'", self.srvname) try: self.vgrnt.destroy() logging.debug('vagrant destroy completed') except subprocess.CalledProcessError as e: logging.exception('vagrant destroy failed: %s', e) vgrntdir = os.path.join(self.srvdir, '.vagrant') try: shutil.rmtree(vgrntdir) logging.debug('deleted vagrant dir: %s', vgrntdir) except Exception as e: logging.debug("could not delete vagrant dir: %s, %s", vgrntdir, e) try: _check_call(['vagrant', 'global-status', '--prune']) except subprocess.CalledProcessError as e: logging.debug('pruning global vagrant status failed: %s', e) def vagrant_uuid_okay(self): """Having an uuid means that vagrant up has run successfully.""" if self.srvuuid is None: return False return True def _vagrant_file_name(self, name): return name.replace('/', '-VAGRANTSLASH-') def _vagrant_fetch_uuid(self): if isfile(os.path.join(self.srvdir, '.vagrant')): # Vagrant 1.0 - it's a json file... with open(os.path.join(self.srvdir, '.vagrant')) as f: id = json.load(f)['active']['default'] logging.debug('vm uuid: %s', id) return id elif isfile(os.path.join(self.srvdir, '.vagrant', 'machines', 'default', self.provider, 'id')): # Vagrant 1.2 (and maybe 1.1?) it's a directory tree... with open(os.path.join(self.srvdir, '.vagrant', 'machines', 'default', self.provider, 'id')) as f: id = f.read() logging.debug('vm uuid: %s', id) return id else: logging.debug('vm uuid is None') return None def box_add(self, boxname, boxfile, force=True): """Add vagrant box to vagrant. Parameters ---------- boxname name assigned to local deployment of box boxfile path to box file force overwrite existing box image (default: True) """ boxfile = abspath(boxfile) if not isfile(boxfile): raise FDroidBuildVmException( 'supplied boxfile \'%s\' does not exist' % boxfile ) self.vgrnt.box_add(boxname, abspath(boxfile), force=force) def box_remove(self, boxname): try: _check_call(['vagrant', 'box', 'remove', '--all', '--force', boxname]) except subprocess.CalledProcessError as e: logging.debug('tried removing box %s, but is did not exist: %s', boxname, e) boxpath = os.path.join( expanduser('~'), '.vagrant', self._vagrant_file_name(boxname) ) if isdir(boxpath): logging.info( "attempting to remove box '%s' by deleting: %s", boxname, boxpath ) shutil.rmtree(boxpath) def sshinfo(self): """Get ssh connection info for a vagrant VM. Returns ------- A dictionary containing 'hostname', 'port', 'user' and 'idfile' """ import paramiko try: sshconfig_path = os.path.join(self.srvdir, 'sshconfig') with open(sshconfig_path, 'wb') as fp: fp.write(_check_output(['vagrant', 'ssh-config'], cwd=self.srvdir)) vagranthost = 'default' # Host in ssh config file sshconfig = paramiko.SSHConfig() with open(sshconfig_path, 'r') as f: sshconfig.parse(f) sshconfig = sshconfig.lookup(vagranthost) idfile = sshconfig['identityfile'] if isinstance(idfile, list): idfile = idfile[0] elif idfile.startswith('"') and idfile.endswith('"'): idfile = idfile[1:-1] return { 'hostname': sshconfig['hostname'], 'port': int(sshconfig['port']), 'user': sshconfig['user'], 'idfile': idfile, } except subprocess.CalledProcessError as e: raise FDroidBuildVmException("Error getting ssh config") from e class LibvirtBuildVm(FDroidBuildVm): def __init__(self, srvdir): super().__init__(srvdir, 'libvirt') import libvirt try: self.conn = libvirt.open('qemu:///system') except libvirt.libvirtError as e: raise FDroidBuildVmException('could not connect to libvirtd: %s' % (e)) from e def destroy(self): super().destroy() # resorting to virsh instead of libvirt python bindings, because # this is way more easy and therefore fault tolerant. # (eg. lookupByName only works on running VMs) try: _check_call(('virsh', '-c', 'qemu:///system', 'destroy', self.srvname)) except subprocess.CalledProcessError as e: logging.info("could not force libvirt domain '%s' off: %s", self.srvname, e) try: # libvirt python bindings do not support all flags required # for undefining domains correctly. _check_call(('virsh', '-c', 'qemu:///system', 'undefine', self.srvname, '--nvram', '--managed-save', '--remove-all-storage', '--snapshots-metadata')) except subprocess.CalledProcessError as e: logging.info("could not undefine libvirt domain '%s': %s", self.srvname, e) def box_add(self, boxname, boxfile, force=True): boximg = '%s_vagrant_box_image_0.img' % (boxname) if force: try: _check_call(['virsh', '-c', 'qemu:///system', 'vol-delete', '--pool', 'default', boximg]) logging.debug("removed old box image '%s'" "from libvirt storeage pool", boximg) except subprocess.CalledProcessError as e: logging.debug("tried removing old box image '%s'," "file was not present in first place", boximg, exc_info=e) super().box_add(boxname, boxfile, force) def box_remove(self, boxname): super().box_remove(boxname) try: _check_call(['virsh', '-c', 'qemu:///system', 'vol-delete', '--pool', 'default', '%s_vagrant_box_image_0.img' % (boxname)]) except subprocess.CalledProcessError as e: logging.debug("tried removing '%s', file was not present in first place", boxname, exc_info=e) class VirtualboxBuildVm(FDroidBuildVm): def __init__(self, srvdir): super().__init__(srvdir, 'virtualbox')