chiark / gitweb /
Merge branch 'python-vagrant-copy-caches' into 'master'
[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 time
26 import shutil
27 import subprocess
28 import textwrap
29 from .common import FDroidException
30 from logging import getLogger
31
32 logger = getLogger('fdroidserver-vmtools')
33
34
35 def get_clean_builder(serverdir, reset=False):
36     if not os.path.isdir(serverdir):
37         if os.path.islink(serverdir):
38             os.unlink(serverdir)
39         logger.info("buildserver path does not exists, creating %s", serverdir)
40         os.makedirs(serverdir)
41     vagrantfile = os.path.join(serverdir, 'Vagrantfile')
42     if not os.path.isfile(vagrantfile):
43         with open(os.path.join('builder', 'Vagrantfile'), 'w') as f:
44             f.write(textwrap.dedent("""\
45                 # generated file, do not change.
46
47                 Vagrant.configure("2") do |config|
48                     config.vm.box = "buildserver"
49                     config.vm.synced_folder ".", "/vagrant", disabled: true
50                 end
51                 """))
52     vm = get_build_vm(serverdir)
53     if reset:
54         logger.info('resetting buildserver by request')
55     elif not vm.vagrant_uuid_okay():
56         logger.info('resetting buildserver, bceause vagrant vm is not okay.')
57         reset = True
58     elif not vm.snapshot_exists('fdroidclean'):
59         logger.info("resetting buildserver, because snapshot 'fdroidclean' is not present.")
60         reset = True
61
62     if reset:
63         vm.destroy()
64     vm.up()
65     vm.suspend()
66
67     if reset:
68         logger.info('buildserver recreated: taking a clean snapshot')
69         vm.snapshot_create('fdroidclean')
70     else:
71         logger.info('builserver ok: reverting to clean snapshot')
72         vm.snapshot_revert('fdroidclean')
73     vm.up()
74
75     try:
76         sshinfo = vm.sshinfo()
77     except FDroidBuildVmException:
78         # workaround because libvirt sometimes likes to forget
79         # about ssh connection info even thou the vm is running
80         vm.halt()
81         vm.up()
82         sshinfo = vm.sshinfo()
83
84     return sshinfo
85
86
87 def _check_call(cmd, shell=False, cwd=None):
88     logger.debug(' '.join(cmd))
89     return subprocess.check_call(cmd, shell=shell, cwd=cwd)
90
91
92 def _check_output(cmd, shell=False, cwd=None):
93     logger.debug(' '.join(cmd))
94     return subprocess.check_output(cmd, shell=shell, cwd=cwd)
95
96
97 def get_build_vm(srvdir, provider=None):
98     """Factory function for getting FDroidBuildVm instances.
99
100     This function tries to figure out what hypervisor should be used
101     and creates an object for controlling a build VM.
102
103     :param srvdir: path to a directory which contains a Vagrantfile
104     :param provider: optionally this parameter allows specifiying an
105         spesific vagrant provider.
106     :returns: FDroidBuildVm instance.
107     """
108     abssrvdir = abspath(srvdir)
109
110     # use supplied provider
111     if provider:
112         if provider == 'libvirt':
113             logger.debug('build vm provider \'libvirt\' selected')
114             return LibvirtBuildVm(abssrvdir)
115         elif provider == 'virtualbox':
116             logger.debug('build vm provider \'virtualbox\' selected')
117             return VirtualboxBuildVm(abssrvdir)
118         else:
119             logger.warn('build vm provider not supported: \'%s\'', provider)
120
121     # try guessing provider from installed software
122     try:
123         kvm_installed = 0 == _check_call(['which', 'kvm'])
124     except subprocess.CalledProcessError:
125         kvm_installed = False
126         try:
127             kvm_installed |= 0 == _check_call(['which', 'qemu'])
128         except subprocess.CalledProcessError:
129             pass
130     try:
131         vbox_installed = 0 == _check_call(['which', 'VBoxHeadless'])
132     except subprocess.CalledProcessError:
133         vbox_installed = False
134     if kvm_installed and vbox_installed:
135         logger.debug('both kvm and vbox are installed.')
136     elif kvm_installed:
137         logger.debug('libvirt is the sole installed and supported vagrant provider, selecting \'libvirt\'')
138         return LibvirtBuildVm(abssrvdir)
139     elif vbox_installed:
140         logger.debug('virtualbox is the sole installed and supported vagrant provider, selecting \'virtualbox\'')
141         return VirtualboxBuildVm(abssrvdir)
142     else:
143         logger.debug('could not confirm that either virtualbox or kvm/libvirt are installed')
144
145     # try guessing provider from .../srvdir/.vagrant internals
146     has_libvirt_machine = isdir(joinpath(abssrvdir, '.vagrant',
147                                          'machines', 'default', 'libvirt'))
148     has_vbox_machine = isdir(joinpath(abssrvdir, '.vagrant',
149                                       'machines', 'default', 'libvirt'))
150     if has_libvirt_machine and has_vbox_machine:
151         logger.info('build vm provider lookup found virtualbox and libvirt, defaulting to \'virtualbox\'')
152         return VirtualboxBuildVm(abssrvdir)
153     elif has_libvirt_machine:
154         logger.debug('build vm provider lookup found \'libvirt\'')
155         return LibvirtBuildVm(abssrvdir)
156     elif has_vbox_machine:
157         logger.debug('build vm provider lookup found \'virtualbox\'')
158         return VirtualboxBuildVm(abssrvdir)
159
160     logger.info('build vm provider lookup could not determine provider, defaulting to \'virtualbox\'')
161     return VirtualboxBuildVm(abssrvdir)
162
163
164 class FDroidBuildVmException(FDroidException):
165     pass
166
167
168 class FDroidBuildVm():
169     """Abstract base class for working with FDroids build-servers.
170
171     Use the factory method `fdroidserver.vmtools.get_build_vm()` for
172     getting correct instances of this class.
173
174     This is intended to be a hypervisor independant, fault tolerant
175     wrapper around the vagrant functions we use.
176     """
177
178     def __init__(self, srvdir):
179         """Create new server class.
180         """
181         self.srvdir = srvdir
182         self.srvname = basename(srvdir) + '_default'
183         self.vgrntfile = joinpath(srvdir, 'Vagrantfile')
184         self.srvuuid = self._vagrant_fetch_uuid()
185         if not isdir(srvdir):
186             raise FDroidBuildVmException("Can not init vagrant, directory %s not present" % (srvdir))
187         if not isfile(self.vgrntfile):
188             raise FDroidBuildVmException("Can not init vagrant, '%s' not present" % (self.vgrntfile))
189         import vagrant
190         self.vgrnt = vagrant.Vagrant(root=srvdir, out_cm=vagrant.stdout_cm, err_cm=vagrant.stdout_cm)
191
192     def up(self, provision=True):
193         try:
194             self.vgrnt.up(provision=provision)
195             logger.info('...waiting a sec...')
196             time.sleep(10)
197             self.srvuuid = self._vagrant_fetch_uuid()
198         except subprocess.CalledProcessError as e:
199             raise FDroidBuildVmException("could not bring up vm '%s'" % self.srvname) from e
200
201     def suspend(self):
202         logger.info('suspending buildserver')
203         try:
204             self.vgrnt.suspend()
205             logger.info('...waiting a sec...')
206             time.sleep(10)
207         except subprocess.CalledProcessError as e:
208             raise FDroidBuildVmException("could not suspend vm '%s'" % self.srvname) from e
209
210     def halt(self):
211         self.vgrnt.halt(force=True)
212
213     def destroy(self):
214         """Remove every trace of this VM from the system.
215
216         This includes deleting:
217         * hypervisor specific definitions
218         * vagrant state informations (eg. `.vagrant` folder)
219         * images related to this vm
220         """
221         logger.info("destroying vm '%s'", self.srvname)
222         try:
223             self.vgrnt.destroy()
224             logger.debug('vagrant destroy completed')
225         except subprocess.CalledProcessError as e:
226             logger.exception('vagrant destroy failed: %s', e)
227         vgrntdir = joinpath(self.srvdir, '.vagrant')
228         try:
229             shutil.rmtree(vgrntdir)
230             logger.debug('deleted vagrant dir: %s', vgrntdir)
231         except Exception as e:
232             logger.debug("could not delete vagrant dir: %s, %s", vgrntdir, e)
233         try:
234             _check_call(['vagrant', 'global-status', '--prune'])
235         except subprocess.CalledProcessError as e:
236             logger.debug('pruning global vagrant status failed: %s', e)
237
238     def package(self, output=None):
239         self.vgrnt.package(output=output)
240
241     def vagrant_uuid_okay(self):
242         '''Having an uuid means that vagrant up has run successfully.'''
243         if self.srvuuid is None:
244             return False
245         return True
246
247     def _vagrant_file_name(self, name):
248         return name.replace('/', '-VAGRANTSLASH-')
249
250     def _vagrant_fetch_uuid(self):
251         if isfile(joinpath(self.srvdir, '.vagrant')):
252             # Vagrant 1.0 - it's a json file...
253             with open(joinpath(self.srvdir, '.vagrant')) as f:
254                 id = json.load(f)['active']['default']
255                 logger.debug('vm uuid: %s', id)
256             return id
257         elif isfile(joinpath(self.srvdir, '.vagrant', 'machines',
258                              'default', self.provider, 'id')):
259             # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
260             with open(joinpath(self.srvdir, '.vagrant', 'machines',
261                                'default', self.provider, 'id')) as f:
262                 id = f.read()
263                 logger.debug('vm uuid: %s', id)
264             return id
265         else:
266             logger.debug('vm uuid is None')
267             return None
268
269     def box_add(self, boxname, boxfile, force=True):
270         """Add vagrant box to vagrant.
271
272         :param boxname: name assigned to local deployment of box
273         :param boxfile: path to box file
274         :param force: overwrite existing box image (default: True)
275         """
276         boxfile = abspath(boxfile)
277         if not isfile(boxfile):
278             raise FDroidBuildVmException('supplied boxfile \'%s\' does not exist', boxfile)
279         self.vgrnt.box_add(boxname, abspath(boxfile), force=force)
280
281     def box_remove(self, boxname):
282         try:
283             _check_call(['vagrant', 'box', 'remove', '--all', '--force', boxname])
284         except subprocess.CalledProcessError as e:
285             logger.debug('tried removing box %s, but is did not exist: %s', boxname, e)
286         boxpath = joinpath(expanduser('~'), '.vagrant',
287                            self._vagrant_file_name(boxname))
288         if isdir(boxpath):
289             logger.info("attempting to remove box '%s' by deleting: %s",
290                         boxname, boxpath)
291             shutil.rmtree(boxpath)
292
293     def sshinfo(self):
294         """Get ssh connection info for a vagrant VM
295
296         :returns: A dictionary containing 'hostname', 'port', 'user'
297             and 'idfile'
298         """
299         import paramiko
300         try:
301             _check_call(['vagrant ssh-config > sshconfig'],
302                         cwd=self.srvdir, shell=True)
303             vagranthost = 'default'  # Host in ssh config file
304             sshconfig = paramiko.SSHConfig()
305             with open(joinpath(self.srvdir, 'sshconfig'), 'r') as f:
306                 sshconfig.parse(f)
307             sshconfig = sshconfig.lookup(vagranthost)
308             idfile = sshconfig['identityfile']
309             if isinstance(idfile, list):
310                 idfile = idfile[0]
311             elif idfile.startswith('"') and idfile.endswith('"'):
312                 idfile = idfile[1:-1]
313             return {'hostname': sshconfig['hostname'],
314                     'port': int(sshconfig['port']),
315                     'user': sshconfig['user'],
316                     'idfile': idfile}
317         except subprocess.CalledProcessError as e:
318             raise FDroidBuildVmException("Error getting ssh config") from e
319
320     def snapshot_create(self, snapshot_name):
321         raise NotImplementedError('not implemented, please use a sub-type instance')
322
323     def snapshot_list(self):
324         raise NotImplementedError('not implemented, please use a sub-type instance')
325
326     def snapshot_exists(self, snapshot_name):
327         raise NotImplementedError('not implemented, please use a sub-type instance')
328
329     def snapshot_revert(self, snapshot_name):
330         raise NotImplementedError('not implemented, please use a sub-type instance')
331
332
333 class LibvirtBuildVm(FDroidBuildVm):
334     def __init__(self, srvdir):
335         self.provider = 'libvirt'
336         super().__init__(srvdir)
337         import libvirt
338
339         try:
340             self.conn = libvirt.open('qemu:///system')
341         except libvirt.libvirtError as e:
342             raise FDroidBuildVmException('could not connect to libvirtd: %s' % (e))
343
344     def destroy(self):
345
346         super().destroy()
347
348         # resorting to virsh instead of libvirt python bindings, because
349         # this is way more easy and therefore fault tolerant.
350         # (eg. lookupByName only works on running VMs)
351         try:
352             _check_call(('virsh', '-c', 'qemu:///system', 'destroy', self.srvname))
353             logger.info("...waiting a sec...")
354             time.sleep(10)
355         except subprocess.CalledProcessError as e:
356             logger.info("could not force libvirt domain '%s' off: %s", self.srvname, e)
357         try:
358             # libvirt python bindings do not support all flags required
359             # for undefining domains correctly.
360             _check_call(('virsh', '-c', 'qemu:///system', 'undefine', self.srvname, '--nvram', '--managed-save', '--remove-all-storage', '--snapshots-metadata'))
361             logger.info("...waiting a sec...")
362             time.sleep(10)
363         except subprocess.CalledProcessError as e:
364             logger.info("could not undefine libvirt domain '%s': %s", self.srvname, e)
365
366     def package(self, output=None, keep_box_file=False):
367         if not output:
368             output = "buildserver.box"
369             logger.debug('no output name set for packaging \'%s\',' +
370                          'defaulting to %s', self.srvname, output)
371         storagePool = self.conn.storagePoolLookupByName('default')
372         if storagePool:
373
374             if isfile('metadata.json'):
375                 rmfile('metadata.json')
376             if isfile('Vagrantfile'):
377                 rmfile('Vagrantfile')
378             if isfile('box.img'):
379                 rmfile('box.img')
380
381             logger.debug('preparing box.img for box %s', output)
382             vol = storagePool.storageVolLookupByName(self.srvname + '.img')
383             imagepath = vol.path()
384             # TODO use a libvirt storage pool to ensure the img file is readable
385             _check_call(['sudo', '/bin/chmod', '-R', 'a+rX', '/var/lib/libvirt/images'])
386             shutil.copy2(imagepath, 'box.img')
387             _check_call(['qemu-img', 'rebase', '-p', '-b', '', 'box.img'])
388             img_info_raw = _check_output(['qemu-img', 'info', '--output=json', 'box.img'])
389             img_info = json.loads(img_info_raw.decode('utf-8'))
390             metadata = {"provider": "libvirt",
391                         "format": img_info['format'],
392                         "virtual_size": math.ceil(img_info['virtual-size'] / (1024. ** 3)),
393                         }
394
395             logger.debug('preparing metadata.json for box %s', output)
396             with open('metadata.json', 'w') as fp:
397                 fp.write(json.dumps(metadata))
398             logger.debug('preparing Vagrantfile for box %s', output)
399             vagrantfile = textwrap.dedent("""\
400                   Vagrant.configure("2") do |config|
401                     config.ssh.username = "vagrant"
402                     config.ssh.password = "vagrant"
403
404                     config.vm.provider :libvirt do |libvirt|
405
406                       libvirt.driver = "kvm"
407                       libvirt.host = ""
408                       libvirt.connect_via_ssh = false
409                       libvirt.storage_pool_name = "default"
410
411                     end
412                   end""")
413             with open('Vagrantfile', 'w') as fp:
414                 fp.write(vagrantfile)
415             with tarfile.open(output, 'w:gz') as tar:
416                 logger.debug('adding metadata.json to box %s ...', output)
417                 tar.add('metadata.json')
418                 logger.debug('adding Vagrantfile to box %s ...', output)
419                 tar.add('Vagrantfile')
420                 logger.debug('adding box.img to box %s ...', output)
421                 tar.add('box.img')
422
423             if not keep_box_file:
424                 logger.debug('box packaging complete, removing temporary files.')
425                 rmfile('metadata.json')
426                 rmfile('Vagrantfile')
427                 rmfile('box.img')
428
429         else:
430             logger.warn('could not connect to storage-pool \'default\',' +
431                         'skipping packaging buildserver box')
432
433     def box_add(self, boxname, boxfile, force=True):
434         boximg = '%s_vagrant_box_image_0.img' % (boxname)
435         if force:
436             try:
437                 _check_call(['virsh', '-c', 'qemu:///system', 'vol-delete', '--pool', 'default', boximg])
438                 logger.debug("removed old box image '%s' from libvirt storeage pool", boximg)
439             except subprocess.CalledProcessError as e:
440                 logger.debug("tired removing old box image '%s', file was not present in first place", boximg, exc_info=e)
441         super().box_add(boxname, boxfile, force)
442
443     def box_remove(self, boxname):
444         super().box_remove(boxname)
445         try:
446             _check_call(['virsh', '-c', 'qemu:///system', 'vol-delete', '--pool', 'default', '%s_vagrant_box_image_0.img' % (boxname)])
447         except subprocess.CalledProcessError as e:
448             logger.debug("tired removing '%s', file was not present in first place", boxname, exc_info=e)
449
450     def snapshot_create(self, snapshot_name):
451         logger.info("creating snapshot '%s' for vm '%s'", snapshot_name, self.srvname)
452         try:
453             _check_call(['virsh', '-c', 'qemu:///system', 'snapshot-create-as', self.srvname, snapshot_name])
454             logger.info('...waiting a sec...')
455             time.sleep(10)
456         except subprocess.CalledProcessError as e:
457             raise FDroidBuildVmException("could not cerate snapshot '%s' "
458                                          "of libvirt vm '%s'"
459                                          % (snapshot_name, self.srvname)) from e
460
461     def snapshot_list(self):
462         import libvirt
463         try:
464             dom = self.conn.lookupByName(self.srvname)
465             return dom.listAllSnapshots()
466         except libvirt.libvirtError as e:
467             raise FDroidBuildVmException('could not list snapshots for domain \'%s\'' % self.srvname) from e
468
469     def snapshot_exists(self, snapshot_name):
470         import libvirt
471         try:
472             dom = self.conn.lookupByName(self.srvname)
473             return dom.snapshotLookupByName(snapshot_name) is not None
474         except libvirt.libvirtError:
475             return False
476
477     def snapshot_revert(self, snapshot_name):
478         logger.info("reverting vm '%s' to snapshot '%s'", self.srvname, snapshot_name)
479         import libvirt
480         try:
481             dom = self.conn.lookupByName(self.srvname)
482             snap = dom.snapshotLookupByName(snapshot_name)
483             dom.revertToSnapshot(snap)
484             logger.info('...waiting a sec...')
485             time.sleep(10)
486         except libvirt.libvirtError as e:
487             raise FDroidBuildVmException('could not revert domain \'%s\' to snapshot \'%s\''
488                                          % (self.srvname, snapshot_name)) from e
489
490
491 class VirtualboxBuildVm(FDroidBuildVm):
492
493     def __init__(self, srvdir):
494         self.provider = 'virtualbox'
495         super().__init__(srvdir)
496
497     def snapshot_create(self, snapshot_name):
498         logger.info("creating snapshot '%s' for vm '%s'", snapshot_name, self.srvname)
499         try:
500             _check_call(['VBoxManage', 'snapshot', self.srvuuid, 'take', 'fdroidclean'], cwd=self.srvdir)
501             logger.info('...waiting a sec...')
502             time.sleep(10)
503         except subprocess.CalledProcessError as e:
504             raise FDroidBuildVmException('could not cerate snapshot '
505                                          'of virtualbox vm %s'
506                                          % self.srvname) from e
507
508     def snapshot_list(self):
509         try:
510             o = _check_output(['VBoxManage', 'snapshot',
511                                self.srvuuid, 'list',
512                                '--details'], cwd=self.srvdir)
513             return o
514         except subprocess.CalledProcessError as e:
515             raise FDroidBuildVmException("could not list snapshots "
516                                          "of virtualbox vm '%s'"
517                                          % (self.srvname)) from e
518
519     def snapshot_exists(self, snapshot_name):
520         try:
521             return str(snapshot_name) in str(self.snapshot_list())
522         except FDroidBuildVmException:
523             return False
524
525     def snapshot_revert(self, snapshot_name):
526         logger.info("reverting vm '%s' to snapshot '%s'",
527                     self.srvname, snapshot_name)
528         try:
529             _check_call(['VBoxManage', 'snapshot', self.srvuuid,
530                          'restore', 'fdroidclean'], cwd=self.srvdir)
531         except subprocess.CalledProcessError as e:
532             raise FDroidBuildVmException("could not load snapshot "
533                                          "'fdroidclean' for vm '%s'"
534                                          % (self.srvname)) from e