chiark / gitweb /
revised build server creation
[fdroidserver.git] / fdroidserver / vmtools.py
1 #!/usr/bin/env python3
2 #
3 # vmtools.py - part of the FDroid server tools
4 # Copyright (C) 2017 Michael Poehn <michael.poehn@fsfe.org>
5 #
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.
10 #
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.
15 #
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/>.
18
19 from os import remove as rmfile
20 from os.path import isdir, isfile, join as joinpath, basename, abspath, expanduser
21 import math
22 import json
23 import tarfile
24 import time
25 import shutil
26 import subprocess
27 from .common import FDroidException
28 from logging import getLogger
29
30 logger = getLogger('fdroidserver-vmtools')
31
32
33 def _check_call(cmd, shell=False):
34     logger.debug(' '.join(cmd))
35     return subprocess.check_call(cmd, shell=shell)
36
37
38 def _check_output(cmd, shell=False):
39     logger.debug(' '.join(cmd))
40     return subprocess.check_output(cmd, shell=shell)
41
42
43 def get_build_vm(srvdir, provider=None):
44     """Factory function for getting FDroidBuildVm instances.
45
46     This function tries to figure out what hypervisor should be used
47     and creates an object for controlling a build VM.
48
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.
53     """
54     abssrvdir = abspath(srvdir)
55
56     # use supplied provider
57     if 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)
64         else:
65             logger.warn('build vm provider not supported: \'%s\'', provider)
66
67     # try guessing provider from installed software
68     try:
69         kvm_installed = 0 == _check_call(['which', 'kvm'])
70     except subprocess.CalledProcessError:
71         kvm_installed = False
72         try:
73             kvm_installed |= 0 == _check_call(['which', 'qemu'])
74         except subprocess.CalledProcessError:
75             pass
76     try:
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.')
82     elif kvm_installed:
83         logger.debug('libvirt is the sole installed and supported vagrant provider, selecting \'libvirt\'')
84         return LibvirtBuildVm(abssrvdir)
85     elif vbox_installed:
86         logger.debug('virtualbox is the sole installed and supported vagrant provider, selecting \'virtualbox\'')
87         return VirtualboxBuildVm(abssrvdir)
88     else:
89         logger.debug('could not confirm that either virtualbox or kvm/libvirt are installed')
90
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)
105
106     logger.info('build vm provider lookup could not determine provider, defaulting to \'virtualbox\'')
107     return VirtualboxBuildVm(abssrvdir)
108
109
110 class FDroidBuildVmException(FDroidException):
111     pass
112
113
114 class FDroidBuildVm():
115     """Abstract base class for working with FDroids build-servers.
116
117     Use the factory method `fdroidserver.vmtools.get_build_vm()` for
118     getting correct instances of this class.
119
120     This is intended to be a hypervisor independant, fault tolerant
121     wrapper around the vagrant functions we use.
122     """
123
124     def __init__(self, srvdir):
125         """Create new server class.
126         """
127         self.srvdir = srvdir
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))
134         import vagrant
135         self.vgrnt = vagrant.Vagrant(root=srvdir, out_cm=vagrant.stdout_cm, err_cm=vagrant.stdout_cm)
136
137     def check_okay(self):
138         return True
139
140     def up(self, provision=True):
141         try:
142             self.vgrnt.up(provision=provision)
143         except subprocess.CalledProcessError as e:
144             logger.info('could not bring vm up: %s', e)
145
146     def snapshot_create(self, name):
147         raise NotImplementedError('not implemented, please use a sub-type instance')
148
149     def suspend(self):
150         self.vgrnt.suspend()
151
152     def resume(self):
153         self.vgrnt.resume()
154
155     def halt(self):
156         self.vgrnt.halt(force=True)
157
158     def destroy(self):
159         """Remove every trace of this VM from the system.
160
161         This includes deleting:
162         * hypervisor specific definitions
163         * vagrant state informations (eg. `.vagrant` folder)
164         * images related to this vm
165         """
166         try:
167             self.vgrnt.destroy()
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')
172         try:
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)
177         try:
178             _check_call(['vagrant', 'global-status', '--prune'])
179         except subprocess.CalledProcessError as e:
180             logger.debug('pruning global vagrant status failed: %s', e)
181
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)
188
189     def _vagrant_file_name(self, name):
190         return name.replace('/', '-VAGRANTSLASH-')
191
192     def box_add(self, boxname, boxfile, force=True):
193         """Add vagrant box to vagrant.
194
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)
198         """
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)
203
204     def box_remove(self, boxname):
205         try:
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))
211         if isdir(boxpath):
212             logger.info("attempting to remove box '%s' by deleting: %s",
213                         boxname, boxpath)
214             shutil.rmtree(boxpath)
215
216
217 class LibvirtBuildVm(FDroidBuildVm):
218     def __init__(self, srvdir):
219         super().__init__(srvdir)
220         import libvirt
221
222         try:
223             self.conn = libvirt.open('qemu:///system')
224         except libvirt.libvirtError as e:
225             raise FDroidBuildVmException('could not connect to libvirtd: %s' % (e))
226
227     def check_okay(self):
228         import libvirt
229         imagepath = joinpath('var', 'lib', 'libvirt', 'images',
230                              '%s.img' % self._vagrant_file_name(self.srvname))
231         image_present = False
232         if isfile(imagepath):
233             image_present = True
234         try:
235             self.conn.lookupByName(self.srvname)
236             domain_defined = True
237         except libvirt.libvirtError:
238             pass
239         if image_present and domain_defined:
240             return True
241         return False
242
243     def destroy(self):
244
245         super().destroy()
246
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)
250         try:
251             _check_call(('virsh', '-c', 'qemu:///system', 'destroy', self.srvname))
252             logger.info("...waiting a sec...")
253             time.sleep(10)
254         except subprocess.CalledProcessError as e:
255             logger.info("could not force libvirt domain '%s' off: %s", self.srvname, e)
256         try:
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...")
261             time.sleep(10)
262         except subprocess.CalledProcessError as e:
263             logger.info("could not undefine libvirt domain '%s': %s", self.srvname, e)
264
265     def package(self, output=None, vagrantfile=None, keep_box_file=False):
266         if not output:
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')
271         if storagePool:
272
273             if isfile('metadata.json'):
274                 rmfile('metadata.json')
275             if isfile('Vagrantfile'):
276                 rmfile('Vagrantfile')
277             if isfile('box.img'):
278                 rmfile('box.img')
279
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)),
292                         }
293
294             if not vagrantfile:
295                 logger.debug('no Vagrantfile supplied for box, generating a minimal one...')
296                 vagrantfile = 'Vagrant.configure("2") do |config|\nend'
297
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)
310                 tar.add('box.img')
311
312             if not keep_box_file:
313                 logger.debug('box packaging complete, removing temporary files.')
314                 rmfile('metadata.json')
315                 rmfile('Vagrantfile')
316                 rmfile('box.img')
317
318         else:
319             logger.warn('could not connect to storage-pool \'default\',' +
320                         'skipping packaging buildserver box')
321
322     def box_remove(self, boxname):
323         super().box_remove(boxname)
324         try:
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)
328
329     def snapshot_create(self, snapshot_name):
330         try:
331             _check_call(['virsh', '-c', 'qemu:///system', 'snapshot-create-as', self.srvname, snapshot_name])
332             logger.info('...waiting a sec...')
333             time.sleep(10)
334         except subprocess.CalledProcessError as e:
335             raise FDroidBuildVmException("could not cerate snapshot '%s' "
336                                          "of libvirt vm '%s'"
337                                          % (snapshot_name, self.srvname)) from e
338
339     def snapshot_list(self):
340         import libvirt
341         try:
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
346
347     def snapshot_exists(self, snapshot_name):
348         import libvirt
349         try:
350             dom = self.conn.lookupByName(self.srvname)
351             return dom.snapshotLookupByName(snapshot_name) is not None
352         except libvirt.libvirtError:
353             return False
354
355     def snapshot_revert(self, snapshot_name):
356         import libvirt
357         try:
358             dom = self.conn.lookupByName(self.srvname)
359             snap = dom.snapshotLookupByName(snapshot_name)
360             dom.revertToSnapshot(snap)
361             logger.info('...waiting a sec...')
362             time.sleep(10)
363         except libvirt.libvirtError as e:
364             raise FDroidBuildVmException('could not revert domain \'%s\' to snapshot \'%s\''
365                                          % (self.srvname, snapshot_name)) from e
366
367
368 class VirtualboxBuildVm(FDroidBuildVm):
369     def snapshot_create(self, snapshot_name):
370         raise NotImplemented('TODO')
371         try:
372             _check_call(['VBoxManage', 'snapshot', self.srvname, 'take', 'fdroidclean'], cwd=self.srvdir)
373             logger.info('...waiting a sec...')
374             time.sleep(10)
375         except subprocess.CalledProcessError as e:
376             raise FDroidBuildVmException('could not cerate snapshot '
377                                          'of virtualbox vm %s'
378                                          % self.srvname) from e
379
380     def snapshot_available(self, snapshot_name):
381         raise NotImplemented('TODO')