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