Merge lp:~mwhudson/utah/live-server into lp:utah
- live-server
- Merge into dev
Status: | Merged |
---|---|
Approved by: | Joshua Powers |
Approved revision: | no longer in the source branch. |
Merged at revision: | 1111 |
Proposed branch: | lp:~mwhudson/utah/live-server |
Merge into: | lp:utah |
Diff against target: |
458 lines (+420/-1) 4 files modified
examples/run_utah_tests.py (+3/-0) utah/config.py (+5/-0) utah/parser.py (+1/-1) utah/provisioning/live_server.py (+411/-0) |
To merge this branch: | bzr merge lp:~mwhudson/utah/live-server |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Canonical CI Engineering | Pending | ||
Review via email: mp+335374@code.launchpad.net |
Commit message
Description of the change
I tested this branch with something approximating:
$ echo '{"group": "mwhudson", "launchpad_user": "mwhudson"}' > ~/.utah/config
$ wget http://
$ git clone https:/
$ bzr branch lp:ubuntu-test-cases/server ~/src/ubuntu-
$ PYTHONPATH=. ./examples/
Please let me know what you think. It's a bit rough but it should at least not risk breaking anything else.
- 1111. By Joshua Powers
-
Enable live-server ISO testing
This allows for the testing of live-server ISOs (aka subiquity)
based ISOs.
Preview Diff
1 | === modified file 'examples/run_utah_tests.py' |
2 | --- examples/run_utah_tests.py 2017-07-06 18:03:17 +0000 |
3 | +++ examples/run_utah_tests.py 2017-12-20 00:09:54 +0000 |
4 | @@ -72,6 +72,9 @@ |
5 | 'utah-baremetal package needed for physical machines') |
6 | return get_baremetal(args.arch, **kw) |
7 | else: |
8 | + if args.machinetype == 'virtual-live-server': |
9 | + from utah.provisioning.live_server import LiveServerVM |
10 | + kw['machinetype'] = LiveServerVM |
11 | if 'vm' in MISSING: |
12 | raise UTAHException('Virtual Machine support is not available') |
13 | # TODO: consider removing these as explicit command line arguments |
14 | |
15 | === modified file 'utah/config.py' |
16 | --- utah/config.py 2017-08-30 21:58:10 +0000 |
17 | +++ utah/config.py 2017-12-20 00:09:54 +0000 |
18 | @@ -214,6 +214,11 @@ |
19 | 'format': 'path', |
20 | 'default': None, |
21 | }, |
22 | + 'launchpad_user': { |
23 | + 'description': 'Launchpad whose keys should be imported on a live-server install', |
24 | + 'type': 'string', |
25 | + 'default': 'server-team-bot', |
26 | + }, |
27 | 'logfile': { |
28 | 'description': 'Path to log file', |
29 | 'type': 'string', |
30 | |
31 | === modified file 'utah/parser.py' |
32 | --- utah/parser.py 2017-07-06 18:03:17 +0000 |
33 | +++ utah/parser.py 2017-12-20 00:09:54 +0000 |
34 | @@ -80,7 +80,7 @@ |
35 | "\t\t/usr/share/utah/client/examples/master.run"), |
36 | formatter_class=argparse.RawDescriptionHelpFormatter) |
37 | parser.add_argument('-m', '--machinetype', metavar='MACHINETYPE', |
38 | - choices=('physical', 'virtual'), |
39 | + choices=('physical', 'virtual', 'virtual-live-server'), |
40 | default=config.machinetype, |
41 | help='Type of machine to provision (%(choices)s) ' |
42 | '(Default is %(default)s)') |
43 | |
44 | === added file 'utah/provisioning/live_server.py' |
45 | --- utah/provisioning/live_server.py 1970-01-01 00:00:00 +0000 |
46 | +++ utah/provisioning/live_server.py 2017-12-20 00:09:54 +0000 |
47 | @@ -0,0 +1,411 @@ |
48 | + |
49 | +# Support for testing "live-server" (aka subiquity) images. Missing |
50 | +# functionality: extracting syslog, installer, curtin logs from the |
51 | +# image file. Not sure how to hook that in. |
52 | + |
53 | +import os |
54 | +import re |
55 | +import select |
56 | +import string |
57 | +import shutil |
58 | +import tempfile |
59 | +import time |
60 | + |
61 | +import yaml |
62 | + |
63 | +import libvirt |
64 | + |
65 | +from xml.etree import ElementTree |
66 | + |
67 | +from utah.config import config |
68 | + |
69 | +from utah.process import ProcessRunner |
70 | + |
71 | +from utah.timeout import UTAHTimeout |
72 | + |
73 | +from utah.provisioning.ssh import SSHMixin |
74 | +from utah.provisioning.vm import ( |
75 | + LibvirtVM, |
76 | + random_mac_address, |
77 | + UTAHVMProvisioningException, |
78 | + ) |
79 | + |
80 | + |
81 | +class LiveServerVM(SSHMixin, LibvirtVM): |
82 | + def __init__(self, machineid, diskbus, disksizes, emulator, name, **kw): |
83 | + self.diskbus = diskbus |
84 | + self.disksizes = disksizes |
85 | + self.emulator = emulator |
86 | + self.macs = config.macs |
87 | + self.prefix = config.prefix |
88 | + self.disks = [] |
89 | + if name is None: |
90 | + name = '-'.join([str(config.prefix), str(machineid)]) |
91 | + autoname = True |
92 | + super(LiveServerVM, self).__init__( |
93 | + machineid=machineid, name=name, **kw) |
94 | + if autoname: |
95 | + self._namesetup() |
96 | + self._loggerunsetup() |
97 | + self._loggersetup() |
98 | + if self.inventory is not None: |
99 | + self.cleanfunction(self.inventory.destroy, |
100 | + machineid=self.machineid) |
101 | + self.directory = os.path.join(config.vmpath, self.name) |
102 | + self.cleanfile(self.directory) |
103 | + self.logger.info('Checking if machine directory {} exists' |
104 | + .format(self.directory)) |
105 | + if not os.path.isdir(self.directory): |
106 | + self.logger.debug('Creating {}'.format(self.directory)) |
107 | + try: |
108 | + os.makedirs(self.directory) |
109 | + except OSError as err: |
110 | + raise UTAHVMProvisioningException(err) |
111 | + self.logger.debug('LiveServerVM init finished') |
112 | + |
113 | + def _tmpimage(self, image=None, tmpdir=None): |
114 | + """Create a temporary copy of the image so libvirt will lock that one. |
115 | + |
116 | + This allows other simultaneous processes to update the cached image. |
117 | + |
118 | + :returns: The path to the temporary image |
119 | + :rtype: str |
120 | + |
121 | + """ |
122 | + if image is None: |
123 | + image = self.image.image |
124 | + if tmpdir is None: |
125 | + tmpdir = self.tmpdir |
126 | + self.logger.info('Making temp copy of install image') |
127 | + tmpimage = os.path.join(tmpdir, os.path.basename(image)) |
128 | + self.logger.debug('Copying %s to %s', image, tmpimage) |
129 | + shutil.copyfile(image, tmpimage) |
130 | + return tmpimage |
131 | + |
132 | + def _setuppreseed(self, tmpdir): |
133 | + # Read the provided "answers file" for subiquity, overwrite |
134 | + # the Identity section so that we can log in how SSHMixin |
135 | + # expects to be able to and prepare a disk image in the form |
136 | + # subiquity expects that contains it. |
137 | + self.answers_image_path = os.path.join(tmpdir, "answers.img") |
138 | + mount_dir = os.path.join(tmpdir, "answers") |
139 | + os.makedirs(mount_dir) |
140 | + cmds = [ |
141 | + ['truncate', '-s', '1M', self.answers_image_path], |
142 | + ['mkfs.ext2', '-U', '00c629d6-06ab-4dfd-b21e-c3186f34105d', self.answers_image_path], |
143 | + # XXX This probably should change to "sudo mount" rather |
144 | + # than dodgy fuse packages from universe: |
145 | + ['fuse-ext2', self.answers_image_path, mount_dir, '-o', 'rw+'], |
146 | + ] |
147 | + for cmd in cmds: |
148 | + if ProcessRunner(cmd).returncode != 0: |
149 | + raise UTAHVMProvisioningException( |
150 | + '{} failed'.format(cmd)) |
151 | + yaml_file = os.path.join(mount_dir, "answers.yaml") |
152 | + answers = yaml.safe_load(open(self.preseed)) |
153 | + answers['Identity'] = { |
154 | + # This is "ubuntu", crypted: |
155 | + 'password': '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1', |
156 | + 'hostname': self.name, |
157 | + 'realname': 'Utah', |
158 | + 'ssh-import-id': 'lp:' + config.launchpad_user.encode('utf-8'), |
159 | + 'username': config.user, |
160 | + } |
161 | + |
162 | + with open(yaml_file, 'w') as f: |
163 | + f.write(yaml.dump(answers)) |
164 | + cmd = ['fusermount', '-u', mount_dir] |
165 | + if ProcessRunner(cmd).returncode != 0: |
166 | + raise UTAHVMProvisioningException( |
167 | + '{} failed'.format(cmd)) |
168 | + disk = {'bus': self.diskbus, |
169 | + 'file': self.answers_image_path, |
170 | + 'size': 1024*1024, |
171 | + 'type': 'raw'} |
172 | + self.disks.append(disk) |
173 | + |
174 | + def _createdisks(self, disksizes=None): |
175 | + """Create disk files if needed and build a list of them.""" |
176 | + self.logger.info('Creating disks') |
177 | + if disksizes is None: |
178 | + disksizes = self.disksizes |
179 | + for index, size in enumerate(disksizes): |
180 | + disksize = '{}G'.format(size) |
181 | + basename = 'disk{}.qcow2'.format(index) |
182 | + diskfile = os.path.join(self.directory, basename) |
183 | + if not os.path.isfile(diskfile): |
184 | + cmd = ['qemu-img', 'create', '-f', 'qcow2', diskfile, |
185 | + disksize] |
186 | + self.logger.debug('Creating %s disk using: %s', |
187 | + disksize, ' '.join(cmd)) |
188 | + if ProcessRunner(cmd).returncode != 0: |
189 | + raise UTAHVMProvisioningException( |
190 | + 'Could not create disk image at {}'.format(diskfile)) |
191 | + disk = {'bus': self.diskbus, |
192 | + 'file': diskfile, |
193 | + 'size': disksize, |
194 | + 'type': 'qcow2'} |
195 | + self.disks.append(disk) |
196 | + self.cleanfile(diskfile) |
197 | + self.logger.debug('Adding disk to list') |
198 | + |
199 | + def _installxml_rewrite_boot(self, xmlt): |
200 | + # Set the arch appropriately, remove any configuration of |
201 | + # kernel/initrd/commandline and configure the VM to boot from |
202 | + # CD. |
203 | + xmlt.getroot().set('type', 'kvm') |
204 | + ose = xmlt.find('os') |
205 | + if self.image.arch == 'i386': |
206 | + ose.find('type').set('arch', 'i686') |
207 | + elif self.image.arch == 'amd64': |
208 | + ose.find('type').set('arch', 'x86_64') |
209 | + elif self.image.arch == 'ppc64el': |
210 | + ose.find('type').set('arch', 'ppc64le') |
211 | + else: |
212 | + ose.find('type').set('arch', self.image.arch) |
213 | + self.logger.debug('Setting up boot info') |
214 | + for kernele in list(ose.iterfind('kernel')): |
215 | + ose.remove(kernele) |
216 | + for initrde in list(ose.iterfind('initrd')): |
217 | + ose.remove(initrde) |
218 | + for cmdlinee in list(ose.iterfind('cmdline')): |
219 | + ose.remove(cmdlinee) |
220 | + boot = ElementTree.Element('boot') |
221 | + boot.set('dev', 'cdrom') |
222 | + ose.append(boot) |
223 | + |
224 | + def _installxml_rewrite_disks(self, devices, image): |
225 | + # 1. Remove all existing disks |
226 | + # 2. Hook image up to cdrom |
227 | + # 3. Create all required disks. |
228 | + self.logger.debug('Setting up disks') |
229 | + for disk in list(devices.iterfind('disk')): |
230 | + if disk.get('device') == 'disk': |
231 | + devices.remove(disk) |
232 | + self.logger.debug('Removed existing disk') |
233 | + #TODO: Add a cdrom if none exists |
234 | + if disk.get('device') == 'cdrom': |
235 | + if disk.find('source') is not None: |
236 | + disk.find('source').set('file', image) |
237 | + self.logger.debug('Rewrote existing CD-ROM') |
238 | + else: |
239 | + source = ElementTree.Element('source') |
240 | + source.set('file', image) |
241 | + disk.append(source) |
242 | + self.logger.debug( |
243 | + 'Added source to existing CD-ROM') |
244 | + for disk in self.disks: |
245 | + diske = ElementTree.Element('disk') |
246 | + diske.set('type', 'file') |
247 | + diske.set('device', 'disk') |
248 | + driver = ElementTree.Element('driver') |
249 | + driver.set('name', 'qemu') |
250 | + driver.set('type', disk['type']) |
251 | + diske.append(driver) |
252 | + serial = ElementTree.Element('serial') |
253 | + serial.text = "disk-{}".format(self.disks.index(disk)) |
254 | + diske.append(serial) |
255 | + source = ElementTree.Element('source') |
256 | + source.set('file', disk['file']) |
257 | + diske.append(source) |
258 | + target = ElementTree.Element('target') |
259 | + dev = ("vd{}" |
260 | + .format(string.ascii_lowercase[self.disks.index(disk)])) |
261 | + target.set('dev', dev) |
262 | + target.set('bus', disk['bus']) |
263 | + diske.append(target) |
264 | + devices.append(diske) |
265 | + self.logger.debug('Added %s disk', str(disk['size'])) |
266 | + |
267 | + def _installxml_rewrite_nics(self, devices): |
268 | + macs = list(self.macs) |
269 | + for interface in devices.iterfind('interface'): |
270 | + if interface.get('type') in ['network', 'bridge']: |
271 | + if len(macs) > 0: |
272 | + mac = macs.pop(0) |
273 | + interface.find('mac').set('address', mac) |
274 | + self.logger.debug( |
275 | + 'Rewrote interface to use specified mac address: %s', |
276 | + mac) |
277 | + else: |
278 | + mac = random_mac_address() |
279 | + interface.find('mac').set('address', mac) |
280 | + self.macs.append(mac) |
281 | + self.logger.debug( |
282 | + 'Rewrote interface to use random mac address: %s', |
283 | + mac) |
284 | + if interface.get('type') == 'bridge': |
285 | + interface.find('source').set('bridge', config.bridge) |
286 | + |
287 | + def _installxml_add_serial(self, devices): |
288 | + serial = ElementTree.Element('serial') |
289 | + serial.set('type', 'file') |
290 | + source = ElementTree.Element('source') |
291 | + source.set('path', self.syslog) |
292 | + #TODO: reintegrate this when the required qemu version lands |
293 | +# source.set('append', 'on') # lp#1548499 |
294 | + serial.append(source) |
295 | + target = ElementTree.Element('target') |
296 | + target.set('port', '0') |
297 | + serial.append(target) |
298 | + devices.append(serial) |
299 | + |
300 | + def _installxml(self, image, tmpdir, xml): |
301 | + """Return the XML tree to be passed to libvirt for VM installation.""" |
302 | + self.logger.info('Creating installation XML') |
303 | + xmlt = ElementTree.ElementTree(file=xml) |
304 | + xmlt.find('on_reboot').text = 'destroy' |
305 | + self.logger.debug('Rewriting basic info') |
306 | + xmlt.find('name').text = self.name |
307 | + xmlt.find('uuid').text = self.uuid |
308 | + self._installxml_rewrite_boot(xmlt) |
309 | + devices = xmlt.find('devices') |
310 | + self._installxml_rewrite_disks(devices, image) |
311 | + self._installxml_rewrite_nics(devices) |
312 | + self._installxml_add_serial(devices) |
313 | + xmlt.write(os.path.join(tmpdir, 'install.xml')) |
314 | + self.logger.info('Installation XML ready') |
315 | + return xmlt |
316 | + |
317 | + def _installvm(self, lv=None, tmpdir=None, xml=None): |
318 | + """Install a VM, then undefine it in libvirt. |
319 | + |
320 | + The final installation will recreate the VM using the existing disks. |
321 | + |
322 | + """ |
323 | + self.logger.info('Creating VM') |
324 | + if lv is None: |
325 | + lv = self.lv |
326 | + if xml is None: |
327 | + xml = self.xml |
328 | + if tmpdir is None: |
329 | + tmpdir = self.tmpdir |
330 | + os.chmod(tmpdir, 0o755) |
331 | + self.logger.info( |
332 | + 'Installing system on VM (may take over an hour)') |
333 | + self.logger.info('You can watch the progress with virt-viewer') |
334 | + log_filename = os.path.join(config.logpath, |
335 | + '{}.syslog.log'.format(self.name)) |
336 | + self.logger.info('Logs will be written to %s', log_filename) |
337 | + |
338 | + before = time.time() |
339 | + try: |
340 | + vm = lv.defineXML(ElementTree.tostring(xml.getroot())) |
341 | + vm.create() |
342 | + while vm.isActive() is not 0: |
343 | + time.sleep(1) |
344 | + finally: # This stops us from leaking VMs on exceptions |
345 | + try: |
346 | + vm.destroy() |
347 | + except libvirt.libvirtError: |
348 | + pass |
349 | + finally: |
350 | + vm.undefine() |
351 | + |
352 | + after = time.time() |
353 | + self.logger.info('Installation complete in %s seconds' % |
354 | + int(after-before)) |
355 | + def _finalxml(self, tmpdir, xml): |
356 | + """Create the XML to be used for the post-installation VM. |
357 | + |
358 | + This "ejects" the CD and removes the answers image file. |
359 | + """ |
360 | + self.logger.info('Creating final VM XML') |
361 | + self.logger.debug('Setting VM to reboot normally on reboot') |
362 | + xml.find('on_reboot').text = 'restart' |
363 | + self.logger.debug('Removing VM install parameters') |
364 | + devices = xml.find('devices') |
365 | + for disk in list(devices.iterfind('disk')): |
366 | + if disk.get('device') == 'cdrom': |
367 | + disk.remove(disk.find('source')) |
368 | + source = disk.find('source') |
369 | + if source is not None and source.get('file') == self.answers_image_path: |
370 | + devices.remove(disk) |
371 | + xml.write(os.path.join(tmpdir, 'final.xml')) |
372 | + return xml |
373 | + |
374 | + def _create(self, provision_data): |
375 | + """Create the VM, install the system, and prepare it to boot. |
376 | + """ |
377 | + self.logger.info('Creating custom virtual machine') |
378 | + |
379 | + tmpdir = tempfile.mkdtemp(prefix='{}/tmp'.format(self.directory)) |
380 | + self.cleanfile(tmpdir) |
381 | + self.logger.debug('Working dir: %s', tmpdir) |
382 | + os.chdir(tmpdir) |
383 | + |
384 | + if provision_data: |
385 | + 1/0 |
386 | + |
387 | + self._setuppreseed(tmpdir=tmpdir) |
388 | + |
389 | + #self._setuplogging(tmpdir=tmpdir) |
390 | + |
391 | + self._createdisks() |
392 | + |
393 | + image = self._tmpimage(image=self.image.image, tmpdir=tmpdir) |
394 | + |
395 | + xml = self._installxml(image=image, tmpdir=tmpdir, xml=self.xml) |
396 | + |
397 | + self._installvm(lv=self.lv, tmpdir=tmpdir, xml=xml) |
398 | + |
399 | + xml = self._finalxml(tmpdir=tmpdir, xml=xml) |
400 | + |
401 | + self.logger.info('Setting up final VM') |
402 | + self.vm = self.lv.defineXML(ElementTree.tostring(xml.getroot())) |
403 | + self.cleanfunction(self.vm.destroy) |
404 | + self.cleanfunction(self.vm.undefine) |
405 | + if self.poweroff: |
406 | + self.cleanfunction(self.vm.shutdown, force=True) |
407 | + |
408 | + def _start(self): |
409 | + """Start the VM.""" |
410 | + self.logger.info('Starting CustomVM') |
411 | + if self.vm is not None: |
412 | + if self.vm.isActive() == 0: |
413 | + self.vm.create() |
414 | + else: |
415 | + raise UTAHVMProvisioningException('Failed to provision VM') |
416 | + |
417 | + try: |
418 | + self.logger.info( |
419 | + 'Waiting %d seconds for ping response', config.boottimeout) |
420 | + self.pingpoll(timeout=config.boottimeout) |
421 | + except UTAHTimeout: |
422 | + # Ignore timeout for ping, since depending on the network |
423 | + # configuration ssh might still work despite of the ping failure. |
424 | + self.logger.warning('Network connectivity (ping) failure') |
425 | + self.sshpoll(timeout=config.boottimeout) |
426 | + self._enable_root_login() |
427 | + self.active = True |
428 | + |
429 | + def _enable_root_login(self): |
430 | + try: |
431 | + self.ssh_client.connect(self.name, |
432 | + username=config.user, |
433 | + key_filename=config.sshprivatekey) |
434 | + chan = self.ssh_client.get_transport().open_session() |
435 | + chan.get_pty() |
436 | + chan.set_combine_stderr(True) |
437 | + chan.exec_command("sudo su -c 'cp -aT .ssh /root/.ssh && chown root:root -R /root/.ssh && echo ok'") |
438 | + def expect(pat): |
439 | + prog = re.compile(pat) |
440 | + buf = '' |
441 | + while True: |
442 | + match = prog.match(buf) |
443 | + if match: |
444 | + return match |
445 | + r, _, _ = select.select([chan], [], [], 10) |
446 | + if not r: |
447 | + raise Exception("no output for 10s after %r", buf) |
448 | + c = chan.recv(1) |
449 | + buf += c |
450 | + expect('\[sudo\] password for ([a-z]+): ') |
451 | + chan.send('ubuntu\n') |
452 | + expect('\r\n') |
453 | + m = expect("Sorry|ok") |
454 | + if m.string != "ok": |
455 | + raise UTAHVMProvisioningException("_enable_root_login failed") |
456 | + finally: |
457 | + self.ssh_logger.debug('Closing SSH connection') |
458 | + self.ssh_client.close() |