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