chiark / gitweb /
restart builder vm when ssh connection fails
[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, cwd=None):
34     logger.debug(' '.join(cmd))
35     return subprocess.check_call(cmd, shell=shell, cwd=cwd)
36
37
38 def _check_output(cmd, shell=False, cwd=None):
39     logger.debug(' '.join(cmd))
40     return subprocess.check_output(cmd, shell=shell, cwd=cwd)
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'])
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         self.srvuuid = self._vagrant_fetch_uuid()
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         import vagrant
136         self.vgrnt = vagrant.Vagrant(root=srvdir, out_cm=vagrant.stdout_cm, err_cm=vagrant.stdout_cm)
137
138     def up(self, provision=True):
139         try:
140             self.vgrnt.up(provision=provision)
141             logger.info('...waiting a sec...')
142             time.sleep(10)
143             self.srvuuid = self._vagrant_fetch_uuid()
144         except subprocess.CalledProcessError as e:
145             raise FDroidBuildVmException("could not bring up vm '%s'" % self.srvname) from e
146
147     def suspend(self):
148         logger.info('suspending buildserver')
149         try:
150             self.vgrnt.suspend()
151             logger.info('...waiting a sec...')
152             time.sleep(10)
153         except subprocess.CalledProcessError as e:
154             raise FDroidBuildVmException("could not suspend vm '%s'" % self.srvname) from e
155
156     def halt(self):
157         self.vgrnt.halt(force=True)
158
159     def destroy(self):
160         """Remove every trace of this VM from the system.
161
162         This includes deleting:
163         * hypervisor specific definitions
164         * vagrant state informations (eg. `.vagrant` folder)
165         * images related to this vm
166         """
167         logger.info("destroying vm '%s'", self.srvname)
168         try:
169             self.vgrnt.destroy()
170             logger.debug('vagrant destroy completed')
171         except subprocess.CalledProcessError as e:
172             logger.exception('vagrant destroy failed: %s', e)
173         vgrntdir = joinpath(self.srvdir, '.vagrant')
174         try:
175             shutil.rmtree(vgrntdir)
176             logger.debug('deleted vagrant dir: %s', vgrntdir)
177         except Exception as e:
178             logger.debug("could not delete vagrant dir: %s, %s", vgrntdir, e)
179         try:
180             _check_call(['vagrant', 'global-status', '--prune'])
181         except subprocess.CalledProcessError as e:
182             logger.debug('pruning global vagrant status failed: %s', e)
183
184     def package(self, output=None, vagrantfile=None, keep_box_file=None):
185         self.vgrnt.package(output=output, vagrantfile=vagrantfile)
186
187     def vagrant_uuid_okay(self):
188         '''Having an uuid means that vagrant up has run successfully.'''
189         if self.srvuuid is None:
190             return False
191         return True
192
193     def _vagrant_file_name(self, name):
194         return name.replace('/', '-VAGRANTSLASH-')
195
196     def _vagrant_fetch_uuid(self):
197         if isfile(joinpath(self.srvdir, '.vagrant')):
198             # Vagrant 1.0 - it's a json file...
199             with open(joinpath(self.srvdir, '.vagrant')) as f:
200                 id = json.load(f)['active']['default']
201                 logger.debug('vm uuid: %s', id)
202             return id
203         elif isfile(joinpath(self.srvdir, '.vagrant', 'machines',
204                              'default', self.provider, 'id')):
205             # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
206             with open(joinpath(self.srvdir, '.vagrant', 'machines',
207                                'default', self.provider, 'id')) as f:
208                 id = f.read()
209                 logger.debug('vm uuid: %s', id)
210             return id
211         else:
212             logger.debug('vm uuid is None')
213             return None
214
215     def box_add(self, boxname, boxfile, force=True):
216         """Add vagrant box to vagrant.
217
218         :param boxname: name assigned to local deployment of box
219         :param boxfile: path to box file
220         :param force: overwrite existing box image (default: True)
221         """
222         boxfile = abspath(boxfile)
223         if not isfile(boxfile):
224             raise FDroidBuildVmException('supplied boxfile \'%s\' does not exist', boxfile)
225         self.vgrnt.box_add(boxname, abspath(boxfile), force=force)
226
227     def box_remove(self, boxname):
228         try:
229             _check_call(['vagrant', 'box', 'remove', '--all', '--force', boxname])
230         except subprocess.CalledProcessError as e:
231             logger.debug('tried removing box %s, but is did not exist: %s', boxname, e)
232         boxpath = joinpath(expanduser('~'), '.vagrant',
233                            self._vagrant_file_name(boxname))
234         if isdir(boxpath):
235             logger.info("attempting to remove box '%s' by deleting: %s",
236                         boxname, boxpath)
237             shutil.rmtree(boxpath)
238
239     def sshinfo(self):
240         """Get ssh connection info for a vagrant VM
241
242         :returns: A dictionary containing 'hostname', 'port', 'user'
243             and 'idfile'
244         """
245         import paramiko
246         try:
247             _check_call(['vagrant ssh-config > sshconfig'],
248                         cwd=self.srvdir, shell=True)
249             vagranthost = 'default'  # Host in ssh config file
250             sshconfig = paramiko.SSHConfig()
251             with open(joinpath(self.srvdir, 'sshconfig'), 'r') as f:
252                 sshconfig.parse(f)
253             sshconfig = sshconfig.lookup(vagranthost)
254             idfile = sshconfig['identityfile']
255             if isinstance(idfile, list):
256                 idfile = idfile[0]
257             elif idfile.startswith('"') and idfile.endswith('"'):
258                 idfile = idfile[1:-1]
259             return {'hostname': sshconfig['hostname'],
260                     'port': int(sshconfig['port']),
261                     'user': sshconfig['user'],
262                     'idfile': idfile}
263         except subprocess.CalledProcessError as e:
264             raise FDroidBuildVmException("Error getting ssh config") from e
265
266     def snapshot_create(self, snapshot_name):
267         raise NotImplementedError('not implemented, please use a sub-type instance')
268
269     def snapshot_list(self):
270         raise NotImplementedError('not implemented, please use a sub-type instance')
271
272     def snapshot_exists(self, snapshot_name):
273         raise NotImplementedError('not implemented, please use a sub-type instance')
274
275     def snapshot_revert(self, snapshot_name):
276         raise NotImplementedError('not implemented, please use a sub-type instance')
277
278
279 class LibvirtBuildVm(FDroidBuildVm):
280     def __init__(self, srvdir):
281         self.provider = 'libvirt'
282         super().__init__(srvdir)
283         import libvirt
284
285         try:
286             self.conn = libvirt.open('qemu:///system')
287         except libvirt.libvirtError as e:
288             raise FDroidBuildVmException('could not connect to libvirtd: %s' % (e))
289
290     def destroy(self):
291
292         super().destroy()
293
294         # resorting to virsh instead of libvirt python bindings, because
295         # this is way more easy and therefore fault tolerant.
296         # (eg. lookupByName only works on running VMs)
297         try:
298             _check_call(('virsh', '-c', 'qemu:///system', 'destroy', self.srvname))
299             logger.info("...waiting a sec...")
300             time.sleep(10)
301         except subprocess.CalledProcessError as e:
302             logger.info("could not force libvirt domain '%s' off: %s", self.srvname, e)
303         try:
304             # libvirt python bindings do not support all flags required
305             # for undefining domains correctly.
306             _check_call(('virsh', '-c', 'qemu:///system', 'undefine', self.srvname, '--nvram', '--managed-save', '--remove-all-storage', '--snapshots-metadata'))
307             logger.info("...waiting a sec...")
308             time.sleep(10)
309         except subprocess.CalledProcessError as e:
310             logger.info("could not undefine libvirt domain '%s': %s", self.srvname, e)
311
312     def package(self, output=None, vagrantfile=None, keep_box_file=False):
313         if not output:
314             output = "buildserver.box"
315             logger.debug('no output name set for packaging \'%s\',' +
316                          'defaulting to %s', self.srvname, output)
317         storagePool = self.conn.storagePoolLookupByName('default')
318         if storagePool:
319
320             if isfile('metadata.json'):
321                 rmfile('metadata.json')
322             if isfile('Vagrantfile'):
323                 rmfile('Vagrantfile')
324             if isfile('box.img'):
325                 rmfile('box.img')
326
327             logger.debug('preparing box.img for box %s', output)
328             vol = storagePool.storageVolLookupByName(self.srvname + '.img')
329             imagepath = vol.path()
330             # TODO use a libvirt storage pool to ensure the img file is readable
331             _check_call(['sudo', '/bin/chmod', '-R', 'a+rX', '/var/lib/libvirt/images'])
332             shutil.copy2(imagepath, 'box.img')
333             _check_call(['qemu-img', 'rebase', '-p', '-b', '', 'box.img'])
334             img_info_raw = _check_output(['qemu-img', 'info', '--output=json', 'box.img'])
335             img_info = json.loads(img_info_raw.decode('utf-8'))
336             metadata = {"provider": "libvirt",
337                         "format": img_info['format'],
338                         "virtual_size": math.ceil(img_info['virtual-size'] / (1024. ** 3)),
339                         }
340
341             if not vagrantfile:
342                 logger.debug('no Vagrantfile supplied for box, generating a minimal one...')
343                 vagrantfile = 'Vagrant.configure("2") do |config|\nend'
344
345             logger.debug('preparing metadata.json for box %s', output)
346             with open('metadata.json', 'w') as fp:
347                 fp.write(json.dumps(metadata))
348             logger.debug('preparing Vagrantfile for box %s', output)
349             with open('Vagrantfile', 'w') as fp:
350                 fp.write(vagrantfile)
351             with tarfile.open(output, 'w:gz') as tar:
352                 logger.debug('adding metadata.json to box %s ...', output)
353                 tar.add('metadata.json')
354                 logger.debug('adding Vagrantfile to box %s ...', output)
355                 tar.add('Vagrantfile')
356                 logger.debug('adding box.img to box %s ...', output)
357                 tar.add('box.img')
358
359             if not keep_box_file:
360                 logger.debug('box packaging complete, removing temporary files.')
361                 rmfile('metadata.json')
362                 rmfile('Vagrantfile')
363                 rmfile('box.img')
364
365         else:
366             logger.warn('could not connect to storage-pool \'default\',' +
367                         'skipping packaging buildserver box')
368
369     def box_add(self, boxname, boxfile, force=True):
370         boximg = '%s_vagrant_box_image_0.img' % (boxname)
371         if force:
372             try:
373                 _check_call(['virsh', '-c', 'qemu:///system', 'vol-delete', '--pool', 'default', boximg])
374                 logger.debug("removed old box image '%s' from libvirt storeage pool", boximg)
375             except subprocess.CalledProcessError as e:
376                 logger.debug("tired removing old box image '%s', file was not present in first place", boximg, exc_info=e)
377         super().box_add(boxname, boxfile, force)
378
379     def box_remove(self, boxname):
380         super().box_remove(boxname)
381         try:
382             _check_call(['virsh', '-c', 'qemu:///system', 'vol-delete', '--pool', 'default', '%s_vagrant_box_image_0.img' % (boxname)])
383         except subprocess.CalledProcessError as e:
384             logger.debug("tired removing '%s', file was not present in first place", boxname, exc_info=e)
385
386     def snapshot_create(self, snapshot_name):
387         logger.info("creating snapshot '%s' for vm '%s'", snapshot_name, self.srvname)
388         try:
389             _check_call(['virsh', '-c', 'qemu:///system', 'snapshot-create-as', self.srvname, snapshot_name])
390             logger.info('...waiting a sec...')
391             time.sleep(10)
392         except subprocess.CalledProcessError as e:
393             raise FDroidBuildVmException("could not cerate snapshot '%s' "
394                                          "of libvirt vm '%s'"
395                                          % (snapshot_name, self.srvname)) from e
396
397     def snapshot_list(self):
398         import libvirt
399         try:
400             dom = self.conn.lookupByName(self.srvname)
401             return dom.listAllSnapshots()
402         except libvirt.libvirtError as e:
403             raise FDroidBuildVmException('could not list snapshots for domain \'%s\'' % self.srvname) from e
404
405     def snapshot_exists(self, snapshot_name):
406         import libvirt
407         try:
408             dom = self.conn.lookupByName(self.srvname)
409             return dom.snapshotLookupByName(snapshot_name) is not None
410         except libvirt.libvirtError:
411             return False
412
413     def snapshot_revert(self, snapshot_name):
414         logger.info("reverting vm '%s' to snapshot '%s'", self.srvname, snapshot_name)
415         import libvirt
416         try:
417             dom = self.conn.lookupByName(self.srvname)
418             snap = dom.snapshotLookupByName(snapshot_name)
419             dom.revertToSnapshot(snap)
420             logger.info('...waiting a sec...')
421             time.sleep(10)
422         except libvirt.libvirtError as e:
423             raise FDroidBuildVmException('could not revert domain \'%s\' to snapshot \'%s\''
424                                          % (self.srvname, snapshot_name)) from e
425
426
427 class VirtualboxBuildVm(FDroidBuildVm):
428
429     def __init__(self, srvdir):
430         self.provider = 'virtualbox'
431         super().__init__(srvdir)
432
433     def snapshot_create(self, snapshot_name):
434         logger.info("creating snapshot '%s' for vm '%s'", snapshot_name, self.srvname)
435         try:
436             _check_call(['VBoxManage', 'snapshot', self.srvuuid, 'take', 'fdroidclean'], cwd=self.srvdir)
437             logger.info('...waiting a sec...')
438             time.sleep(10)
439         except subprocess.CalledProcessError as e:
440             raise FDroidBuildVmException('could not cerate snapshot '
441                                          'of virtualbox vm %s'
442                                          % self.srvname) from e
443
444     def snapshot_list(self):
445         try:
446             o = _check_output(['VBoxManage', 'snapshot',
447                                self.srvuuid, 'list',
448                                '--details'], cwd=self.srvdir)
449             return o
450         except subprocess.CalledProcessError as e:
451             raise FDroidBuildVmException("could not list snapshots "
452                                          "of virtualbox vm '%s'"
453                                          % (self.srvname)) from e
454
455     def snapshot_exists(self, snapshot_name):
456         try:
457             return str(snapshot_name) in str(self.snapshot_list())
458         except FDroidBuildVmException:
459             return False
460
461     def snapshot_revert(self, snapshot_name):
462         logger.info("reverting vm '%s' to snapshot '%s'",
463                     self.srvname, snapshot_name)
464         try:
465             _check_call(['VBoxManage', 'snapshot', self.srvuuid,
466                          'restore', 'fdroidclean'], cwd=self.srvdir)
467         except subprocess.CalledProcessError as e:
468             raise FDroidBuildVmException("could not load snapshot "
469                                          "'fdroidclean' for vm '%s'"
470                                          % (self.srvname)) from e