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