Setup and run QEMU VM
[csit.git] / resources / libraries / python / QemuUtils.py
1 # Copyright (c) 2016 Cisco and/or its affiliates.
2 # Licensed under the Apache License, Version 2.0 (the "License");
3 # you may not use this file except in compliance with the License.
4 # You may obtain a copy of the License at:
5 #
6 #     http://www.apache.org/licenses/LICENSE-2.0
7 #
8 # Unless required by applicable law or agreed to in writing, software
9 # distributed under the License is distributed on an "AS IS" BASIS,
10 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 # See the License for the specific language governing permissions and
12 # limitations under the License.
13
14 """QEMU utilities library."""
15
16 import json
17 import re
18 from time import time, sleep
19 from robot.api import logger
20 from resources.libraries.python.ssh import SSH
21 from resources.libraries.python.constants import Constants
22 from resources.libraries.python.topology import NodeType
23
24
25 class QemuUtils(object):
26     """QEMU utilities."""
27
28     __QEMU_BIN = '/tmp/qemu-2.2.1/build/x86_64-softmmu/qemu-system-x86_64'
29     # QEMU Machine Protocol socket
30     __QMP_SOCK = '/tmp/qmp.sock'
31     # QEMU Guest Agent socket
32     __QGA_SOCK = '/tmp/qga.sock'
33
34     def __init__(self):
35         self._qemu_opt = {}
36         # Default 1 CPU.
37         self._qemu_opt['smp'] = '-smp 1,sockets=1,cores=1,threads=1'
38         # Daemonize the QEMU process after initialization. Default one
39         # management interface.
40         self._qemu_opt['options'] = '-daemonize -enable-kvm ' \
41             '-machine pc-1.0,accel=kvm,usb=off,mem-merge=off ' \
42             '-net nic,macaddr=52:54:00:00:02:01'
43         self._qemu_opt['ssh_fwd_port'] = 10022
44         # Default 512MB virtual RAM
45         self._qemu_opt['mem_size'] = 512
46         # Default huge page mount point, required for Vhost-user interfaces.
47         self._qemu_opt['huge_mnt'] = '/mnt/huge'
48         # VM node info dict
49         self._vm_info = {
50             'type': NodeType.VM,
51             'port': 10022,
52             'username': 'cisco',
53             'password': 'cisco',
54             'interfaces': {},
55         }
56         self._vhost_id = 0
57         self._ssh = None
58         self._node = None
59         self._socks = [self.__QMP_SOCK, self.__QGA_SOCK]
60
61     def qemu_set_smp(self, cpus, cores, threads, sockets):
62         """Set SMP option for QEMU
63
64         :param cpus: Number of CPUs.
65         :param cores: Number of CPU cores on one socket.
66         :param threads: Number of threads on one CPU core.
67         :param sockets: Number of discrete sockets in the system.
68         :type cpus: int
69         :type cores: int
70         :type threads: int
71         :type sockets: int
72         """
73         self._qemu_opt['smp'] = '-smp {},cores={},threads={},sockets={}'.format(
74             cpus, cores, threads, sockets)
75
76     def qemu_set_ssh_fwd_port(self, fwd_port):
77         """Set host port for guest SSH forwarding.
78
79         :param fwd_port: Port number on host for guest SSH forwarding.
80         :type fwd_port: int
81         """
82         self._qemu_opt['ssh_fwd_port'] = fwd_port
83         self._vm_info['port'] = fwd_port
84
85     def qemu_set_mem_size(self, mem_size):
86         """Set virtual RAM size.
87
88         :param mem_size: RAM size in Mega Bytes.
89         :type mem_size: int
90         """
91         self._qemu_opt['mem_size'] = mem_size
92
93     def qemu_set_huge_mnt(self, huge_mnt):
94         """Set hugefile mount point.
95
96         :param huge_mnt: System hugefile mount point.
97         :type huge_mnt: int
98         """
99         self._qemu_opt['huge_mnt'] = huge_mnt
100
101     def qemu_set_disk_image(self, disk_image):
102         """Set disk image.
103
104         :param disk_image: Path of the disk image.
105         :type disk_image: str
106         """
107         self._qemu_opt['disk_image'] = disk_image
108
109     def qemu_set_node(self, node):
110         """Set node to run QEMU on.
111
112         :param node: Node to run QEMU on.
113         :param node: dict
114         """
115         self._node = node
116         self._ssh = SSH()
117         self._ssh.connect(node)
118         self._vm_info['host'] = node['host']
119
120     def qemu_add_vhost_user_if(self, socket, server=True, mac=None):
121         """Add Vhost-user interface.
122
123         :param socket: Path of the unix socket.
124         :param server: If True the socket shall be a listening socket.
125         :param mac: Vhost-user interface MAC address (optional, otherwise is
126             used autogenerated MAC 52:54:00:00:04:xx).
127         :type socket: str
128         :type server: bool
129         :type mac: str
130         """
131         self._vhost_id += 1
132         # Create unix socket character device.
133         chardev = ' -chardev socket,id=char{0},path={1}'.format(self._vhost_id,
134                                                                 socket)
135         if server is True:
136             chardev += ',server'
137         self._qemu_opt['options'] += chardev
138         # Create Vhost-user network backend.
139         netdev = ' -netdev vhost-user,id=vhost{0},chardev=char{0}'.format(
140             self._vhost_id)
141         self._qemu_opt['options'] += netdev
142         # If MAC is not specified use autogenerated 52:54:00:00:04:<vhost_id>
143         # e.g. vhost1 MAC is 52:54:00:00:04:01
144         if mac is None:
145             mac = '52:54:00:00:04:{0:02x}'.format(self._vhost_id)
146         # Create Virtio network device.
147         device = ' -device virtio-net-pci,netdev=vhost{0},mac={1}'.format(
148             self._vhost_id, mac)
149         self._qemu_opt['options'] += device
150         # Add interface MAC and socket to the node dict
151         if_data = {'mac_address': mac, 'socket': socket}
152         if_name = 'vhost{}'.format(self._vhost_id)
153         self._vm_info['interfaces'][if_name] = if_data
154         # Add socket to the socket list
155         self._socks.append(socket)
156
157     def _qemu_qmp_exec(self, cmd):
158         """Execute QMP command.
159
160         QMP is JSON based protocol which allows to control QEMU instance.
161
162         :param cmd: QMP command to execute.
163         :type cmd: str
164         :return: Command output in python representation of JSON format. The
165             { "return": {} } response is QMP's success response. An error
166             response will contain the "error" keyword instead of "return".
167         """
168         # To enter command mode, the qmp_capabilities command must be issued.
169         qmp_cmd = 'echo "{ \\"execute\\": \\"qmp_capabilities\\" }' + \
170             '{ \\"execute\\": \\"' + cmd + '\\" }" | sudo -S nc -U ' + \
171             self.__QMP_SOCK
172         (ret_code, stdout, stderr) = self._ssh.exec_command(qmp_cmd)
173         if 0 != int(ret_code):
174             logger.debug('QMP execute failed {0}'.format(stderr))
175             raise RuntimeError('QMP execute "{0}" failed on {1}'.format(cmd,
176                 self._node['host']))
177         logger.trace(stdout)
178         # Skip capabilities negotiation messages.
179         out_list = stdout.splitlines()
180         if len(out_list) < 3:
181             raise RuntimeError('Invalid QMP output on {0}'.format(
182                 self._node['host']))
183         return json.loads(out_list[2])
184
185     def _qemu_qga_exec(self, cmd):
186         """Execute QGA command.
187
188         QGA provide access to a system-level agent via standard QMP commands.
189
190         :param cmd: QGA command to execute.
191         :type cmd: str
192         """
193         qga_cmd = 'echo "{ \\"execute\\": \\"' + cmd + '\\" }" | sudo -S nc ' \
194             '-q 1 -U ' + self.__QGA_SOCK
195         (ret_code, stdout, stderr) = self._ssh.exec_command(qga_cmd)
196         if 0 != int(ret_code):
197             logger.debug('QGA execute failed {0}'.format(stderr))
198             raise RuntimeError('QGA execute "{0}" failed on {1}'.format(cmd,
199                 self._node['host']))
200         logger.trace(stdout)
201         if not stdout:
202             return {}
203         return json.loads(stdout)
204
205     def _wait_until_vm_boot(self, timeout=300):
206         """Wait until QEMU VM is booted.
207
208         Ping QEMU guest agent each 5s until VM booted or timeout.
209
210         :param timeout: Waiting timeout in seconds (optional, default 300s).
211         :type timeout: int
212         """
213         start = time()
214         while 1:
215             if time() - start > timeout:
216                 raise RuntimeError('timeout, VM {0} not booted on {1}'.format(
217                     self._qemu_opt['disk_image'], self._node['host']))
218             out = self._qemu_qga_exec('guest-ping')
219             # Empty output - VM not booted yet
220             if not out:
221                 sleep(5)
222             # Non-error return - VM booted
223             elif out.get('return') is not None:
224                 break
225             else:
226                 raise RuntimeError('QGA guest-ping unexpected output {}'.format(
227                     out))
228         logger.trace('VM {0} booted on {1}'.format(self._qemu_opt['disk_image'],
229                                                    self._node['host']))
230
231     def _update_vm_interfaces(self):
232         """Update interface names in VM node dict."""
233         # Send guest-network-get-interfaces command via QGA, output example:
234         # {"return": [{"name": "eth0", "hardware-address": "52:54:00:00:04:01"},
235         # {"name": "eth1", "hardware-address": "52:54:00:00:04:02"}]}
236         out = self._qemu_qga_exec('guest-network-get-interfaces')
237         interfaces = out.get('return')
238         mac_name = {}
239         if not interfaces:
240             raise RuntimeError('Get VM {0} interface list failed on {1}'.format(
241                 self._qemu_opt['disk_image'], self._node['host']))
242         # Create MAC-name dict
243         for interface in interfaces:
244             mac_name[interface['hardware-address']] = interface['name']
245         # Match interface by MAC and save interface name
246         for interface in self._vm_info['interfaces'].values():
247             mac = interface.get('mac_address')
248             if_name = mac_name.get(mac)
249             if if_name is None:
250                 logger.trace('Interface name for MAC {} not found'.format(mac))
251             else:
252                 interface['name'] = if_name
253
254     def _huge_page_check(self):
255         """Huge page check."""
256         huge_mnt = self._qemu_opt.get('huge_mnt')
257         mem_size = self._qemu_opt.get('mem_size')
258         # Check size of free huge pages
259         (_, output, _) = self._ssh.exec_command('grep Huge /proc/meminfo')
260         regex = re.compile(r'HugePages_Free:\s+(\d+)')
261         match = regex.search(output)
262         huge_free = int(match.group(1))
263         regex = re.compile(r'Hugepagesize:\s+(\d+)')
264         match = regex.search(output)
265         huge_size = int(match.group(1))
266         if (mem_size * 1024) > (huge_free * huge_size):
267             raise RuntimeError('Not enough free huge pages {0} kB, required '
268                 '{1} MB'.format(huge_free * huge_size, mem_size))
269         # Check if huge pages mount point exist
270         has_huge_mnt = False
271         (_, output, _) = self._ssh.exec_command('cat /proc/mounts')
272         for line in output.splitlines():
273             # Try to find something like:
274             # none /mnt/huge hugetlbfs rw,relatime,pagesize=2048k 0 0
275             mount = line.split()
276             if mount[2] == 'hugetlbfs' and mount[1] == huge_mnt:
277                 has_huge_mnt = True
278                 break
279         # If huge page mount point not exist create one
280         if not has_huge_mnt:
281             cmd = 'mount -t hugetlbfs -o pagesize=2048k none {0}'.format(
282                 huge_mnt)
283             (ret_code, _, stderr) = self._ssh.exec_command_sudo(cmd)
284             if int(ret_code) != 0:
285                 logger.debug('Mount huge pages failed {0}'.format(stderr))
286                 raise RuntimeError('Mount huge pages failed on {0}'.format(
287                     self._node['host']))
288
289     def qemu_start(self):
290         """Start QEMU and wait until VM boot.
291
292         :return: VM node info
293         :rtype: dict
294         .. note:: First set at least disk image and node to run QEMU on.
295         """
296         # SSH forwarding
297         ssh_fwd = '-net user,hostfwd=tcp::{0}-:22'.format(
298             self._qemu_opt.get('ssh_fwd_port'))
299         # Memory and huge pages
300         mem = '-object memory-backend-file,id=mem,size={0}M,mem-path={1},' \
301             'share=on -m {0} -numa node,memdev=mem'.format(
302             self._qemu_opt.get('mem_size'), self._qemu_opt.get('huge_mnt'))
303         self._huge_page_check()
304         # Setup QMP via unix socket
305         qmp = '-qmp unix:{0},server,nowait'.format(self.__QMP_SOCK)
306         # Setup QGA via chardev (unix socket) and virtio-serial channel
307         qga = '-chardev socket,path=/tmp/qga.sock,server,nowait,id=qga0 ' \
308             '-device virtio-serial ' \
309             '-device virtserialport,chardev=qga0,name=org.qemu.guest_agent.0'
310         # Run QEMU
311         cmd = '{0} {1} {2} {3} {4} -hda {5} {6} {7}'.format(
312             self.__QEMU_BIN, self._qemu_opt.get('smp'), mem, ssh_fwd,
313             self._qemu_opt.get('options'),
314             self._qemu_opt.get('disk_image'), qmp, qga)
315         (ret_code, _, stderr) = self._ssh.exec_command_sudo(cmd, timeout=300)
316         if int(ret_code) != 0:
317             logger.debug('QEMU start failed {0}'.format(stderr))
318             raise RuntimeError('QEMU start failed on {0}'.format(
319                 self._node['host']))
320         logger.trace('QEMU running')
321         # Wait until VM boot
322         self._wait_until_vm_boot()
323         # Update interface names in VM node dict
324         self._update_vm_interfaces()
325         # Return VM node dict
326         return self._vm_info
327
328     def qemu_quit(self):
329         """Quit the QEMU emulator."""
330         out = self._qemu_qmp_exec('quit')
331         err = out.get('error')
332         if err is not None:
333             raise RuntimeError('QEMU quit failed on {0}, error: {1}'.format(
334                 self._node['host'], json.dumps(err)))
335
336     def qemu_system_powerdown(self):
337         """Power down the system (if supported)."""
338         out = self._qemu_qmp_exec('system_powerdown')
339         err = out.get('error')
340         if err is not None:
341             raise RuntimeError('QEMU system powerdown failed on {0}, '
342                 'error: {1}'.format(self._node['host'], json.dumps(err)))
343
344     def qemu_system_reset(self):
345         """Reset the system."""
346         out = self._qemu_qmp_exec('system_reset')
347         err = out.get('error')
348         if err is not None:
349             raise RuntimeError('QEMU system reset failed on {0}, '
350                 'error: {1}'.format(self._node['host'], json.dumps(err)))
351
352     def qemu_kill(self):
353         """Kill qemu process."""
354         # TODO: add PID storage so that we can kill specific PID
355         # Note: in QEMU start phase there are 3 QEMU processes because we
356         # daemonize QEMU
357         self._ssh.exec_command_sudo('pkill -SIGKILL qemu')
358
359     def qemu_clear_socks(self):
360         """Remove all sockets created by QEMU."""
361         for sock in self._socks:
362             cmd = 'rm -f {}'.format(sock)
363             self._ssh.exec_command_sudo(cmd)
364
365     def qemu_system_status(self):
366         """Return current VM status.
367
368         :return: VM status.
369         :rtype: str
370         """
371         out = self._qemu_qmp_exec('query-status')
372         ret = out.get('return')
373         if ret is not None:
374             return ret.get('status')
375         else:
376             err = out.get('error')
377             raise RuntimeError('QEMU query-status failed on {0}, '
378                 'error: {1}'.format(self._node['host'], json.dumps(err)))
379
380     @staticmethod
381     def build_qemu(node):
382         """Build QEMU from sources.
383
384         :param node: Node to build QEMU on.
385         :type node: dict
386         """
387         ssh = SSH()
388         ssh.connect(node)
389
390         (ret_code, stdout, stderr) = \
391             ssh.exec_command('sudo -Sn bash {0}/{1}/qemu_build.sh'.format(
392                 Constants.REMOTE_FW_DIR, Constants.RESOURCES_LIB_SH), 1000)
393         logger.trace(stdout)
394         if 0 != int(ret_code):
395             logger.debug('QEMU build failed {0}'.format(stderr))
396             raise RuntimeError('QEMU build failed on {0}'.format(node['host']))