chiark / gitweb /
auto-lookup vm provider based on available executables; more fault tolerant vagrant...
[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
21 import math
22 import json
23 import tarfile
24 import time
25 import shutil
26 import vagrant
27 import subprocess
28 from .common import FDroidException
29 from logging import getLogger
30
31 logger = getLogger('fdroidserver-vmtools')
32
33
34 def _check_call(cmd, shell=False):
35     logger.debug(' '.join(cmd))
36     return subprocess.check_call(cmd, shell=shell)
37
38
39 def _check_output(cmd, shell=False):
40     logger.debug(' '.join(cmd))
41     return subprocess.check_output(cmd, shell=shell)
42
43
44 def get_build_vm(srvdir, provider=None):
45     """Factory function for getting FDroidBuildVm instances.
46
47     This function tries to figure out what hypervisor should be used
48     and creates an object for controlling a build VM.
49
50     :param srvdir: path to a directory which contains a Vagrantfile
51     :param provider: optionally this parameter allows specifiying an
52         spesific vagrant provider.
53     :returns: FDroidBuildVm instance.
54     """
55     abssrvdir = abspath(srvdir)
56
57     # use supplied provider
58     if provider:
59         if provider == 'libvirt':
60             logger.debug('build vm provider \'libvirt\' selected')
61             return LibvirtBuildVm(abssrvdir)
62         elif provider == 'virtualbox':
63             logger.debug('build vm provider \'virtualbox\' selected')
64             return VirtualboxBuildVm(abssrvdir)
65         else:
66             logger.warn('build vm provider not supported: \'%s\'', provider)
67
68     # try guessing provider from installed software
69     try:
70         kvm_installed = 0 == _check_call(['which', 'kvm'])
71     except subprocess.CalledProcessError:
72         kvm_installed = False
73         try:
74             kvm_installed |= 0 == _check_call(['which', 'qemu'])
75         except subprocess.CalledProcessError:
76             pass
77     try:
78         vbox_installed = 0 == _check_call(['which', 'VBoxHeadless'], shell=True)
79     except subprocess.CalledProcessError:
80         vbox_installed = False
81     if kvm_installed and vbox_installed:
82         logger.debug('both kvm and vbox are installed.')
83     elif kvm_installed:
84         logger.debug('libvirt is the sole installed and supported vagrant provider, selecting \'libvirt\'')
85         return LibvirtBuildVm(abssrvdir)
86     elif vbox_installed:
87         logger.debug('virtualbox is the sole installed and supported vagrant provider, selecting \'virtualbox\'')
88         return VirtualboxBuildVm(abssrvdir)
89     else:
90         logger.debug('could not confirm that either virtualbox or kvm/libvirt are installed')
91
92     # try guessing provider from .../srvdir/.vagrant internals
93     has_libvirt_machine = isdir(joinpath(abssrvdir, '.vagrant',
94                                          'machines', 'default', 'libvirt'))
95     has_vbox_machine = isdir(joinpath(abssrvdir, '.vagrant',
96                                       'machines', 'default', 'libvirt'))
97     if has_libvirt_machine and has_vbox_machine:
98         logger.info('build vm provider lookup found virtualbox and libvirt, defaulting to \'virtualbox\'')
99         return VirtualboxBuildVm(abssrvdir)
100     elif has_libvirt_machine:
101         logger.debug('build vm provider lookup found \'libvirt\'')
102         return LibvirtBuildVm(abssrvdir)
103     elif has_vbox_machine:
104         logger.debug('build vm provider lookup found \'virtualbox\'')
105         return VirtualboxBuildVm(abssrvdir)
106
107     logger.info('build vm provider lookup could not determine provider, defaulting to \'virtualbox\'')
108     return VirtualboxBuildVm(abssrvdir)
109
110
111 class FDroidBuildVmException(FDroidException):
112     pass
113
114
115 class FDroidBuildVm():
116     """Abstract base class for working with FDroids build-servers.
117
118     Use the factory method `fdroidserver.vmtools.get_build_vm()` for
119     getting correct instances of this class.
120
121     This is intended to be a hypervisor independant, fault tolerant
122     wrapper around the vagrant functions we use.
123     """
124
125     def __init__(self, srvdir):
126         """Create new server class.
127         """
128         self.srvdir = srvdir
129         self.srvname = basename(srvdir) + '_default'
130         self.vgrntfile = joinpath(srvdir, 'Vagrantfile')
131         if not isdir(srvdir):
132             raise FDroidBuildVmException("Can not init vagrant, directory %s not present" % (srvdir))
133         if not isfile(self.vgrntfile):
134             raise FDroidBuildVmException("Can not init vagrant, '%s' not present" % (self.vgrntfile))
135         self.vgrnt = vagrant.Vagrant(root=srvdir, out_cm=vagrant.stdout_cm, err_cm=vagrant.stdout_cm)
136
137     def isUpAndRunning(self):
138         raise NotImplementedError('TODO implement this')
139
140     def up(self, provision=True):
141         try:
142             self.vgrnt.up(provision=provision)
143         except subprocess.CalledProcessError as e:
144             logger.info('could not bring vm up: %s', e)
145
146     def halt(self):
147         self.vgrnt.halt(force=True)
148
149     def destroy(self):
150         """Remove every trace of this VM from the system.
151
152         This includes deleting:
153         * hypervisor specific definitions
154         * vagrant state informations (eg. `.vagrant` folder)
155         * images related to this vm
156         """
157         try:
158             self.vgrnt.destroy()
159             logger.debug('vagrant destroy completed')
160         except subprocess.CalledProcessError as e:
161             logger.debug('vagrant destroy failed: %s', e)
162         vgrntdir = joinpath(self.srvdir, '.vagrant')
163         try:
164             shutil.rmtree(vgrntdir)
165             logger.debug('deleted vagrant dir: %s', vgrntdir)
166         except Exception as e:
167             logger.debug("could not delete vagrant dir: %s, %s", vgrntdir, e)
168         try:
169             _check_call(['vagrant', 'global-status', '--prune'])
170         except subprocess.CalledProcessError as e:
171             logger.debug('pruning global vagrant status failed: %s', e)
172
173     def package(self, output=None, vagrantfile=None, keep_box_file=None):
174         previous_tmp_dir = joinpath(self.srvdir, '_tmp_package')
175         if isdir(previous_tmp_dir):
176             logger.info('found previous vagrant package temp dir \'%s\', deleting it', previous_tmp_dir)
177             shutil.rmtree(previous_tmp_dir)
178         self.vgrnt.package(output=output, vagrantfile=vagrantfile)
179
180     def box_add(self, boxname, boxfile, force=True):
181         """Add vagrant box to vagrant.
182
183         :param boxname: name assigned to local deployment of box
184         :param boxfile: path to box file
185         :param force: overwrite existing box image (default: True)
186         """
187         boxfile = abspath(boxfile)
188         if not isfile(boxfile):
189             raise FDroidBuildVmException('supplied boxfile \'%s\' does not exist', boxfile)
190         self.vgrnt.box_add(boxname, abspath(boxfile), force=force)
191
192     def box_remove(self, boxname):
193         try:
194             _check_call(['vagrant', 'box', 'remove', '--all', '--force', boxname])
195         except subprocess.CalledProcessError as e:
196             logger.debug('tried removing box %s, but is did not exist: %s', boxname, e)
197             # TODO: remove box files manually
198             # nesessary when Vagrantfile in ~/.vagrant.d/... is broken.
199
200
201 class LibvirtBuildVm(FDroidBuildVm):
202     def __init__(self, srvdir):
203         super().__init__(srvdir)
204         import libvirt
205
206         try:
207             self.conn = libvirt.open('qemu:///system')
208         except libvirt.libvirtError as e:
209             raise FDroidBuildVmException('could not connect to libvirtd: %s' % (e))
210
211     def destroy(self):
212
213         super().destroy()
214
215         # resorting to virsh instead of libvirt python bindings, because
216         # this is way more easy and therefore fault tolerant.
217         # (eg. lookupByName only works on running VMs)
218         try:
219             _check_call(('virsh', '-c', 'qemu:///system', 'destroy', self.srvname))
220             logger.info("...waiting a sec...")
221             time.sleep(10)
222         except subprocess.CalledProcessError as e:
223             logger.info("could not force libvirt domain '%s' off: %s", self.srvname, e)
224         try:
225             # libvirt python bindings do not support all flags required
226             # for undefining domains correctly.
227             _check_call(('virsh', '-c', 'qemu:///system', 'undefine', self.srvname, '--nvram', '--managed-save', '--remove-all-storage', '--snapshots-metadata'))
228             logger.info("...waiting a sec...")
229             time.sleep(10)
230         except subprocess.CalledProcessError as e:
231             logger.info("could not undefine libvirt domain '%s': %s", self.srvname, e)
232
233     def package(self, output=None, vagrantfile=None, keep_box_file=False):
234         if not output:
235             output = "buildserver.box"
236             logger.debug('no output name set for packaging \'%s\',' +
237                          'defaulting to %s', self.srvname, output)
238         import libvirt
239         virConnect = libvirt.open('qemu:///system')
240         storagePool = virConnect.storagePoolLookupByName('default')
241         if storagePool:
242
243             if isfile('metadata.json'):
244                 rmfile('metadata.json')
245             if isfile('Vagrantfile'):
246                 rmfile('Vagrantfile')
247             if isfile('box.img'):
248                 rmfile('box.img')
249
250             logger.debug('preparing box.img for box %s', output)
251             vol = storagePool.storageVolLookupByName(self.srvname + '.img')
252             imagepath = vol.path()
253             # TODO use a libvirt storage pool to ensure the img file is readable
254             _check_call(['sudo', '/bin/chmod', '-R', 'a+rX', '/var/lib/libvirt/images'])
255             shutil.copy2(imagepath, 'box.img')
256             _check_call(['qemu-img', 'rebase', '-p', '-b', '', 'box.img'])
257             img_info_raw = _check_output(['qemu-img', 'info', '--output=json', 'box.img'])
258             img_info = json.loads(img_info_raw.decode('utf-8'))
259             metadata = {"provider": "libvirt",
260                         "format": img_info['format'],
261                         "virtual_size": math.ceil(img_info['virtual-size'] / (1024. ** 3)) + 1,
262                         }
263
264             if not vagrantfile:
265                 logger.debug('no Vagrantfile supplied for box, generating a minimal one...')
266                 vagrantfile = 'Vagrant.configure("2") do |config|\nend'
267
268             logger.debug('preparing metadata.json for box %s', output)
269             with open('metadata.json', 'w') as fp:
270                 fp.write(json.dumps(metadata))
271             logger.debug('preparing Vagrantfile for box %s', output)
272             with open('Vagrantfile', 'w') as fp:
273                 fp.write(vagrantfile)
274             with tarfile.open(output, 'w:gz') as tar:
275                 logger.debug('adding metadata.json to box %s ...', output)
276                 tar.add('metadata.json')
277                 logger.debug('adding Vagrantfile to box %s ...', output)
278                 tar.add('Vagrantfile')
279                 logger.debug('adding box.img to box %s ...', output)
280                 tar.add('box.img')
281
282             if not keep_box_file:
283                 logger.debug('box packaging complete, removing temporary files.')
284                 rmfile('metadata.json')
285                 rmfile('Vagrantfile')
286                 rmfile('box.img')
287
288         else:
289             logger.warn('could not connect to storage-pool \'default\',' +
290                         'skipping packaging buildserver box')
291
292     def box_remove(self, boxname):
293         super().box_remove(boxname)
294         try:
295             _check_call(['virsh', '-c', 'qemu:///system', 'vol-delete', '--pool', 'default', '%s_vagrant_box_image_0.img' % (boxname)])
296         except subprocess.CalledProcessError as e:
297             logger.info('tired removing \'%s\', file was not present in first place: %s', boxname, e)
298
299
300 class VirtualboxBuildVm(FDroidBuildVm):
301     pass