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_uuid_okay(self):
188 '''Having an uuid means that vagrant up has run successfully.'''
189 if self.srvuuid is None:
193 def _vagrant_file_name(self, name):
194 return name.replace('/', '-VAGRANTSLASH-')
196 def _vagrant_fetch_uuid(self):
197 if isfile(joinpath(self.srvdir, '.vagrant')):
198 # Vagrant 1.0 - it's a json file...
199 with open(joinpath(self.srvdir, '.vagrant')) as f:
200 id = json.load(f)['active']['default']
201 logger.debug('vm uuid: %s', id)
203 elif isfile(joinpath(self.srvdir, '.vagrant', 'machines',
204 'default', self.provider, 'id')):
205 # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
206 with open(joinpath(self.srvdir, '.vagrant', 'machines',
207 'default', self.provider, 'id')) as f:
209 logger.debug('vm uuid: %s', id)
212 logger.debug('vm uuid is None')
215 def box_add(self, boxname, boxfile, force=True):
216 """Add vagrant box to vagrant.
218 :param boxname: name assigned to local deployment of box
219 :param boxfile: path to box file
220 :param force: overwrite existing box image (default: True)
222 boxfile = abspath(boxfile)
223 if not isfile(boxfile):
224 raise FDroidBuildVmException('supplied boxfile \'%s\' does not exist', boxfile)
225 self.vgrnt.box_add(boxname, abspath(boxfile), force=force)
227 def box_remove(self, boxname):
229 _check_call(['vagrant', 'box', 'remove', '--all', '--force', boxname])
230 except subprocess.CalledProcessError as e:
231 logger.debug('tried removing box %s, but is did not exist: %s', boxname, e)
232 boxpath = joinpath(expanduser('~'), '.vagrant',
233 self._vagrant_file_name(boxname))
235 logger.info("attempting to remove box '%s' by deleting: %s",
237 shutil.rmtree(boxpath)
240 """Get ssh connection info for a vagrant VM
242 :returns: A dictionary containing 'hostname', 'port', 'user'
247 _check_call(['vagrant ssh-config > sshconfig'],
248 cwd=self.srvdir, shell=True)
249 vagranthost = 'default' # Host in ssh config file
250 sshconfig = paramiko.SSHConfig()
251 with open(joinpath(self.srvdir, 'sshconfig'), 'r') as f:
253 sshconfig = sshconfig.lookup(vagranthost)
254 idfile = sshconfig['identityfile']
255 if isinstance(idfile, list):
257 elif idfile.startswith('"') and idfile.endswith('"'):
258 idfile = idfile[1:-1]
259 return {'hostname': sshconfig['hostname'],
260 'port': int(sshconfig['port']),
261 'user': sshconfig['user'],
263 except subprocess.CalledProcessError as e:
264 raise FDroidBuildVmException("Error getting ssh config") from e
266 def snapshot_create(self, snapshot_name):
267 raise NotImplementedError('not implemented, please use a sub-type instance')
269 def snapshot_list(self):
270 raise NotImplementedError('not implemented, please use a sub-type instance')
272 def snapshot_exists(self, snapshot_name):
273 raise NotImplementedError('not implemented, please use a sub-type instance')
275 def snapshot_revert(self, snapshot_name):
276 raise NotImplementedError('not implemented, please use a sub-type instance')
279 class LibvirtBuildVm(FDroidBuildVm):
280 def __init__(self, srvdir):
281 self.provider = 'libvirt'
282 super().__init__(srvdir)
286 self.conn = libvirt.open('qemu:///system')
287 except libvirt.libvirtError as e:
288 raise FDroidBuildVmException('could not connect to libvirtd: %s' % (e))
294 # resorting to virsh instead of libvirt python bindings, because
295 # this is way more easy and therefore fault tolerant.
296 # (eg. lookupByName only works on running VMs)
298 _check_call(('virsh', '-c', 'qemu:///system', 'destroy', self.srvname))
299 logger.info("...waiting a sec...")
301 except subprocess.CalledProcessError as e:
302 logger.info("could not force libvirt domain '%s' off: %s", self.srvname, e)
304 # libvirt python bindings do not support all flags required
305 # for undefining domains correctly.
306 _check_call(('virsh', '-c', 'qemu:///system', 'undefine', self.srvname, '--nvram', '--managed-save', '--remove-all-storage', '--snapshots-metadata'))
307 logger.info("...waiting a sec...")
309 except subprocess.CalledProcessError as e:
310 logger.info("could not undefine libvirt domain '%s': %s", self.srvname, e)
312 def package(self, output=None, vagrantfile=None, keep_box_file=False):
314 output = "buildserver.box"
315 logger.debug('no output name set for packaging \'%s\',' +
316 'defaulting to %s', self.srvname, output)
317 storagePool = self.conn.storagePoolLookupByName('default')
320 if isfile('metadata.json'):
321 rmfile('metadata.json')
322 if isfile('Vagrantfile'):
323 rmfile('Vagrantfile')
324 if isfile('box.img'):
327 logger.debug('preparing box.img for box %s', output)
328 vol = storagePool.storageVolLookupByName(self.srvname + '.img')
329 imagepath = vol.path()
330 # TODO use a libvirt storage pool to ensure the img file is readable
331 _check_call(['sudo', '/bin/chmod', '-R', 'a+rX', '/var/lib/libvirt/images'])
332 shutil.copy2(imagepath, 'box.img')
333 _check_call(['qemu-img', 'rebase', '-p', '-b', '', 'box.img'])
334 img_info_raw = _check_output(['qemu-img', 'info', '--output=json', 'box.img'])
335 img_info = json.loads(img_info_raw.decode('utf-8'))
336 metadata = {"provider": "libvirt",
337 "format": img_info['format'],
338 "virtual_size": math.ceil(img_info['virtual-size'] / (1024. ** 3)),
342 logger.debug('no Vagrantfile supplied for box, generating a minimal one...')
343 vagrantfile = 'Vagrant.configure("2") do |config|\nend'
345 logger.debug('preparing metadata.json for box %s', output)
346 with open('metadata.json', 'w') as fp:
347 fp.write(json.dumps(metadata))
348 logger.debug('preparing Vagrantfile for box %s', output)
349 with open('Vagrantfile', 'w') as fp:
350 fp.write(vagrantfile)
351 with tarfile.open(output, 'w:gz') as tar:
352 logger.debug('adding metadata.json to box %s ...', output)
353 tar.add('metadata.json')
354 logger.debug('adding Vagrantfile to box %s ...', output)
355 tar.add('Vagrantfile')
356 logger.debug('adding box.img to box %s ...', output)
359 if not keep_box_file:
360 logger.debug('box packaging complete, removing temporary files.')
361 rmfile('metadata.json')
362 rmfile('Vagrantfile')
366 logger.warn('could not connect to storage-pool \'default\',' +
367 'skipping packaging buildserver box')
369 def box_add(self, boxname, boxfile, force=True):
370 boximg = '%s_vagrant_box_image_0.img' % (boxname)
373 _check_call(['virsh', '-c', 'qemu:///system', 'vol-delete', '--pool', 'default', boximg])
374 logger.debug("removed old box image '%s' from libvirt storeage pool", boximg)
375 except subprocess.CalledProcessError as e:
376 logger.debug("tired removing old box image '%s', file was not present in first place", boximg, exc_info=e)
377 super().box_add(boxname, boxfile, force)
379 def box_remove(self, boxname):
380 super().box_remove(boxname)
382 _check_call(['virsh', '-c', 'qemu:///system', 'vol-delete', '--pool', 'default', '%s_vagrant_box_image_0.img' % (boxname)])
383 except subprocess.CalledProcessError as e:
384 logger.debug("tired removing '%s', file was not present in first place", boxname, exc_info=e)
386 def snapshot_create(self, snapshot_name):
387 logger.info("creating snapshot '%s' for vm '%s'", snapshot_name, self.srvname)
389 _check_call(['virsh', '-c', 'qemu:///system', 'snapshot-create-as', self.srvname, snapshot_name])
390 logger.info('...waiting a sec...')
392 except subprocess.CalledProcessError as e:
393 raise FDroidBuildVmException("could not cerate snapshot '%s' "
395 % (snapshot_name, self.srvname)) from e
397 def snapshot_list(self):
400 dom = self.conn.lookupByName(self.srvname)
401 return dom.listAllSnapshots()
402 except libvirt.libvirtError as e:
403 raise FDroidBuildVmException('could not list snapshots for domain \'%s\'' % self.srvname) from e
405 def snapshot_exists(self, snapshot_name):
408 dom = self.conn.lookupByName(self.srvname)
409 return dom.snapshotLookupByName(snapshot_name) is not None
410 except libvirt.libvirtError:
413 def snapshot_revert(self, snapshot_name):
414 logger.info("reverting vm '%s' to snapshot '%s'", self.srvname, snapshot_name)
417 dom = self.conn.lookupByName(self.srvname)
418 snap = dom.snapshotLookupByName(snapshot_name)
419 dom.revertToSnapshot(snap)
420 logger.info('...waiting a sec...')
422 except libvirt.libvirtError as e:
423 raise FDroidBuildVmException('could not revert domain \'%s\' to snapshot \'%s\''
424 % (self.srvname, snapshot_name)) from e
427 class VirtualboxBuildVm(FDroidBuildVm):
429 def __init__(self, srvdir):
430 self.provider = 'virtualbox'
431 super().__init__(srvdir)
433 def snapshot_create(self, snapshot_name):
434 logger.info("creating snapshot '%s' for vm '%s'", snapshot_name, self.srvname)
436 _check_call(['VBoxManage', 'snapshot', self.srvuuid, 'take', 'fdroidclean'], cwd=self.srvdir)
437 logger.info('...waiting a sec...')
439 except subprocess.CalledProcessError as e:
440 raise FDroidBuildVmException('could not cerate snapshot '
441 'of virtualbox vm %s'
442 % self.srvname) from e
444 def snapshot_list(self):
446 o = _check_output(['VBoxManage', 'snapshot',
447 self.srvuuid, 'list',
448 '--details'], cwd=self.srvdir)
450 except subprocess.CalledProcessError as e:
451 raise FDroidBuildVmException("could not list snapshots "
452 "of virtualbox vm '%s'"
453 % (self.srvname)) from e
455 def snapshot_exists(self, snapshot_name):
457 return str(snapshot_name) in str(self.snapshot_list())
458 except FDroidBuildVmException:
461 def snapshot_revert(self, snapshot_name):
462 logger.info("reverting vm '%s' to snapshot '%s'",
463 self.srvname, snapshot_name)
465 _check_call(['VBoxManage', 'snapshot', self.srvuuid,
466 'restore', 'fdroidclean'], cwd=self.srvdir)
467 except subprocess.CalledProcessError as e:
468 raise FDroidBuildVmException("could not load snapshot "
469 "'fdroidclean' for vm '%s'"
470 % (self.srvname)) from e