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
28 from .common import FDroidException
29 from logging import getLogger
31 from fdroidserver import _
33 logger = getLogger('fdroidserver-vmtools')
36 def get_clean_builder(serverdir, reset=False):
37 if not os.path.isdir(serverdir):
38 if os.path.islink(serverdir):
40 logger.info("buildserver path does not exists, creating %s", serverdir)
41 os.makedirs(serverdir)
42 vagrantfile = os.path.join(serverdir, 'Vagrantfile')
43 if not os.path.isfile(vagrantfile):
44 with open(os.path.join('builder', 'Vagrantfile'), 'w') as f:
45 f.write(textwrap.dedent("""\
46 # generated file, do not change.
48 Vagrant.configure("2") do |config|
49 config.vm.box = "buildserver"
50 config.vm.synced_folder ".", "/vagrant", disabled: true
53 vm = get_build_vm(serverdir)
55 logger.info('resetting buildserver by request')
56 elif not vm.vagrant_uuid_okay():
57 logger.info('resetting buildserver, bceause vagrant vm is not okay.')
59 elif not vm.snapshot_exists('fdroidclean'):
60 logger.info("resetting buildserver, because snapshot 'fdroidclean' is not present.")
69 logger.info('buildserver recreated: taking a clean snapshot')
70 vm.snapshot_create('fdroidclean')
72 logger.info('builserver ok: reverting to clean snapshot')
73 vm.snapshot_revert('fdroidclean')
77 sshinfo = vm.sshinfo()
78 except FDroidBuildVmException:
79 # workaround because libvirt sometimes likes to forget
80 # about ssh connection info even thou the vm is running
83 sshinfo = vm.sshinfo()
88 def _check_call(cmd, shell=False, cwd=None):
89 logger.debug(' '.join(cmd))
90 return subprocess.check_call(cmd, shell=shell, cwd=cwd)
93 def _check_output(cmd, shell=False, cwd=None):
94 logger.debug(' '.join(cmd))
95 return subprocess.check_output(cmd, shell=shell, cwd=cwd)
98 def get_build_vm(srvdir, provider=None):
99 """Factory function for getting FDroidBuildVm instances.
101 This function tries to figure out what hypervisor should be used
102 and creates an object for controlling a build VM.
104 :param srvdir: path to a directory which contains a Vagrantfile
105 :param provider: optionally this parameter allows specifiying an
106 spesific vagrant provider.
107 :returns: FDroidBuildVm instance.
109 abssrvdir = abspath(srvdir)
111 # use supplied provider
113 if provider == 'libvirt':
114 logger.debug('build vm provider \'libvirt\' selected')
115 return LibvirtBuildVm(abssrvdir)
116 elif provider == 'virtualbox':
117 logger.debug('build vm provider \'virtualbox\' selected')
118 return VirtualboxBuildVm(abssrvdir)
120 logger.warn('build vm provider not supported: \'%s\'', provider)
122 # try guessing provider from installed software
124 kvm_installed = 0 == _check_call(['which', 'kvm'])
125 except subprocess.CalledProcessError:
126 kvm_installed = False
128 kvm_installed |= 0 == _check_call(['which', 'qemu'])
129 except subprocess.CalledProcessError:
132 vbox_installed = 0 == _check_call(['which', 'VBoxHeadless'])
133 except subprocess.CalledProcessError:
134 vbox_installed = False
135 if kvm_installed and vbox_installed:
136 logger.debug('both kvm and vbox are installed.')
138 logger.debug('libvirt is the sole installed and supported vagrant provider, selecting \'libvirt\'')
139 return LibvirtBuildVm(abssrvdir)
141 logger.debug('virtualbox is the sole installed and supported vagrant provider, selecting \'virtualbox\'')
142 return VirtualboxBuildVm(abssrvdir)
144 logger.debug('could not confirm that either virtualbox or kvm/libvirt are installed')
146 # try guessing provider from .../srvdir/.vagrant internals
147 has_libvirt_machine = isdir(joinpath(abssrvdir, '.vagrant',
148 'machines', 'default', 'libvirt'))
149 has_vbox_machine = isdir(joinpath(abssrvdir, '.vagrant',
150 'machines', 'default', 'virtualbox'))
151 if has_libvirt_machine and has_vbox_machine:
152 logger.info('build vm provider lookup found virtualbox and libvirt, defaulting to \'virtualbox\'')
153 return VirtualboxBuildVm(abssrvdir)
154 elif has_libvirt_machine:
155 logger.debug('build vm provider lookup found \'libvirt\'')
156 return LibvirtBuildVm(abssrvdir)
157 elif has_vbox_machine:
158 logger.debug('build vm provider lookup found \'virtualbox\'')
159 return VirtualboxBuildVm(abssrvdir)
161 logger.info('build vm provider lookup could not determine provider, defaulting to \'virtualbox\'')
162 return VirtualboxBuildVm(abssrvdir)
165 class FDroidBuildVmException(FDroidException):
169 class FDroidBuildVm():
170 """Abstract base class for working with FDroids build-servers.
172 Use the factory method `fdroidserver.vmtools.get_build_vm()` for
173 getting correct instances of this class.
175 This is intended to be a hypervisor independant, fault tolerant
176 wrapper around the vagrant functions we use.
179 def __init__(self, srvdir):
180 """Create new server class.
183 self.srvname = basename(srvdir) + '_default'
184 self.vgrntfile = joinpath(srvdir, 'Vagrantfile')
185 self.srvuuid = self._vagrant_fetch_uuid()
186 if not isdir(srvdir):
187 raise FDroidBuildVmException("Can not init vagrant, directory %s not present" % (srvdir))
188 if not isfile(self.vgrntfile):
189 raise FDroidBuildVmException("Can not init vagrant, '%s' not present" % (self.vgrntfile))
191 self.vgrnt = vagrant.Vagrant(root=srvdir, out_cm=vagrant.stdout_cm, err_cm=vagrant.stdout_cm)
193 def up(self, provision=True):
195 self.vgrnt.up(provision=provision)
196 self.srvuuid = self._vagrant_fetch_uuid()
197 except subprocess.CalledProcessError as e:
198 raise FDroidBuildVmException("could not bring up vm '%s'" % self.srvname) from e
201 logger.info('suspending buildserver')
204 except subprocess.CalledProcessError as e:
205 raise FDroidBuildVmException("could not suspend vm '%s'" % self.srvname) from e
208 self.vgrnt.halt(force=True)
211 """Remove every trace of this VM from the system.
213 This includes deleting:
214 * hypervisor specific definitions
215 * vagrant state informations (eg. `.vagrant` folder)
216 * images related to this vm
218 logger.info("destroying vm '%s'", self.srvname)
221 logger.debug('vagrant destroy completed')
222 except subprocess.CalledProcessError as e:
223 logger.exception('vagrant destroy failed: %s', e)
224 vgrntdir = joinpath(self.srvdir, '.vagrant')
226 shutil.rmtree(vgrntdir)
227 logger.debug('deleted vagrant dir: %s', vgrntdir)
228 except Exception as e:
229 logger.debug("could not delete vagrant dir: %s, %s", vgrntdir, e)
231 _check_call(['vagrant', 'global-status', '--prune'])
232 except subprocess.CalledProcessError as e:
233 logger.debug('pruning global vagrant status failed: %s', e)
235 def package(self, output=None):
236 self.vgrnt.package(output=output)
238 def vagrant_uuid_okay(self):
239 '''Having an uuid means that vagrant up has run successfully.'''
240 if self.srvuuid is None:
244 def _vagrant_file_name(self, name):
245 return name.replace('/', '-VAGRANTSLASH-')
247 def _vagrant_fetch_uuid(self):
248 if isfile(joinpath(self.srvdir, '.vagrant')):
249 # Vagrant 1.0 - it's a json file...
250 with open(joinpath(self.srvdir, '.vagrant')) as f:
251 id = json.load(f)['active']['default']
252 logger.debug('vm uuid: %s', id)
254 elif isfile(joinpath(self.srvdir, '.vagrant', 'machines',
255 'default', self.provider, 'id')):
256 # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
257 with open(joinpath(self.srvdir, '.vagrant', 'machines',
258 'default', self.provider, 'id')) as f:
260 logger.debug('vm uuid: %s', id)
263 logger.debug('vm uuid is None')
266 def box_add(self, boxname, boxfile, force=True):
267 """Add vagrant box to vagrant.
269 :param boxname: name assigned to local deployment of box
270 :param boxfile: path to box file
271 :param force: overwrite existing box image (default: True)
273 boxfile = abspath(boxfile)
274 if not isfile(boxfile):
275 raise FDroidBuildVmException('supplied boxfile \'%s\' does not exist', boxfile)
276 self.vgrnt.box_add(boxname, abspath(boxfile), force=force)
278 def box_remove(self, boxname):
280 _check_call(['vagrant', 'box', 'remove', '--all', '--force', boxname])
281 except subprocess.CalledProcessError as e:
282 logger.debug('tried removing box %s, but is did not exist: %s', boxname, e)
283 boxpath = joinpath(expanduser('~'), '.vagrant',
284 self._vagrant_file_name(boxname))
286 logger.info("attempting to remove box '%s' by deleting: %s",
288 shutil.rmtree(boxpath)
291 """Get ssh connection info for a vagrant VM
293 :returns: A dictionary containing 'hostname', 'port', 'user'
298 _check_call(['vagrant ssh-config > sshconfig'],
299 cwd=self.srvdir, shell=True)
300 vagranthost = 'default' # Host in ssh config file
301 sshconfig = paramiko.SSHConfig()
302 with open(joinpath(self.srvdir, 'sshconfig'), 'r') as f:
304 sshconfig = sshconfig.lookup(vagranthost)
305 idfile = sshconfig['identityfile']
306 if isinstance(idfile, list):
308 elif idfile.startswith('"') and idfile.endswith('"'):
309 idfile = idfile[1:-1]
310 return {'hostname': sshconfig['hostname'],
311 'port': int(sshconfig['port']),
312 'user': sshconfig['user'],
314 except subprocess.CalledProcessError as e:
315 raise FDroidBuildVmException("Error getting ssh config") from e
317 def snapshot_create(self, snapshot_name):
318 raise NotImplementedError('not implemented, please use a sub-type instance')
320 def snapshot_list(self):
321 raise NotImplementedError('not implemented, please use a sub-type instance')
323 def snapshot_exists(self, snapshot_name):
324 raise NotImplementedError('not implemented, please use a sub-type instance')
326 def snapshot_revert(self, snapshot_name):
327 raise NotImplementedError('not implemented, please use a sub-type instance')
330 class LibvirtBuildVm(FDroidBuildVm):
331 def __init__(self, srvdir):
332 self.provider = 'libvirt'
333 super().__init__(srvdir)
337 self.conn = libvirt.open('qemu:///system')
338 except libvirt.libvirtError as e:
339 raise FDroidBuildVmException('could not connect to libvirtd: %s' % (e))
345 # resorting to virsh instead of libvirt python bindings, because
346 # this is way more easy and therefore fault tolerant.
347 # (eg. lookupByName only works on running VMs)
349 _check_call(('virsh', '-c', 'qemu:///system', 'destroy', self.srvname))
350 except subprocess.CalledProcessError as e:
351 logger.info("could not force libvirt domain '%s' off: %s", self.srvname, e)
353 # libvirt python bindings do not support all flags required
354 # for undefining domains correctly.
355 _check_call(('virsh', '-c', 'qemu:///system', 'undefine', self.srvname, '--nvram', '--managed-save', '--remove-all-storage', '--snapshots-metadata'))
356 except subprocess.CalledProcessError as e:
357 logger.info("could not undefine libvirt domain '%s': %s", self.srvname, e)
359 def package(self, output=None, keep_box_file=False):
361 output = "buildserver.box"
362 logger.debug('no output name set for packaging \'%s\',' +
363 'defaulting to %s', self.srvname, output)
364 storagePool = self.conn.storagePoolLookupByName('default')
365 domainInfo = self.conn.lookupByName(self.srvname).info()
368 if isfile('metadata.json'):
369 rmfile('metadata.json')
370 if isfile('Vagrantfile'):
371 rmfile('Vagrantfile')
372 if isfile('box.img'):
375 logger.debug('preparing box.img for box %s', output)
376 vol = storagePool.storageVolLookupByName(self.srvname + '.img')
377 imagepath = vol.path()
378 # TODO use a libvirt storage pool to ensure the img file is readable
379 if not os.access(imagepath, os.R_OK):
380 logger.warning(_('Cannot read "{path}"!').format(path=imagepath))
381 _check_call(['sudo', '/bin/chmod', '-R', 'a+rX', '/var/lib/libvirt/images'])
382 shutil.copy2(imagepath, 'box.img')
383 _check_call(['qemu-img', 'rebase', '-p', '-b', '', 'box.img'])
384 img_info_raw = _check_output(['qemu-img', 'info', '--output=json', 'box.img'])
385 img_info = json.loads(img_info_raw.decode('utf-8'))
386 metadata = {"provider": "libvirt",
387 "format": img_info['format'],
388 "virtual_size": math.ceil(img_info['virtual-size'] / (1024. ** 3)),
391 logger.debug('preparing metadata.json for box %s', output)
392 with open('metadata.json', 'w') as fp:
393 fp.write(json.dumps(metadata))
394 logger.debug('preparing Vagrantfile for box %s', output)
395 vagrantfile = textwrap.dedent("""\
396 Vagrant.configure("2") do |config|
397 config.ssh.username = "vagrant"
398 config.ssh.password = "vagrant"
400 config.vm.provider :libvirt do |libvirt|
402 libvirt.driver = "kvm"
404 libvirt.connect_via_ssh = false
405 libvirt.storage_pool_name = "default"
406 libvirt.cpus = {cpus}
407 libvirt.memory = {memory}
410 end""".format_map({'memory': str(int(domainInfo[1] / 1024)), 'cpus': str(domainInfo[3])}))
411 with open('Vagrantfile', 'w') as fp:
412 fp.write(vagrantfile)
413 with tarfile.open(output, 'w:gz') as tar:
414 logger.debug('adding metadata.json to box %s ...', output)
415 tar.add('metadata.json')
416 logger.debug('adding Vagrantfile to box %s ...', output)
417 tar.add('Vagrantfile')
418 logger.debug('adding box.img to box %s ...', output)
421 if not keep_box_file:
422 logger.debug('box packaging complete, removing temporary files.')
423 rmfile('metadata.json')
424 rmfile('Vagrantfile')
428 logger.warn('could not connect to storage-pool \'default\',' +
429 'skipping packaging buildserver box')
431 def box_add(self, boxname, boxfile, force=True):
432 boximg = '%s_vagrant_box_image_0.img' % (boxname)
435 _check_call(['virsh', '-c', 'qemu:///system', 'vol-delete', '--pool', 'default', boximg])
436 logger.debug("removed old box image '%s' from libvirt storeage pool", boximg)
437 except subprocess.CalledProcessError as e:
438 logger.debug("tired removing old box image '%s', file was not present in first place", boximg, exc_info=e)
439 super().box_add(boxname, boxfile, force)
441 def box_remove(self, boxname):
442 super().box_remove(boxname)
444 _check_call(['virsh', '-c', 'qemu:///system', 'vol-delete', '--pool', 'default', '%s_vagrant_box_image_0.img' % (boxname)])
445 except subprocess.CalledProcessError as e:
446 logger.debug("tired removing '%s', file was not present in first place", boxname, exc_info=e)
448 def snapshot_create(self, snapshot_name):
449 logger.info("creating snapshot '%s' for vm '%s'", snapshot_name, self.srvname)
451 _check_call(['virsh', '-c', 'qemu:///system', 'snapshot-create-as', self.srvname, snapshot_name])
452 except subprocess.CalledProcessError as e:
453 raise FDroidBuildVmException("could not cerate snapshot '%s' "
455 % (snapshot_name, self.srvname)) from e
457 def snapshot_list(self):
460 dom = self.conn.lookupByName(self.srvname)
461 return dom.listAllSnapshots()
462 except libvirt.libvirtError as e:
463 raise FDroidBuildVmException('could not list snapshots for domain \'%s\'' % self.srvname) from e
465 def snapshot_exists(self, snapshot_name):
468 dom = self.conn.lookupByName(self.srvname)
469 return dom.snapshotLookupByName(snapshot_name) is not None
470 except libvirt.libvirtError:
473 def snapshot_revert(self, snapshot_name):
474 logger.info("reverting vm '%s' to snapshot '%s'", self.srvname, snapshot_name)
477 dom = self.conn.lookupByName(self.srvname)
478 snap = dom.snapshotLookupByName(snapshot_name)
479 dom.revertToSnapshot(snap)
480 except libvirt.libvirtError as e:
481 raise FDroidBuildVmException('could not revert domain \'%s\' to snapshot \'%s\''
482 % (self.srvname, snapshot_name)) from e
485 class VirtualboxBuildVm(FDroidBuildVm):
487 def __init__(self, srvdir):
488 self.provider = 'virtualbox'
489 super().__init__(srvdir)
491 def snapshot_create(self, snapshot_name):
492 logger.info("creating snapshot '%s' for vm '%s'", snapshot_name, self.srvname)
494 _check_call(['VBoxManage', 'snapshot', self.srvuuid, 'take', 'fdroidclean'], cwd=self.srvdir)
495 except subprocess.CalledProcessError as e:
496 raise FDroidBuildVmException('could not cerate snapshot '
497 'of virtualbox vm %s'
498 % self.srvname) from e
500 def snapshot_list(self):
502 o = _check_output(['VBoxManage', 'snapshot',
503 self.srvuuid, 'list',
504 '--details'], cwd=self.srvdir)
506 except subprocess.CalledProcessError as e:
507 raise FDroidBuildVmException("could not list snapshots "
508 "of virtualbox vm '%s'"
509 % (self.srvname)) from e
511 def snapshot_exists(self, snapshot_name):
513 return str(snapshot_name) in str(self.snapshot_list())
514 except FDroidBuildVmException:
517 def snapshot_revert(self, snapshot_name):
518 logger.info("reverting vm '%s' to snapshot '%s'",
519 self.srvname, snapshot_name)
521 _check_call(['VBoxManage', 'snapshot', self.srvuuid,
522 'restore', 'fdroidclean'], cwd=self.srvdir)
523 except subprocess.CalledProcessError as e:
524 raise FDroidBuildVmException("could not load snapshot "
525 "'fdroidclean' for vm '%s'"
526 % (self.srvname)) from e