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