chiark / gitweb /
vmtools: remove old vagrant workaround
[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 os
22 import math
23 import json
24 import tarfile
25 import shutil
26 import subprocess
27 import textwrap
28 from .common import FDroidException
29 from logging import getLogger
30
31 logger = getLogger('fdroidserver-vmtools')
32
33
34 def get_clean_builder(serverdir, reset=False):
35     if not os.path.isdir(serverdir):
36         if os.path.islink(serverdir):
37             os.unlink(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.
45
46                 Vagrant.configure("2") do |config|
47                     config.vm.box = "buildserver"
48                     config.vm.synced_folder ".", "/vagrant", disabled: true
49                 end
50                 """))
51     vm = get_build_vm(serverdir)
52     if reset:
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.')
56         reset = True
57     elif not vm.snapshot_exists('fdroidclean'):
58         logger.info("resetting buildserver, because snapshot 'fdroidclean' is not present.")
59         reset = True
60
61     if reset:
62         vm.destroy()
63     vm.up()
64     vm.suspend()
65
66     if reset:
67         logger.info('buildserver recreated: taking a clean snapshot')
68         vm.snapshot_create('fdroidclean')
69     else:
70         logger.info('builserver ok: reverting to clean snapshot')
71         vm.snapshot_revert('fdroidclean')
72     vm.up()
73
74     try:
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
79         vm.halt()
80         vm.up()
81         sshinfo = vm.sshinfo()
82
83     return sshinfo
84
85
86 def _check_call(cmd, shell=False, cwd=None):
87     logger.debug(' '.join(cmd))
88     return subprocess.check_call(cmd, shell=shell, cwd=cwd)
89
90
91 def _check_output(cmd, shell=False, cwd=None):
92     logger.debug(' '.join(cmd))
93     return subprocess.check_output(cmd, shell=shell, cwd=cwd)
94
95
96 def get_build_vm(srvdir, provider=None):
97     """Factory function for getting FDroidBuildVm instances.
98
99     This function tries to figure out what hypervisor should be used
100     and creates an object for controlling a build VM.
101
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.
106     """
107     abssrvdir = abspath(srvdir)
108
109     # use supplied provider
110     if 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)
117         else:
118             logger.warn('build vm provider not supported: \'%s\'', provider)
119
120     # try guessing provider from installed software
121     try:
122         kvm_installed = 0 == _check_call(['which', 'kvm'])
123     except subprocess.CalledProcessError:
124         kvm_installed = False
125         try:
126             kvm_installed |= 0 == _check_call(['which', 'qemu'])
127         except subprocess.CalledProcessError:
128             pass
129     try:
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.')
135     elif kvm_installed:
136         logger.debug('libvirt is the sole installed and supported vagrant provider, selecting \'libvirt\'')
137         return LibvirtBuildVm(abssrvdir)
138     elif vbox_installed:
139         logger.debug('virtualbox is the sole installed and supported vagrant provider, selecting \'virtualbox\'')
140         return VirtualboxBuildVm(abssrvdir)
141     else:
142         logger.debug('could not confirm that either virtualbox or kvm/libvirt are installed')
143
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)
158
159     logger.info('build vm provider lookup could not determine provider, defaulting to \'virtualbox\'')
160     return VirtualboxBuildVm(abssrvdir)
161
162
163 class FDroidBuildVmException(FDroidException):
164     pass
165
166
167 class FDroidBuildVm():
168     """Abstract base class for working with FDroids build-servers.
169
170     Use the factory method `fdroidserver.vmtools.get_build_vm()` for
171     getting correct instances of this class.
172
173     This is intended to be a hypervisor independant, fault tolerant
174     wrapper around the vagrant functions we use.
175     """
176
177     def __init__(self, srvdir):
178         """Create new server class.
179         """
180         self.srvdir = srvdir
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))
188         import vagrant
189         self.vgrnt = vagrant.Vagrant(root=srvdir, out_cm=vagrant.stdout_cm, err_cm=vagrant.stdout_cm)
190
191     def up(self, provision=True):
192         try:
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
197
198     def suspend(self):
199         logger.info('suspending buildserver')
200         try:
201             self.vgrnt.suspend()
202         except subprocess.CalledProcessError as e:
203             raise FDroidBuildVmException("could not suspend vm '%s'" % self.srvname) from e
204
205     def halt(self):
206         self.vgrnt.halt(force=True)
207
208     def destroy(self):
209         """Remove every trace of this VM from the system.
210
211         This includes deleting:
212         * hypervisor specific definitions
213         * vagrant state informations (eg. `.vagrant` folder)
214         * images related to this vm
215         """
216         logger.info("destroying vm '%s'", self.srvname)
217         try:
218             self.vgrnt.destroy()
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')
223         try:
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)
228         try:
229             _check_call(['vagrant', 'global-status', '--prune'])
230         except subprocess.CalledProcessError as e:
231             logger.debug('pruning global vagrant status failed: %s', e)
232
233     def package(self, output=None):
234         self.vgrnt.package(output=output)
235
236     def vagrant_uuid_okay(self):
237         '''Having an uuid means that vagrant up has run successfully.'''
238         if self.srvuuid is None:
239             return False
240         return True
241
242     def _vagrant_file_name(self, name):
243         return name.replace('/', '-VAGRANTSLASH-')
244
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)
251             return 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:
257                 id = f.read()
258                 logger.debug('vm uuid: %s', id)
259             return id
260         else:
261             logger.debug('vm uuid is None')
262             return None
263
264     def box_add(self, boxname, boxfile, force=True):
265         """Add vagrant box to vagrant.
266
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)
270         """
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)
275
276     def box_remove(self, boxname):
277         try:
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))
283         if isdir(boxpath):
284             logger.info("attempting to remove box '%s' by deleting: %s",
285                         boxname, boxpath)
286             shutil.rmtree(boxpath)
287
288     def sshinfo(self):
289         """Get ssh connection info for a vagrant VM
290
291         :returns: A dictionary containing 'hostname', 'port', 'user'
292             and 'idfile'
293         """
294         import paramiko
295         try:
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:
301                 sshconfig.parse(f)
302             sshconfig = sshconfig.lookup(vagranthost)
303             idfile = sshconfig['identityfile']
304             if isinstance(idfile, list):
305                 idfile = idfile[0]
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'],
311                     'idfile': idfile}
312         except subprocess.CalledProcessError as e:
313             raise FDroidBuildVmException("Error getting ssh config") from e
314
315     def snapshot_create(self, snapshot_name):
316         raise NotImplementedError('not implemented, please use a sub-type instance')
317
318     def snapshot_list(self):
319         raise NotImplementedError('not implemented, please use a sub-type instance')
320
321     def snapshot_exists(self, snapshot_name):
322         raise NotImplementedError('not implemented, please use a sub-type instance')
323
324     def snapshot_revert(self, snapshot_name):
325         raise NotImplementedError('not implemented, please use a sub-type instance')
326
327
328 class LibvirtBuildVm(FDroidBuildVm):
329     def __init__(self, srvdir):
330         self.provider = 'libvirt'
331         super().__init__(srvdir)
332         import libvirt
333
334         try:
335             self.conn = libvirt.open('qemu:///system')
336         except libvirt.libvirtError as e:
337             raise FDroidBuildVmException('could not connect to libvirtd: %s' % (e))
338
339     def destroy(self):
340
341         super().destroy()
342
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)
346         try:
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)
350         try:
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)
356
357     def package(self, output=None, keep_box_file=False):
358         if not output:
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()
364         if storagePool:
365
366             if isfile('metadata.json'):
367                 rmfile('metadata.json')
368             if isfile('Vagrantfile'):
369                 rmfile('Vagrantfile')
370             if isfile('box.img'):
371                 rmfile('box.img')
372
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)),
385                         }
386
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"
395
396                     config.vm.provider :libvirt do |libvirt|
397
398                       libvirt.driver = "kvm"
399                       libvirt.host = ""
400                       libvirt.connect_via_ssh = false
401                       libvirt.storage_pool_name = "default"
402                       libvirt.cpus = {cpus}
403                       libvirt.memory = {memory}
404
405                     end
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)
415                 tar.add('box.img')
416
417             if not keep_box_file:
418                 logger.debug('box packaging complete, removing temporary files.')
419                 rmfile('metadata.json')
420                 rmfile('Vagrantfile')
421                 rmfile('box.img')
422
423         else:
424             logger.warn('could not connect to storage-pool \'default\',' +
425                         'skipping packaging buildserver box')
426
427     def box_add(self, boxname, boxfile, force=True):
428         boximg = '%s_vagrant_box_image_0.img' % (boxname)
429         if force:
430             try:
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)
436
437     def box_remove(self, boxname):
438         super().box_remove(boxname)
439         try:
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)
443
444     def snapshot_create(self, snapshot_name):
445         logger.info("creating snapshot '%s' for vm '%s'", snapshot_name, self.srvname)
446         try:
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' "
450                                          "of libvirt vm '%s'"
451                                          % (snapshot_name, self.srvname)) from e
452
453     def snapshot_list(self):
454         import libvirt
455         try:
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
460
461     def snapshot_exists(self, snapshot_name):
462         import libvirt
463         try:
464             dom = self.conn.lookupByName(self.srvname)
465             return dom.snapshotLookupByName(snapshot_name) is not None
466         except libvirt.libvirtError:
467             return False
468
469     def snapshot_revert(self, snapshot_name):
470         logger.info("reverting vm '%s' to snapshot '%s'", self.srvname, snapshot_name)
471         import libvirt
472         try:
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
479
480
481 class VirtualboxBuildVm(FDroidBuildVm):
482
483     def __init__(self, srvdir):
484         self.provider = 'virtualbox'
485         super().__init__(srvdir)
486
487     def snapshot_create(self, snapshot_name):
488         logger.info("creating snapshot '%s' for vm '%s'", snapshot_name, self.srvname)
489         try:
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
495
496     def snapshot_list(self):
497         try:
498             o = _check_output(['VBoxManage', 'snapshot',
499                                self.srvuuid, 'list',
500                                '--details'], cwd=self.srvdir)
501             return o
502         except subprocess.CalledProcessError as e:
503             raise FDroidBuildVmException("could not list snapshots "
504                                          "of virtualbox vm '%s'"
505                                          % (self.srvname)) from e
506
507     def snapshot_exists(self, snapshot_name):
508         try:
509             return str(snapshot_name) in str(self.snapshot_list())
510         except FDroidBuildVmException:
511             return False
512
513     def snapshot_revert(self, snapshot_name):
514         logger.info("reverting vm '%s' to snapshot '%s'",
515                     self.srvname, snapshot_name)
516         try:
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