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):
34 logger.debug(' '.join(cmd))
35 return subprocess.check_call(cmd, shell=shell)
38 def _check_output(cmd, shell=False):
39 logger.debug(' '.join(cmd))
40 return subprocess.check_output(cmd, shell=shell)
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'], shell=True)
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 if not isdir(srvdir):
131 raise FDroidBuildVmException("Can not init vagrant, directory %s not present" % (srvdir))
132 if not isfile(self.vgrntfile):
133 raise FDroidBuildVmException("Can not init vagrant, '%s' not present" % (self.vgrntfile))
135 self.vgrnt = vagrant.Vagrant(root=srvdir, out_cm=vagrant.stdout_cm, err_cm=vagrant.stdout_cm)
137 def check_okay(self):
140 def up(self, provision=True):
142 self.vgrnt.up(provision=provision)
143 except subprocess.CalledProcessError as e:
144 logger.info('could not bring vm up: %s', e)
146 def snapshot_create(self, name):
147 raise NotImplementedError('not implemented, please use a sub-type instance')
156 self.vgrnt.halt(force=True)
159 """Remove every trace of this VM from the system.
161 This includes deleting:
162 * hypervisor specific definitions
163 * vagrant state informations (eg. `.vagrant` folder)
164 * images related to this vm
168 logger.debug('vagrant destroy completed')
169 except subprocess.CalledProcessError as e:
170 logger.debug('vagrant destroy failed: %s', e)
171 vgrntdir = joinpath(self.srvdir, '.vagrant')
173 shutil.rmtree(vgrntdir)
174 logger.debug('deleted vagrant dir: %s', vgrntdir)
175 except Exception as e:
176 logger.debug("could not delete vagrant dir: %s, %s", vgrntdir, e)
178 _check_call(['vagrant', 'global-status', '--prune'])
179 except subprocess.CalledProcessError as e:
180 logger.debug('pruning global vagrant status failed: %s', e)
182 def package(self, output=None, vagrantfile=None, keep_box_file=None):
183 previous_tmp_dir = joinpath(self.srvdir, '_tmp_package')
184 if isdir(previous_tmp_dir):
185 logger.info('found previous vagrant package temp dir \'%s\', deleting it', previous_tmp_dir)
186 shutil.rmtree(previous_tmp_dir)
187 self.vgrnt.package(output=output, vagrantfile=vagrantfile)
189 def _vagrant_file_name(self, name):
190 return name.replace('/', '-VAGRANTSLASH-')
192 def box_add(self, boxname, boxfile, force=True):
193 """Add vagrant box to vagrant.
195 :param boxname: name assigned to local deployment of box
196 :param boxfile: path to box file
197 :param force: overwrite existing box image (default: True)
199 boxfile = abspath(boxfile)
200 if not isfile(boxfile):
201 raise FDroidBuildVmException('supplied boxfile \'%s\' does not exist', boxfile)
202 self.vgrnt.box_add(boxname, abspath(boxfile), force=force)
204 def box_remove(self, boxname):
206 _check_call(['vagrant', 'box', 'remove', '--all', '--force', boxname])
207 except subprocess.CalledProcessError as e:
208 logger.debug('tried removing box %s, but is did not exist: %s', boxname, e)
209 boxpath = joinpath(expanduser('~'), '.vagrant',
210 self._vagrant_file_name(boxname))
212 logger.info("attempting to remove box '%s' by deleting: %s",
214 shutil.rmtree(boxpath)
217 class LibvirtBuildVm(FDroidBuildVm):
218 def __init__(self, srvdir):
219 super().__init__(srvdir)
223 self.conn = libvirt.open('qemu:///system')
224 except libvirt.libvirtError as e:
225 raise FDroidBuildVmException('could not connect to libvirtd: %s' % (e))
227 def check_okay(self):
229 imagepath = joinpath('var', 'lib', 'libvirt', 'images',
230 '%s.img' % self._vagrant_file_name(self.srvname))
231 image_present = False
232 if isfile(imagepath):
235 self.conn.lookupByName(self.srvname)
236 domain_defined = True
237 except libvirt.libvirtError:
239 if image_present and domain_defined:
247 # resorting to virsh instead of libvirt python bindings, because
248 # this is way more easy and therefore fault tolerant.
249 # (eg. lookupByName only works on running VMs)
251 _check_call(('virsh', '-c', 'qemu:///system', 'destroy', self.srvname))
252 logger.info("...waiting a sec...")
254 except subprocess.CalledProcessError as e:
255 logger.info("could not force libvirt domain '%s' off: %s", self.srvname, e)
257 # libvirt python bindings do not support all flags required
258 # for undefining domains correctly.
259 _check_call(('virsh', '-c', 'qemu:///system', 'undefine', self.srvname, '--nvram', '--managed-save', '--remove-all-storage', '--snapshots-metadata'))
260 logger.info("...waiting a sec...")
262 except subprocess.CalledProcessError as e:
263 logger.info("could not undefine libvirt domain '%s': %s", self.srvname, e)
265 def package(self, output=None, vagrantfile=None, keep_box_file=False):
267 output = "buildserver.box"
268 logger.debug('no output name set for packaging \'%s\',' +
269 'defaulting to %s', self.srvname, output)
270 storagePool = self.conn.storagePoolLookupByName('default')
273 if isfile('metadata.json'):
274 rmfile('metadata.json')
275 if isfile('Vagrantfile'):
276 rmfile('Vagrantfile')
277 if isfile('box.img'):
280 logger.debug('preparing box.img for box %s', output)
281 vol = storagePool.storageVolLookupByName(self.srvname + '.img')
282 imagepath = vol.path()
283 # TODO use a libvirt storage pool to ensure the img file is readable
284 _check_call(['sudo', '/bin/chmod', '-R', 'a+rX', '/var/lib/libvirt/images'])
285 shutil.copy2(imagepath, 'box.img')
286 _check_call(['qemu-img', 'rebase', '-p', '-b', '', 'box.img'])
287 img_info_raw = _check_output(['qemu-img', 'info', '--output=json', 'box.img'])
288 img_info = json.loads(img_info_raw.decode('utf-8'))
289 metadata = {"provider": "libvirt",
290 "format": img_info['format'],
291 "virtual_size": math.ceil(img_info['virtual-size'] / (1024. ** 3)),
295 logger.debug('no Vagrantfile supplied for box, generating a minimal one...')
296 vagrantfile = 'Vagrant.configure("2") do |config|\nend'
298 logger.debug('preparing metadata.json for box %s', output)
299 with open('metadata.json', 'w') as fp:
300 fp.write(json.dumps(metadata))
301 logger.debug('preparing Vagrantfile for box %s', output)
302 with open('Vagrantfile', 'w') as fp:
303 fp.write(vagrantfile)
304 with tarfile.open(output, 'w:gz') as tar:
305 logger.debug('adding metadata.json to box %s ...', output)
306 tar.add('metadata.json')
307 logger.debug('adding Vagrantfile to box %s ...', output)
308 tar.add('Vagrantfile')
309 logger.debug('adding box.img to box %s ...', output)
312 if not keep_box_file:
313 logger.debug('box packaging complete, removing temporary files.')
314 rmfile('metadata.json')
315 rmfile('Vagrantfile')
319 logger.warn('could not connect to storage-pool \'default\',' +
320 'skipping packaging buildserver box')
322 def box_remove(self, boxname):
323 super().box_remove(boxname)
325 _check_call(['virsh', '-c', 'qemu:///system', 'vol-delete', '--pool', 'default', '%s_vagrant_box_image_0.img' % (boxname)])
326 except subprocess.CalledProcessError as e:
327 logger.info('tired removing \'%s\', file was not present in first place: %s', boxname, e)
329 def snapshot_create(self, snapshot_name):
331 _check_call(['virsh', '-c', 'qemu:///system', 'snapshot-create-as', self.srvname, snapshot_name])
332 logger.info('...waiting a sec...')
334 except subprocess.CalledProcessError as e:
335 raise FDroidBuildVmException("could not cerate snapshot '%s' "
337 % (snapshot_name, self.srvname)) from e
339 def snapshot_list(self):
342 dom = self.conn.lookupByName(self.srvname)
343 return dom.listAllSnapshots()
344 except libvirt.libvirtError as e:
345 raise FDroidBuildVmException('could not list snapshots for domain \'%s\'' % self.srvname) from e
347 def snapshot_exists(self, snapshot_name):
350 dom = self.conn.lookupByName(self.srvname)
351 return dom.snapshotLookupByName(snapshot_name) is not None
352 except libvirt.libvirtError:
355 def snapshot_revert(self, snapshot_name):
358 dom = self.conn.lookupByName(self.srvname)
359 snap = dom.snapshotLookupByName(snapshot_name)
360 dom.revertToSnapshot(snap)
361 logger.info('...waiting a sec...')
363 except libvirt.libvirtError as e:
364 raise FDroidBuildVmException('could not revert domain \'%s\' to snapshot \'%s\''
365 % (self.srvname, snapshot_name)) from e
368 class VirtualboxBuildVm(FDroidBuildVm):
369 def snapshot_create(self, snapshot_name):
370 raise NotImplemented('TODO')
372 _check_call(['VBoxManage', 'snapshot', self.srvname, 'take', 'fdroidclean'], cwd=self.srvdir)
373 logger.info('...waiting a sec...')
375 except subprocess.CalledProcessError as e:
376 raise FDroidBuildVmException('could not cerate snapshot '
377 'of virtualbox vm %s'
378 % self.srvname) from e
380 def snapshot_available(self, snapshot_name):
381 raise NotImplemented('TODO')