3 # vmtools.py - part of the FDroid server tools
4 # Copyright (C) 2017 Michael Poehn <michael.poehn@fsfe.org>
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.
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.
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/>.
19 from os import remove as rmfile
20 from os.path import isdir, isfile, join as joinpath, basename, abspath
28 from .common import FDroidException
29 from logging import getLogger
31 logger = getLogger('fdroidserver-vmtools')
34 def get_build_vm(srvdir, provider=None):
35 """Factory function for getting FDroidBuildVm instances.
37 This function tries to figure out what hypervisor should be used
38 and creates an object for controlling a build VM.
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.
45 abssrvdir = abspath(srvdir)
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)
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)
69 logger.info('build vm provider lookup could not determine provider, defaulting to \'virtualbox\'')
70 return VirtualboxBuildVm(abssrvdir)
73 class FDroidBuildVmException(FDroidException):
77 class FDroidBuildVm():
78 """Abstract base class for working with FDroids build-servers.
80 Use the factory method `fdroidserver.vmtools.get_build_vm()` for
81 getting correct instances of this class.
83 This is intended to be a hypervisor independant, fault tolerant
84 wrapper around the vagrant functions we use.
87 def __init__(self, srvdir):
88 """Create new server class.
91 self.srvname = basename(srvdir) + '_default'
92 self.vgrntfile = joinpath(srvdir, 'Vagrantfile')
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)
99 def isUpAndRunning(self):
100 raise NotImplementedError('TODO implement this')
102 def up(self, provision=True):
104 self.vgrnt.up(provision=provision)
105 except subprocess.CalledProcessError as e:
106 logger.info('could not bring vm up: %s', e)
109 self.vgrnt.halt(force=True)
112 """Remove every trace of this VM from the system.
114 This includes deleting:
115 * hypervisor specific definitions
116 * vagrant state informations (eg. `.vagrant` folder)
117 * images related to this vm
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')
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)
131 self._check_call(['vagrant', 'global-status', '--prune'])
132 except subprocess.CalledProcessError as e:
133 logger.debug('pruning global vagrant status failed: %s', e)
135 def package(self, output=None, keep_box_file=None):
136 self.vgrnt.package(output=output)
138 def box_add(self, boxname, boxfile, force=True):
139 """Add vagrant box to vagrant.
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)
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)
150 def box_remove(self, boxname):
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.
158 def _check_call(self, cmd, shell=False):
159 logger.debug(' '.join(cmd))
160 return subprocess.check_call(cmd, shell=shell)
162 def _check_output(self, cmd, shell=False):
163 logger.debug(' '.join(cmd))
164 return subprocess.check_output(cmd, shell=shell)
167 class LibvirtBuildVm(FDroidBuildVm):
168 def __init__(self, srvdir):
169 super().__init__(srvdir)
173 self.conn = libvirt.open('qemu:///system')
174 except libvirt.libvirtError as e:
175 raise FDroidBuildVmException('could not connect to libvirtd: %s' % (e))
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)
185 self._check_call(('virsh', '-c', 'qemu:///system', 'destroy', self.srvname))
186 logger.info("...waiting a sec...")
188 except subprocess.CalledProcessError as e:
189 logger.info("could not force libvirt domain '%s' off: %s", self.srvname, e)
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...")
196 except subprocess.CalledProcessError as e:
197 logger.info("could not undefine libvirt domain '%s': %s", self.srvname, e)
199 def package(self, output=None, vagrantfile=None, keep_box_file=False):
201 output = "buildserver.box"
202 logger.debug('no output name set for packaging \'%s\',' +
203 'defaulting to %s', self.srvname, output)
205 virConnect = libvirt.open('qemu:///system')
206 storagePool = virConnect.storagePoolLookupByName('default')
209 if isfile('metadata.json'):
210 rmfile('metadata.json')
211 if isfile('Vagrantfile'):
212 rmfile('Vagrantfile')
213 if isfile('box.img'):
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,
231 logger.debug('no Vagrantfile supplied for box, generating a minimal one...')
232 vagrantfile = 'Vagrant.configure("2") do |config|\nend'
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)
248 if not keep_box_file:
249 logger.debug('box packaging complete, removing temporary files.')
250 rmfile('metadata.json')
251 rmfile('Vagrantfile')
255 logger.warn('could not connect to storage-pool \'default\',' +
256 'skipping packaging buildserver box')
258 def box_remove(self, boxname):
259 super().box_remove(boxname)
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)
266 class VirtualboxBuildVm(FDroidBuildVm):