Merge lp:~mwhudson/utah/live-server into lp:utah

Proposed by Michael Hudson-Doyle
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
Reviewer Review Type Date Requested Status
Canonical CI Engineering Pending
Review via email: mp+335374@code.launchpad.net

Description of the change

I tested this branch with something approximating:

$ echo '{"group": "mwhudson", "launchpad_user": "mwhudson"}' > ~/.utah/config
$ wget http://people.canonical.com/~mwh/live-server-for-utah.iso -O ~/isos/live-server-for-utah.iso
$ git clone https://github.com/CanonicalLtd/subiquity ~/src/subiquity
$ bzr branch lp:ubuntu-test-cases/server ~/src/ubuntu-test-cases/server
$ PYTHONPATH=. ./examples/run_utah_tests.py -m virtual-live-server ~/src/ubuntu-test-cases/server/runlists/lxc.run -p ~/src/subiquity/examples/answers.yaml -x /etc/utah/bridged-network-vm.xml -i ~/isos/live-server-for-utah.iso

Please let me know what you think. It's a bit rough but it should at least not risk breaking anything else.

To post a comment you must log in.
lp:~mwhudson/utah/live-server updated
1111. By Joshua Powers

Enable live-server ISO testing

This allows for the testing of live-server ISOs (aka subiquity)
based ISOs.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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()

Subscribers

People subscribed via source and target branches