3 # vmtools.py - part of the FDroid server tools
4 # Copyright (C) 2017 Michael Poehn <michael.poehn@fsfe.org>
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 from os import remove as rmfile
20 from os.path import isdir, isfile, join as joinpath, basename, abspath, expanduser
27 from .common import FDroidException
28 from logging import getLogger
30 logger = getLogger('fdroidserver-vmtools')
33 def _check_call(cmd, shell=False, cwd=None):
34 logger.debug(' '.join(cmd))
35 return subprocess.check_call(cmd, shell=shell, cwd=cwd)
38 def _check_output(cmd, shell=False, cwd=None):
39 logger.debug(' '.join(cmd))
40 return subprocess.check_output(cmd, shell=shell, cwd=cwd)
43 def get_build_vm(srvdir, provider=None):
44 """Factory function for getting FDroidBuildVm instances.
46 This function tries to figure out what hypervisor should be used
47 and creates an object for controlling a build VM.
49 :param srvdir: path to a directory which contains a Vagrantfile
50 :param provider: optionally this parameter allows specifiying an
51 spesific vagrant provider.
52 :returns: FDroidBuildVm instance.
54 abssrvdir = abspath(srvdir)
56 # use supplied provider
58 if provider == 'libvirt':
59 logger.debug('build vm provider \'libvirt\' selected')
60 return LibvirtBuildVm(abssrvdir)
61 elif provider == 'virtualbox':
62 logger.debug('build vm provider \'virtualbox\' selected')
63 return VirtualboxBuildVm(abssrvdir)
65 logger.warn('build vm provider not supported: \'%s\'', provider)
67 # try guessing provider from installed software
69 kvm_installed = 0 == _check_call(['which', 'kvm'])
70 except subprocess.CalledProcessError:
73 kvm_installed |= 0 == _check_call(['which', 'qemu'])
74 except subprocess.CalledProcessError:
77 vbox_installed = 0 == _check_call(['which', 'VBoxHeadless'])
78 except subprocess.CalledProcessError:
79 vbox_installed = False
80 if kvm_installed and vbox_installed:
81 logger.debug('both kvm and vbox are installed.')
83 logger.debug('libvirt is the sole installed and supported vagrant provider, selecting \'libvirt\'')
84 return LibvirtBuildVm(abssrvdir)
86 logger.debug('virtualbox is the sole installed and supported vagrant provider, selecting \'virtualbox\'')
87 return VirtualboxBuildVm(abssrvdir)
89 logger.debug('could not confirm that either virtualbox or kvm/libvirt are installed')
91 # try guessing provider from .../srvdir/.vagrant internals
92 has_libvirt_machine = isdir(joinpath(abssrvdir, '.vagrant',
93 'machines', 'default', 'libvirt'))
94 has_vbox_machine = isdir(joinpath(abssrvdir, '.vagrant',
95 'machines', 'default', 'libvirt'))
96 if has_libvirt_machine and has_vbox_machine:
97 logger.info('build vm provider lookup found virtualbox and libvirt, defaulting to \'virtualbox\'')
98 return VirtualboxBuildVm(abssrvdir)
99 elif has_libvirt_machine:
100 logger.debug('build vm provider lookup found \'libvirt\'')
101 return LibvirtBuildVm(abssrvdir)
102 elif has_vbox_machine:
103 logger.debug('build vm provider lookup found \'virtualbox\'')
104 return VirtualboxBuildVm(abssrvdir)
106 logger.info('build vm provider lookup could not determine provider, defaulting to \'virtualbox\'')
107 return VirtualboxBuildVm(abssrvdir)
110 class FDroidBuildVmException(FDroidException):
114 class FDroidBuildVm():
115 """Abstract base class for working with FDroids build-servers.
117 Use the factory method `fdroidserver.vmtools.get_build_vm()` for
118 getting correct instances of this class.
120 This is intended to be a hypervisor independant, fault tolerant
121 wrapper around the vagrant functions we use.
124 def __init__(self, srvdir):
125 """Create new server class.
128 self.srvname = basename(srvdir) + '_default'
129 self.vgrntfile = joinpath(srvdir, 'Vagrantfile')
130 self.srvuuid = self._vagrant_fetch_uuid()
131 if not isdir(srvdir):
132 raise FDroidBuildVmException("Can not init vagrant, directory %s not present" % (srvdir))
133 if not isfile(self.vgrntfile):
134 raise FDroidBuildVmException("Can not init vagrant, '%s' not present" % (self.vgrntfile))
136 self.vgrnt = vagrant.Vagrant(root=srvdir, out_cm=vagrant.stdout_cm, err_cm=vagrant.stdout_cm)
138 def up(self, provision=True):
140 self.vgrnt.up(provision=provision)
141 logger.info('...waiting a sec...')
143 self.srvuuid = self._vagrant_fetch_uuid()
144 except subprocess.CalledProcessError as e:
145 raise FDroidBuildVmException("could not bring up vm '%s'" % self.srvname) from e
148 logger.info('suspending buildserver')
151 logger.info('...waiting a sec...')
153 except subprocess.CalledProcessError as e:
154 raise FDroidBuildVmException("could not suspend vm '%s'" % self.srvname) from e
157 self.vgrnt.halt(force=True)
160 """Remove every trace of this VM from the system.
162 This includes deleting:
163 * hypervisor specific definitions
164 * vagrant state informations (eg. `.vagrant` folder)
165 * images related to this vm
167 logger.info("destroying vm '%s'", self.srvname)
170 logger.debug('vagrant destroy completed')
171 except subprocess.CalledProcessError as e:
172 logger.exception('vagrant destroy failed: %s', e)
173 vgrntdir = joinpath(self.srvdir, '.vagrant')
175 shutil.rmtree(vgrntdir)
176 logger.debug('deleted vagrant dir: %s', vgrntdir)
177 except Exception as e:
178 logger.debug("could not delete vagrant dir: %s, %s", vgrntdir, e)
180 _check_call(['vagrant', 'global-status', '--prune'])
181 except subprocess.CalledProcessError as e:
182 logger.debug('pruning global vagrant status failed: %s', e)
184 def package(self, output=None, vagrantfile=None, keep_box_file=None):
185 self.vgrnt.package(output=output, vagrantfile=vagrantfile)
187 def _vagrant_file_name(self, name):
188 return name.replace('/', '-VAGRANTSLASH-')
190 def _vagrant_fetch_uuid(self):
191 if isfile(joinpath(self.srvdir, '.vagrant')):
192 # Vagrant 1.0 - it's a json file...
193 with open(joinpath(self.srvdir, '.vagrant')) as f:
194 id = json.load(f)['active']['default']
195 logger.debug('vm uuid: %s', id)
197 elif isfile(joinpath(self.srvdir, '.vagrant', 'machines',
198 'default', self.provider, 'id')):
199 # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
200 with open(joinpath(self.srvdir, '.vagrant', 'machines',
201 'default', self.provider, 'id')) as f:
203 logger.debug('vm uuid: %s', id)
206 logger.debug('vm uuid is None')
209 def box_add(self, boxname, boxfile, force=True):
210 """Add vagrant box to vagrant.
212 :param boxname: name assigned to local deployment of box
213 :param boxfile: path to box file
214 :param force: overwrite existing box image (default: True)
216 boxfile = abspath(boxfile)
217 if not isfile(boxfile):
218 raise FDroidBuildVmException('supplied boxfile \'%s\' does not exist', boxfile)
219 self.vgrnt.box_add(boxname, abspath(boxfile), force=force)
221 def box_remove(self, boxname):
223 _check_call(['vagrant', 'box', 'remove', '--all', '--force', boxname])
224 except subprocess.CalledProcessError as e:
225 logger.debug('tried removing box %s, but is did not exist: %s', boxname, e)
226 boxpath = joinpath(expanduser('~'), '.vagrant',
227 self._vagrant_file_name(boxname))
229 logger.info("attempting to remove box '%s' by deleting: %s",
231 shutil.rmtree(boxpath)
233 def snapshot_create(self, snapshot_name):
234 raise NotImplementedError('not implemented, please use a sub-type instance')
236 def snapshot_list(self):
237 raise NotImplementedError('not implemented, please use a sub-type instance')
239 def snapshot_exists(self, snapshot_name):
240 raise NotImplementedError('not implemented, please use a sub-type instance')
242 def snapshot_revert(self, snapshot_name):
243 raise NotImplementedError('not implemented, please use a sub-type instance')
246 class LibvirtBuildVm(FDroidBuildVm):
247 def __init__(self, srvdir):
248 self.provider = 'libvirt'
249 super().__init__(srvdir)
253 self.conn = libvirt.open('qemu:///system')
254 except libvirt.libvirtError as e:
255 raise FDroidBuildVmException('could not connect to libvirtd: %s' % (e))
261 # resorting to virsh instead of libvirt python bindings, because
262 # this is way more easy and therefore fault tolerant.
263 # (eg. lookupByName only works on running VMs)
265 _check_call(('virsh', '-c', 'qemu:///system', 'destroy', self.srvname))
266 logger.info("...waiting a sec...")
268 except subprocess.CalledProcessError as e:
269 logger.info("could not force libvirt domain '%s' off: %s", self.srvname, e)
271 # libvirt python bindings do not support all flags required
272 # for undefining domains correctly.
273 _check_call(('virsh', '-c', 'qemu:///system', 'undefine', self.srvname, '--nvram', '--managed-save', '--remove-all-storage', '--snapshots-metadata'))
274 logger.info("...waiting a sec...")
276 except subprocess.CalledProcessError as e:
277 logger.info("could not undefine libvirt domain '%s': %s", self.srvname, e)
279 def package(self, output=None, vagrantfile=None, keep_box_file=False):
281 output = "buildserver.box"
282 logger.debug('no output name set for packaging \'%s\',' +
283 'defaulting to %s', self.srvname, output)
284 storagePool = self.conn.storagePoolLookupByName('default')
287 if isfile('metadata.json'):
288 rmfile('metadata.json')
289 if isfile('Vagrantfile'):
290 rmfile('Vagrantfile')
291 if isfile('box.img'):
294 logger.debug('preparing box.img for box %s', output)
295 vol = storagePool.storageVolLookupByName(self.srvname + '.img')
296 imagepath = vol.path()
297 # TODO use a libvirt storage pool to ensure the img file is readable
298 _check_call(['sudo', '/bin/chmod', '-R', 'a+rX', '/var/lib/libvirt/images'])
299 shutil.copy2(imagepath, 'box.img')
300 _check_call(['qemu-img', 'rebase', '-p', '-b', '', 'box.img'])
301 img_info_raw = _check_output(['qemu-img', 'info', '--output=json', 'box.img'])
302 img_info = json.loads(img_info_raw.decode('utf-8'))
303 metadata = {"provider": "libvirt",
304 "format": img_info['format'],
305 "virtual_size": math.ceil(img_info['virtual-size'] / (1024. ** 3)),
309 logger.debug('no Vagrantfile supplied for box, generating a minimal one...')
310 vagrantfile = 'Vagrant.configure("2") do |config|\nend'
312 logger.debug('preparing metadata.json for box %s', output)
313 with open('metadata.json', 'w') as fp:
314 fp.write(json.dumps(metadata))
315 logger.debug('preparing Vagrantfile for box %s', output)
316 with open('Vagrantfile', 'w') as fp:
317 fp.write(vagrantfile)
318 with tarfile.open(output, 'w:gz') as tar:
319 logger.debug('adding metadata.json to box %s ...', output)
320 tar.add('metadata.json')
321 logger.debug('adding Vagrantfile to box %s ...', output)
322 tar.add('Vagrantfile')
323 logger.debug('adding box.img to box %s ...', output)
326 if not keep_box_file:
327 logger.debug('box packaging complete, removing temporary files.')
328 rmfile('metadata.json')
329 rmfile('Vagrantfile')
333 logger.warn('could not connect to storage-pool \'default\',' +
334 'skipping packaging buildserver box')
336 def box_add(self, boxname, boxfile, force=True):
337 boximg = '%s_vagrant_box_image_0.img' % (boxname)
340 _check_call(['virsh', '-c', 'qemu:///system', 'vol-delete', '--pool', 'default', boximg])
341 logger.debug("removed old box image '%s' from libvirt storeage pool", boximg)
342 except subprocess.CalledProcessError as e:
343 logger.debug("tired removing old box image '%s', file was not present in first place", boximg, exc_info=e)
344 super().box_add(boxname, boxfile, force)
346 def box_remove(self, boxname):
347 super().box_remove(boxname)
349 _check_call(['virsh', '-c', 'qemu:///system', 'vol-delete', '--pool', 'default', '%s_vagrant_box_image_0.img' % (boxname)])
350 except subprocess.CalledProcessError as e:
351 logger.debug("tired removing '%s', file was not present in first place", boxname, exc_info=e)
353 def snapshot_create(self, snapshot_name):
354 logger.info("creating snapshot '%s' for vm '%s'", snapshot_name, self.srvname)
356 _check_call(['virsh', '-c', 'qemu:///system', 'snapshot-create-as', self.srvname, snapshot_name])
357 logger.info('...waiting a sec...')
359 except subprocess.CalledProcessError as e:
360 raise FDroidBuildVmException("could not cerate snapshot '%s' "
362 % (snapshot_name, self.srvname)) from e
364 def snapshot_list(self):
367 dom = self.conn.lookupByName(self.srvname)
368 return dom.listAllSnapshots()
369 except libvirt.libvirtError as e:
370 raise FDroidBuildVmException('could not list snapshots for domain \'%s\'' % self.srvname) from e
372 def snapshot_exists(self, snapshot_name):
375 dom = self.conn.lookupByName(self.srvname)
376 return dom.snapshotLookupByName(snapshot_name) is not None
377 except libvirt.libvirtError:
380 def snapshot_revert(self, snapshot_name):
381 logger.info("reverting vm '%s' to snapshot '%s'", self.srvname, snapshot_name)
384 dom = self.conn.lookupByName(self.srvname)
385 snap = dom.snapshotLookupByName(snapshot_name)
386 dom.revertToSnapshot(snap)
387 logger.info('...waiting a sec...')
389 except libvirt.libvirtError as e:
390 raise FDroidBuildVmException('could not revert domain \'%s\' to snapshot \'%s\''
391 % (self.srvname, snapshot_name)) from e
394 class VirtualboxBuildVm(FDroidBuildVm):
396 def __init__(self, srvdir):
397 self.provider = 'virtualbox'
398 super().__init__(srvdir)
400 def snapshot_create(self, snapshot_name):
401 logger.info("creating snapshot '%s' for vm '%s'", snapshot_name, self.srvname)
403 _check_call(['VBoxManage', 'snapshot', self.srvuuid, 'take', 'fdroidclean'], cwd=self.srvdir)
404 logger.info('...waiting a sec...')
406 except subprocess.CalledProcessError as e:
407 raise FDroidBuildVmException('could not cerate snapshot '
408 'of virtualbox vm %s'
409 % self.srvname) from e
411 def snapshot_list(self):
413 o = _check_output(['VBoxManage', 'snapshot',
414 self.srvuuid, 'list',
415 '--details'], cwd=self.srvdir)
417 except subprocess.CalledProcessError as e:
418 raise FDroidBuildVmException("could not list snapshots "
419 "of virtualbox vm '%s'"
420 % (self.srvname)) from e
422 def snapshot_exists(self, snapshot_name):
424 return str(snapshot_name) in str(self.snapshot_list())
425 except FDroidBuildVmException:
428 def snapshot_revert(self, snapshot_name):
429 logger.info("reverting vm '%s' to snapshot '%s'",
430 self.srvname, snapshot_name)
432 _check_call(['VBoxManage', 'snapshot', self.srvuuid,
433 'restore', 'fdroidclean'], cwd=self.srvdir)
434 except subprocess.CalledProcessError as e:
435 raise FDroidBuildVmException("could not load snapshot "
436 "'fdroidclean' for vm '%s'"
437 % (self.srvname)) from e