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 logger = getLogger('fdroidserver-vmtools')
34 def get_clean_builder(serverdir, reset=False):
35 if not os.path.isdir(serverdir):
36 if os.path.islink(serverdir):
38 logger.info("buildserver path does not exists, creating %s", serverdir)
39 os.makedirs(serverdir)
40 vagrantfile = os.path.join(serverdir, 'Vagrantfile')
41 if not os.path.isfile(vagrantfile):
42 with open(os.path.join('builder', 'Vagrantfile'), 'w') as f:
43 f.write(textwrap.dedent("""\
44 # generated file, do not change.
46 Vagrant.configure("2") do |config|
47 config.vm.box = "buildserver"
48 config.vm.synced_folder ".", "/vagrant", disabled: true
51 vm = get_build_vm(serverdir)
53 logger.info('resetting buildserver by request')
54 elif not vm.vagrant_uuid_okay():
55 logger.info('resetting buildserver, bceause vagrant vm is not okay.')
57 elif not vm.snapshot_exists('fdroidclean'):
58 logger.info("resetting buildserver, because snapshot 'fdroidclean' is not present.")
67 logger.info('buildserver recreated: taking a clean snapshot')
68 vm.snapshot_create('fdroidclean')
70 logger.info('builserver ok: reverting to clean snapshot')
71 vm.snapshot_revert('fdroidclean')
75 sshinfo = vm.sshinfo()
76 except FDroidBuildVmException:
77 # workaround because libvirt sometimes likes to forget
78 # about ssh connection info even thou the vm is running
81 sshinfo = vm.sshinfo()
86 def _check_call(cmd, shell=False, cwd=None):
87 logger.debug(' '.join(cmd))
88 return subprocess.check_call(cmd, shell=shell, cwd=cwd)
91 def _check_output(cmd, shell=False, cwd=None):
92 logger.debug(' '.join(cmd))
93 return subprocess.check_output(cmd, shell=shell, cwd=cwd)
96 def get_build_vm(srvdir, provider=None):
97 """Factory function for getting FDroidBuildVm instances.
99 This function tries to figure out what hypervisor should be used
100 and creates an object for controlling a build VM.
102 :param srvdir: path to a directory which contains a Vagrantfile
103 :param provider: optionally this parameter allows specifiying an
104 spesific vagrant provider.
105 :returns: FDroidBuildVm instance.
107 abssrvdir = abspath(srvdir)
109 # use supplied provider
111 if provider == 'libvirt':
112 logger.debug('build vm provider \'libvirt\' selected')
113 return LibvirtBuildVm(abssrvdir)
114 elif provider == 'virtualbox':
115 logger.debug('build vm provider \'virtualbox\' selected')
116 return VirtualboxBuildVm(abssrvdir)
118 logger.warn('build vm provider not supported: \'%s\'', provider)
120 # try guessing provider from installed software
122 kvm_installed = 0 == _check_call(['which', 'kvm'])
123 except subprocess.CalledProcessError:
124 kvm_installed = False
126 kvm_installed |= 0 == _check_call(['which', 'qemu'])
127 except subprocess.CalledProcessError:
130 vbox_installed = 0 == _check_call(['which', 'VBoxHeadless'])
131 except subprocess.CalledProcessError:
132 vbox_installed = False
133 if kvm_installed and vbox_installed:
134 logger.debug('both kvm and vbox are installed.')
136 logger.debug('libvirt is the sole installed and supported vagrant provider, selecting \'libvirt\'')
137 return LibvirtBuildVm(abssrvdir)
139 logger.debug('virtualbox is the sole installed and supported vagrant provider, selecting \'virtualbox\'')
140 return VirtualboxBuildVm(abssrvdir)
142 logger.debug('could not confirm that either virtualbox or kvm/libvirt are installed')
144 # try guessing provider from .../srvdir/.vagrant internals
145 has_libvirt_machine = isdir(joinpath(abssrvdir, '.vagrant',
146 'machines', 'default', 'libvirt'))
147 has_vbox_machine = isdir(joinpath(abssrvdir, '.vagrant',
148 'machines', 'default', 'libvirt'))
149 if has_libvirt_machine and has_vbox_machine:
150 logger.info('build vm provider lookup found virtualbox and libvirt, defaulting to \'virtualbox\'')
151 return VirtualboxBuildVm(abssrvdir)
152 elif has_libvirt_machine:
153 logger.debug('build vm provider lookup found \'libvirt\'')
154 return LibvirtBuildVm(abssrvdir)
155 elif has_vbox_machine:
156 logger.debug('build vm provider lookup found \'virtualbox\'')
157 return VirtualboxBuildVm(abssrvdir)
159 logger.info('build vm provider lookup could not determine provider, defaulting to \'virtualbox\'')
160 return VirtualboxBuildVm(abssrvdir)
163 class FDroidBuildVmException(FDroidException):
167 class FDroidBuildVm():
168 """Abstract base class for working with FDroids build-servers.
170 Use the factory method `fdroidserver.vmtools.get_build_vm()` for
171 getting correct instances of this class.
173 This is intended to be a hypervisor independant, fault tolerant
174 wrapper around the vagrant functions we use.
177 def __init__(self, srvdir):
178 """Create new server class.
181 self.srvname = basename(srvdir) + '_default'
182 self.vgrntfile = joinpath(srvdir, 'Vagrantfile')
183 self.srvuuid = self._vagrant_fetch_uuid()
184 if not isdir(srvdir):
185 raise FDroidBuildVmException("Can not init vagrant, directory %s not present" % (srvdir))
186 if not isfile(self.vgrntfile):
187 raise FDroidBuildVmException("Can not init vagrant, '%s' not present" % (self.vgrntfile))
189 self.vgrnt = vagrant.Vagrant(root=srvdir, out_cm=vagrant.stdout_cm, err_cm=vagrant.stdout_cm)
191 def up(self, provision=True):
193 self.vgrnt.up(provision=provision)
194 self.srvuuid = self._vagrant_fetch_uuid()
195 except subprocess.CalledProcessError as e:
196 raise FDroidBuildVmException("could not bring up vm '%s'" % self.srvname) from e
199 logger.info('suspending buildserver')
202 except subprocess.CalledProcessError as e:
203 raise FDroidBuildVmException("could not suspend vm '%s'" % self.srvname) from e
206 self.vgrnt.halt(force=True)
209 """Remove every trace of this VM from the system.
211 This includes deleting:
212 * hypervisor specific definitions
213 * vagrant state informations (eg. `.vagrant` folder)
214 * images related to this vm
216 logger.info("destroying vm '%s'", self.srvname)
219 logger.debug('vagrant destroy completed')
220 except subprocess.CalledProcessError as e:
221 logger.exception('vagrant destroy failed: %s', e)
222 vgrntdir = joinpath(self.srvdir, '.vagrant')
224 shutil.rmtree(vgrntdir)
225 logger.debug('deleted vagrant dir: %s', vgrntdir)
226 except Exception as e:
227 logger.debug("could not delete vagrant dir: %s, %s", vgrntdir, e)
229 _check_call(['vagrant', 'global-status', '--prune'])
230 except subprocess.CalledProcessError as e:
231 logger.debug('pruning global vagrant status failed: %s', e)
233 def package(self, output=None):
234 self.vgrnt.package(output=output)
236 def vagrant_uuid_okay(self):
237 '''Having an uuid means that vagrant up has run successfully.'''
238 if self.srvuuid is None:
242 def _vagrant_file_name(self, name):
243 return name.replace('/', '-VAGRANTSLASH-')
245 def _vagrant_fetch_uuid(self):
246 if isfile(joinpath(self.srvdir, '.vagrant')):
247 # Vagrant 1.0 - it's a json file...
248 with open(joinpath(self.srvdir, '.vagrant')) as f:
249 id = json.load(f)['active']['default']
250 logger.debug('vm uuid: %s', id)
252 elif isfile(joinpath(self.srvdir, '.vagrant', 'machines',
253 'default', self.provider, 'id')):
254 # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
255 with open(joinpath(self.srvdir, '.vagrant', 'machines',
256 'default', self.provider, 'id')) as f:
258 logger.debug('vm uuid: %s', id)
261 logger.debug('vm uuid is None')
264 def box_add(self, boxname, boxfile, force=True):
265 """Add vagrant box to vagrant.
267 :param boxname: name assigned to local deployment of box
268 :param boxfile: path to box file
269 :param force: overwrite existing box image (default: True)
271 boxfile = abspath(boxfile)
272 if not isfile(boxfile):
273 raise FDroidBuildVmException('supplied boxfile \'%s\' does not exist', boxfile)
274 self.vgrnt.box_add(boxname, abspath(boxfile), force=force)
276 def box_remove(self, boxname):
278 _check_call(['vagrant', 'box', 'remove', '--all', '--force', boxname])
279 except subprocess.CalledProcessError as e:
280 logger.debug('tried removing box %s, but is did not exist: %s', boxname, e)
281 boxpath = joinpath(expanduser('~'), '.vagrant',
282 self._vagrant_file_name(boxname))
284 logger.info("attempting to remove box '%s' by deleting: %s",
286 shutil.rmtree(boxpath)
289 """Get ssh connection info for a vagrant VM
291 :returns: A dictionary containing 'hostname', 'port', 'user'
296 _check_call(['vagrant ssh-config > sshconfig'],
297 cwd=self.srvdir, shell=True)
298 vagranthost = 'default' # Host in ssh config file
299 sshconfig = paramiko.SSHConfig()
300 with open(joinpath(self.srvdir, 'sshconfig'), 'r') as f:
302 sshconfig = sshconfig.lookup(vagranthost)
303 idfile = sshconfig['identityfile']
304 if isinstance(idfile, list):
306 elif idfile.startswith('"') and idfile.endswith('"'):
307 idfile = idfile[1:-1]
308 return {'hostname': sshconfig['hostname'],
309 'port': int(sshconfig['port']),
310 'user': sshconfig['user'],
312 except subprocess.CalledProcessError as e:
313 raise FDroidBuildVmException("Error getting ssh config") from e
315 def snapshot_create(self, snapshot_name):
316 raise NotImplementedError('not implemented, please use a sub-type instance')
318 def snapshot_list(self):
319 raise NotImplementedError('not implemented, please use a sub-type instance')
321 def snapshot_exists(self, snapshot_name):
322 raise NotImplementedError('not implemented, please use a sub-type instance')
324 def snapshot_revert(self, snapshot_name):
325 raise NotImplementedError('not implemented, please use a sub-type instance')
328 class LibvirtBuildVm(FDroidBuildVm):
329 def __init__(self, srvdir):
330 self.provider = 'libvirt'
331 super().__init__(srvdir)
335 self.conn = libvirt.open('qemu:///system')
336 except libvirt.libvirtError as e:
337 raise FDroidBuildVmException('could not connect to libvirtd: %s' % (e))
343 # resorting to virsh instead of libvirt python bindings, because
344 # this is way more easy and therefore fault tolerant.
345 # (eg. lookupByName only works on running VMs)
347 _check_call(('virsh', '-c', 'qemu:///system', 'destroy', self.srvname))
348 except subprocess.CalledProcessError as e:
349 logger.info("could not force libvirt domain '%s' off: %s", self.srvname, e)
351 # libvirt python bindings do not support all flags required
352 # for undefining domains correctly.
353 _check_call(('virsh', '-c', 'qemu:///system', 'undefine', self.srvname, '--nvram', '--managed-save', '--remove-all-storage', '--snapshots-metadata'))
354 except subprocess.CalledProcessError as e:
355 logger.info("could not undefine libvirt domain '%s': %s", self.srvname, e)
357 def package(self, output=None, keep_box_file=False):
359 output = "buildserver.box"
360 logger.debug('no output name set for packaging \'%s\',' +
361 'defaulting to %s', self.srvname, output)
362 storagePool = self.conn.storagePoolLookupByName('default')
363 domainInfo = self.conn.lookupByName(self.srvname).info()
366 if isfile('metadata.json'):
367 rmfile('metadata.json')
368 if isfile('Vagrantfile'):
369 rmfile('Vagrantfile')
370 if isfile('box.img'):
373 logger.debug('preparing box.img for box %s', output)
374 vol = storagePool.storageVolLookupByName(self.srvname + '.img')
375 imagepath = vol.path()
376 # TODO use a libvirt storage pool to ensure the img file is readable
377 _check_call(['sudo', '/bin/chmod', '-R', 'a+rX', '/var/lib/libvirt/images'])
378 shutil.copy2(imagepath, 'box.img')
379 _check_call(['qemu-img', 'rebase', '-p', '-b', '', 'box.img'])
380 img_info_raw = _check_output(['qemu-img', 'info', '--output=json', 'box.img'])
381 img_info = json.loads(img_info_raw.decode('utf-8'))
382 metadata = {"provider": "libvirt",
383 "format": img_info['format'],
384 "virtual_size": math.ceil(img_info['virtual-size'] / (1024. ** 3)),
387 logger.debug('preparing metadata.json for box %s', output)
388 with open('metadata.json', 'w') as fp:
389 fp.write(json.dumps(metadata))
390 logger.debug('preparing Vagrantfile for box %s', output)
391 vagrantfile = textwrap.dedent("""\
392 Vagrant.configure("2") do |config|
393 config.ssh.username = "vagrant"
394 config.ssh.password = "vagrant"
396 config.vm.provider :libvirt do |libvirt|
398 libvirt.driver = "kvm"
400 libvirt.connect_via_ssh = false
401 libvirt.storage_pool_name = "default"
402 libvirt.cpus = {cpus}
403 libvirt.memory = {memory}
406 end""".format_map({'memory': str(int(domainInfo[1] / 1024)), 'cpus': str(domainInfo[3])}))
407 with open('Vagrantfile', 'w') as fp:
408 fp.write(vagrantfile)
409 with tarfile.open(output, 'w:gz') as tar:
410 logger.debug('adding metadata.json to box %s ...', output)
411 tar.add('metadata.json')
412 logger.debug('adding Vagrantfile to box %s ...', output)
413 tar.add('Vagrantfile')
414 logger.debug('adding box.img to box %s ...', output)
417 if not keep_box_file:
418 logger.debug('box packaging complete, removing temporary files.')
419 rmfile('metadata.json')
420 rmfile('Vagrantfile')
424 logger.warn('could not connect to storage-pool \'default\',' +
425 'skipping packaging buildserver box')
427 def box_add(self, boxname, boxfile, force=True):
428 boximg = '%s_vagrant_box_image_0.img' % (boxname)
431 _check_call(['virsh', '-c', 'qemu:///system', 'vol-delete', '--pool', 'default', boximg])
432 logger.debug("removed old box image '%s' from libvirt storeage pool", boximg)
433 except subprocess.CalledProcessError as e:
434 logger.debug("tired removing old box image '%s', file was not present in first place", boximg, exc_info=e)
435 super().box_add(boxname, boxfile, force)
437 def box_remove(self, boxname):
438 super().box_remove(boxname)
440 _check_call(['virsh', '-c', 'qemu:///system', 'vol-delete', '--pool', 'default', '%s_vagrant_box_image_0.img' % (boxname)])
441 except subprocess.CalledProcessError as e:
442 logger.debug("tired removing '%s', file was not present in first place", boxname, exc_info=e)
444 def snapshot_create(self, snapshot_name):
445 logger.info("creating snapshot '%s' for vm '%s'", snapshot_name, self.srvname)
447 _check_call(['virsh', '-c', 'qemu:///system', 'snapshot-create-as', self.srvname, snapshot_name])
448 except subprocess.CalledProcessError as e:
449 raise FDroidBuildVmException("could not cerate snapshot '%s' "
451 % (snapshot_name, self.srvname)) from e
453 def snapshot_list(self):
456 dom = self.conn.lookupByName(self.srvname)
457 return dom.listAllSnapshots()
458 except libvirt.libvirtError as e:
459 raise FDroidBuildVmException('could not list snapshots for domain \'%s\'' % self.srvname) from e
461 def snapshot_exists(self, snapshot_name):
464 dom = self.conn.lookupByName(self.srvname)
465 return dom.snapshotLookupByName(snapshot_name) is not None
466 except libvirt.libvirtError:
469 def snapshot_revert(self, snapshot_name):
470 logger.info("reverting vm '%s' to snapshot '%s'", self.srvname, snapshot_name)
473 dom = self.conn.lookupByName(self.srvname)
474 snap = dom.snapshotLookupByName(snapshot_name)
475 dom.revertToSnapshot(snap)
476 except libvirt.libvirtError as e:
477 raise FDroidBuildVmException('could not revert domain \'%s\' to snapshot \'%s\''
478 % (self.srvname, snapshot_name)) from e
481 class VirtualboxBuildVm(FDroidBuildVm):
483 def __init__(self, srvdir):
484 self.provider = 'virtualbox'
485 super().__init__(srvdir)
487 def snapshot_create(self, snapshot_name):
488 logger.info("creating snapshot '%s' for vm '%s'", snapshot_name, self.srvname)
490 _check_call(['VBoxManage', 'snapshot', self.srvuuid, 'take', 'fdroidclean'], cwd=self.srvdir)
491 except subprocess.CalledProcessError as e:
492 raise FDroidBuildVmException('could not cerate snapshot '
493 'of virtualbox vm %s'
494 % self.srvname) from e
496 def snapshot_list(self):
498 o = _check_output(['VBoxManage', 'snapshot',
499 self.srvuuid, 'list',
500 '--details'], cwd=self.srvdir)
502 except subprocess.CalledProcessError as e:
503 raise FDroidBuildVmException("could not list snapshots "
504 "of virtualbox vm '%s'"
505 % (self.srvname)) from e
507 def snapshot_exists(self, snapshot_name):
509 return str(snapshot_name) in str(self.snapshot_list())
510 except FDroidBuildVmException:
513 def snapshot_revert(self, snapshot_name):
514 logger.info("reverting vm '%s' to snapshot '%s'",
515 self.srvname, snapshot_name)
517 _check_call(['VBoxManage', 'snapshot', self.srvuuid,
518 'restore', 'fdroidclean'], cwd=self.srvdir)
519 except subprocess.CalledProcessError as e:
520 raise FDroidBuildVmException("could not load snapshot "
521 "'fdroidclean' for vm '%s'"
522 % (self.srvname)) from e