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
28 from .common import FDroidException
29 from logging import getLogger
31 logger = getLogger('fdroidserver-vmtools')
34 def _check_call(cmd, shell=False):
35 logger.debug(' '.join(cmd))
36 return subprocess.check_call(cmd, shell=shell)
39 def _check_output(cmd, shell=False):
40 logger.debug(' '.join(cmd))
41 return subprocess.check_output(cmd, shell=shell)
44 def get_build_vm(srvdir, provider=None):
45 """Factory function for getting FDroidBuildVm instances.
47 This function tries to figure out what hypervisor should be used
48 and creates an object for controlling a build VM.
50 :param srvdir: path to a directory which contains a Vagrantfile
51 :param provider: optionally this parameter allows specifiying an
52 spesific vagrant provider.
53 :returns: FDroidBuildVm instance.
55 abssrvdir = abspath(srvdir)
57 # use supplied provider
59 if provider == 'libvirt':
60 logger.debug('build vm provider \'libvirt\' selected')
61 return LibvirtBuildVm(abssrvdir)
62 elif provider == 'virtualbox':
63 logger.debug('build vm provider \'virtualbox\' selected')
64 return VirtualboxBuildVm(abssrvdir)
66 logger.warn('build vm provider not supported: \'%s\'', provider)
68 # try guessing provider from installed software
70 kvm_installed = 0 == _check_call(['which', 'kvm'])
71 except subprocess.CalledProcessError:
74 kvm_installed |= 0 == _check_call(['which', 'qemu'])
75 except subprocess.CalledProcessError:
78 vbox_installed = 0 == _check_call(['which', 'VBoxHeadless'], shell=True)
79 except subprocess.CalledProcessError:
80 vbox_installed = False
81 if kvm_installed and vbox_installed:
82 logger.debug('both kvm and vbox are installed.')
84 logger.debug('libvirt is the sole installed and supported vagrant provider, selecting \'libvirt\'')
85 return LibvirtBuildVm(abssrvdir)
87 logger.debug('virtualbox is the sole installed and supported vagrant provider, selecting \'virtualbox\'')
88 return VirtualboxBuildVm(abssrvdir)
90 logger.debug('could not confirm that either virtualbox or kvm/libvirt are installed')
92 # try guessing provider from .../srvdir/.vagrant internals
93 has_libvirt_machine = isdir(joinpath(abssrvdir, '.vagrant',
94 'machines', 'default', 'libvirt'))
95 has_vbox_machine = isdir(joinpath(abssrvdir, '.vagrant',
96 'machines', 'default', 'libvirt'))
97 if has_libvirt_machine and has_vbox_machine:
98 logger.info('build vm provider lookup found virtualbox and libvirt, defaulting to \'virtualbox\'')
99 return VirtualboxBuildVm(abssrvdir)
100 elif has_libvirt_machine:
101 logger.debug('build vm provider lookup found \'libvirt\'')
102 return LibvirtBuildVm(abssrvdir)
103 elif has_vbox_machine:
104 logger.debug('build vm provider lookup found \'virtualbox\'')
105 return VirtualboxBuildVm(abssrvdir)
107 logger.info('build vm provider lookup could not determine provider, defaulting to \'virtualbox\'')
108 return VirtualboxBuildVm(abssrvdir)
111 class FDroidBuildVmException(FDroidException):
115 class FDroidBuildVm():
116 """Abstract base class for working with FDroids build-servers.
118 Use the factory method `fdroidserver.vmtools.get_build_vm()` for
119 getting correct instances of this class.
121 This is intended to be a hypervisor independant, fault tolerant
122 wrapper around the vagrant functions we use.
125 def __init__(self, srvdir):
126 """Create new server class.
129 self.srvname = basename(srvdir) + '_default'
130 self.vgrntfile = joinpath(srvdir, 'Vagrantfile')
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))
135 self.vgrnt = vagrant.Vagrant(root=srvdir, out_cm=vagrant.stdout_cm, err_cm=vagrant.stdout_cm)
137 def isUpAndRunning(self):
138 raise NotImplementedError('TODO implement this')
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)
147 self.vgrnt.halt(force=True)
150 """Remove every trace of this VM from the system.
152 This includes deleting:
153 * hypervisor specific definitions
154 * vagrant state informations (eg. `.vagrant` folder)
155 * images related to this vm
159 logger.debug('vagrant destroy completed')
160 except subprocess.CalledProcessError as e:
161 logger.debug('vagrant destroy failed: %s', e)
162 vgrntdir = joinpath(self.srvdir, '.vagrant')
164 shutil.rmtree(vgrntdir)
165 logger.debug('deleted vagrant dir: %s', vgrntdir)
166 except Exception as e:
167 logger.debug("could not delete vagrant dir: %s, %s", vgrntdir, e)
169 _check_call(['vagrant', 'global-status', '--prune'])
170 except subprocess.CalledProcessError as e:
171 logger.debug('pruning global vagrant status failed: %s', e)
173 def package(self, output=None, vagrantfile=None, keep_box_file=None):
174 previous_tmp_dir = joinpath(self.srvdir, '_tmp_package')
175 if isdir(previous_tmp_dir):
176 logger.info('found previous vagrant package temp dir \'%s\', deleting it', previous_tmp_dir)
177 shutil.rmtree(previous_tmp_dir)
178 self.vgrnt.package(output=output, vagrantfile=vagrantfile)
180 def box_add(self, boxname, boxfile, force=True):
181 """Add vagrant box to vagrant.
183 :param boxname: name assigned to local deployment of box
184 :param boxfile: path to box file
185 :param force: overwrite existing box image (default: True)
187 boxfile = abspath(boxfile)
188 if not isfile(boxfile):
189 raise FDroidBuildVmException('supplied boxfile \'%s\' does not exist', boxfile)
190 self.vgrnt.box_add(boxname, abspath(boxfile), force=force)
192 def box_remove(self, boxname):
194 _check_call(['vagrant', 'box', 'remove', '--all', '--force', boxname])
195 except subprocess.CalledProcessError as e:
196 logger.debug('tried removing box %s, but is did not exist: %s', boxname, e)
197 # TODO: remove box files manually
198 # nesessary when Vagrantfile in ~/.vagrant.d/... is broken.
201 class LibvirtBuildVm(FDroidBuildVm):
202 def __init__(self, srvdir):
203 super().__init__(srvdir)
207 self.conn = libvirt.open('qemu:///system')
208 except libvirt.libvirtError as e:
209 raise FDroidBuildVmException('could not connect to libvirtd: %s' % (e))
215 # resorting to virsh instead of libvirt python bindings, because
216 # this is way more easy and therefore fault tolerant.
217 # (eg. lookupByName only works on running VMs)
219 _check_call(('virsh', '-c', 'qemu:///system', 'destroy', self.srvname))
220 logger.info("...waiting a sec...")
222 except subprocess.CalledProcessError as e:
223 logger.info("could not force libvirt domain '%s' off: %s", self.srvname, e)
225 # libvirt python bindings do not support all flags required
226 # for undefining domains correctly.
227 _check_call(('virsh', '-c', 'qemu:///system', 'undefine', self.srvname, '--nvram', '--managed-save', '--remove-all-storage', '--snapshots-metadata'))
228 logger.info("...waiting a sec...")
230 except subprocess.CalledProcessError as e:
231 logger.info("could not undefine libvirt domain '%s': %s", self.srvname, e)
233 def package(self, output=None, vagrantfile=None, keep_box_file=False):
235 output = "buildserver.box"
236 logger.debug('no output name set for packaging \'%s\',' +
237 'defaulting to %s', self.srvname, output)
239 virConnect = libvirt.open('qemu:///system')
240 storagePool = virConnect.storagePoolLookupByName('default')
243 if isfile('metadata.json'):
244 rmfile('metadata.json')
245 if isfile('Vagrantfile'):
246 rmfile('Vagrantfile')
247 if isfile('box.img'):
250 logger.debug('preparing box.img for box %s', output)
251 vol = storagePool.storageVolLookupByName(self.srvname + '.img')
252 imagepath = vol.path()
253 # TODO use a libvirt storage pool to ensure the img file is readable
254 _check_call(['sudo', '/bin/chmod', '-R', 'a+rX', '/var/lib/libvirt/images'])
255 shutil.copy2(imagepath, 'box.img')
256 _check_call(['qemu-img', 'rebase', '-p', '-b', '', 'box.img'])
257 img_info_raw = _check_output(['qemu-img', 'info', '--output=json', 'box.img'])
258 img_info = json.loads(img_info_raw.decode('utf-8'))
259 metadata = {"provider": "libvirt",
260 "format": img_info['format'],
261 "virtual_size": math.ceil(img_info['virtual-size'] / (1024. ** 3)) + 1,
265 logger.debug('no Vagrantfile supplied for box, generating a minimal one...')
266 vagrantfile = 'Vagrant.configure("2") do |config|\nend'
268 logger.debug('preparing metadata.json for box %s', output)
269 with open('metadata.json', 'w') as fp:
270 fp.write(json.dumps(metadata))
271 logger.debug('preparing Vagrantfile for box %s', output)
272 with open('Vagrantfile', 'w') as fp:
273 fp.write(vagrantfile)
274 with tarfile.open(output, 'w:gz') as tar:
275 logger.debug('adding metadata.json to box %s ...', output)
276 tar.add('metadata.json')
277 logger.debug('adding Vagrantfile to box %s ...', output)
278 tar.add('Vagrantfile')
279 logger.debug('adding box.img to box %s ...', output)
282 if not keep_box_file:
283 logger.debug('box packaging complete, removing temporary files.')
284 rmfile('metadata.json')
285 rmfile('Vagrantfile')
289 logger.warn('could not connect to storage-pool \'default\',' +
290 'skipping packaging buildserver box')
292 def box_remove(self, boxname):
293 super().box_remove(boxname)
295 _check_call(['virsh', '-c', 'qemu:///system', 'vol-delete', '--pool', 'default', '%s_vagrant_box_image_0.img' % (boxname)])
296 except subprocess.CalledProcessError as e:
297 logger.info('tired removing \'%s\', file was not present in first place: %s', boxname, e)
300 class VirtualboxBuildVm(FDroidBuildVm):