Merge pull request #45164 from jasperla/vmctl

New vmctl module to interface with the OpenBSD VMM hypervisor
This commit is contained in:
Nicole Thomas 2018-01-09 15:04:44 -05:00 committed by GitHub
commit 57b686033a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 640 additions and 0 deletions

403
salt/modules/vmctl.py Normal file
View File

@ -0,0 +1,403 @@
# -*- coding: utf-8 -*-
'''
Manage vms running on the OpenBSD VMM hypervisor using vmctl(8).
.. versionadded:: Fluorine
:codeauthor: :email:`Jasper Lievisse Adriaanse <jasper@openbsd.org>`
.. note::
This module requires the `vmd` service to be running on the OpenBSD
target machine.
'''
from __future__ import absolute_import
# Import python libs
import logging
import re
# Imoprt salt libs:
import salt.utils.path
from salt.exceptions import (CommandExecutionError, SaltInvocationError)
from salt.ext.six.moves import zip
log = logging.getLogger(__name__)
def __virtual__():
'''
Only works on OpenBSD with vmctl(8) present.
'''
if __grains__['os'] == 'OpenBSD' and salt.utils.path.which('vmctl'):
return True
return (False, 'The vmm execution module cannot be loaded: either the system is not OpenBSD or the vmctl binary was not found')
def _id_to_name(id):
'''
Lookup the name associated with a VM id.
'''
vm = status(id=id)
if vm == {}:
return None
else:
return vm['name']
def create_disk(name, size):
'''
Create a VMM disk with the specified `name` and `size`.
size:
Size in megabytes, or use a specifier such as M, G, T.
CLI Example:
.. code-block:: bash
salt '*' vmctl.create_disk /path/to/disk.img size=10G
'''
ret = False
cmd = 'vmctl create {0} -s {1}'.format(name, size)
result = __salt__['cmd.run_all'](cmd,
output_loglevel='trace',
python_shell=False)
if result['retcode'] == 0:
ret = True
else:
raise CommandExecutionError(
'Problem encountered creating disk image',
info={'errors': [result['stderr']], 'changes': ret}
)
return ret
def load(path):
'''
Load additional configuration from the specified file.
path
Path to the configuration file.
CLI Example:
.. code-block:: bash
salt '*' vmctl.load path=/etc/vm.switches.conf
'''
ret = False
cmd = 'vmctl load {0}'.format(path)
result = __salt__['cmd.run_all'](cmd,
output_loglevel='trace',
python_shell=False)
if result['retcode'] == 0:
ret = True
else:
raise CommandExecutionError(
'Problem encountered running vmctl',
info={'errors': [result['stderr']], 'changes': ret}
)
return ret
def reload():
'''
Remove all stopped VMs and reload configuration from the default configuration file.
CLI Example:
.. code-block:: bash
salt '*' vmctl.reload
'''
ret = False
cmd = 'vmctl reload'
result = __salt__['cmd.run_all'](cmd,
output_loglevel='trace',
python_shell=False)
if result['retcode'] == 0:
ret = True
else:
raise CommandExecutionError(
'Problem encountered running vmctl',
info={'errors': [result['stderr']], 'changes': ret}
)
return ret
def reset(all=False, vms=False, switches=False):
'''
Reset the running state of VMM or a subsystem.
all:
Reset the running state.
switches:
Reset the configured switches.
vms:
Reset and terminate all VMs.
CLI Example:
.. code-block:: bash
salt '*' vmctl.reset all=True
'''
ret = False
cmd = ['vmctl', 'reset']
if all:
cmd.append('all')
elif vms:
cmd.append('vms')
elif switches:
cmd.append('switches')
result = __salt__['cmd.run_all'](cmd,
output_loglevel='trace',
python_shell=False)
if result['retcode'] == 0:
ret = True
else:
raise CommandExecutionError(
'Problem encountered running vmctl',
info={'errors': [result['stderr']], 'changes': ret}
)
return ret
def start(name=None, id=None, bootpath=None, disk=None, disks=None, local_iface=False,
memory=None, nics=0, switch=None):
'''
Starts a VM defined by the specified parameters.
When both a name and id are provided, the id is ignored.
name:
Name of the defined VM.
id:
VM id.
bootpath:
Path to a kernel or BIOS image to load.
disk:
Path to a single disk to use.
disks:
List of multiple disks to use.
local_iface:
Whether to add a local network interface. See "LOCAL INTERFACES"
in the vmctl(8) manual page for more information.
memory:
Memory size of the VM specified in megabytes.
switch:
Add a network interface that is attached to the specified
virtual switch on the host.
CLI Example:
.. code-block:: bash
salt '*' vmctl.start 2 # start VM with id 2
salt '*' vmctl.start name=web1 bootpath='/bsd.rd' nics=2 memory=512M disk='/disk.img'
'''
ret = {'changes': False, 'console': None}
cmd = ['vmctl', 'start']
if not (name or id):
raise SaltInvocationError('Must provide either "name" or "id"')
elif name:
cmd.append(name)
else:
cmd.append(id)
name = _id_to_name(id)
if nics > 0:
cmd.append('-i {0}'.format(nics))
# Paths cannot be appended as otherwise the inserted whitespace is treated by
# vmctl as being part of the path.
if bootpath:
cmd.extend(['-b', bootpath])
if memory:
cmd.append('-m {0}'.format(memory))
if switch:
cmd.append('-n {0}'.format(switch))
if local_iface:
cmd.append('-L')
if disk and (disks and len(disks) > 0):
raise SaltInvocationError('Must provide either "disks" or "disk"')
if disk:
cmd.extend(['-d', disk])
if disks and len(disks) > 0:
cmd.extend(['-d', x] for x in disks)
# Before attempting to define a new VM, make sure it doesn't already exist.
# Otherwise return to indicate nothing was changed.
if len(cmd) > 3:
vmstate = status(name)
if vmstate:
ret['comment'] = 'VM already exists and cannot be redefined'
return ret
result = __salt__['cmd.run_all'](cmd,
output_loglevel='trace',
python_shell=False)
if result['retcode'] == 0:
ret['changes'] = True
m = re.match(r'.*successfully, tty (\/dev.*)', result['stderr'])
if m:
ret['console'] = m.groups()[0]
else:
m = re.match(r'.*Operation already in progress$', result['stderr'])
if m:
ret['changes'] = False
else:
raise CommandExecutionError(
'Problem encountered running vmctl',
info={'errors': [result['stderr']], 'changes': ret}
)
return ret
def status(name=None, id=None):
'''
List VMs running on the host, or only the VM specified by ``id''.
When both a name and id are provided, the id is ignored.
name:
Name of the defined VM.
id:
VM id.
CLI Example:
.. code-block:: bash
salt '*' vmctl.status # to list all VMs
salt '*' vmctl.status name=web1 # to get a single VM
'''
ret = {}
cmd = ['vmctl', 'status']
result = __salt__['cmd.run_all'](cmd,
output_loglevel='trace',
python_shell=False)
if result['retcode'] != 0:
raise CommandExecutionError(
'Problem encountered running vmctl',
info={'error': [result['stderr']], 'changes': ret}
)
# Grab the header and save it with the lowercase names.
header = result['stdout'].splitlines()[0].split()
header = list([x.lower() for x in header])
# A VM can be in one of the following states (from vmm.c:vcpu_state_decode())
# - stopped
# - running
# - requesting termination
# - terminated
# - unknown
for line in result['stdout'].splitlines()[1:]:
data = line.split()
vm = dict(list(zip(header, data)))
vmname = vm.pop('name')
if vm['pid'] == '-':
# If the VM has no PID it's not running.
vm['state'] = 'stopped'
elif vmname and data[-2] == '-':
# When a VM does have a PID and the second to last field is a '-', it's
# transitioning to another state. A VM name itself cannot contain a
# '-' so it's safe to split on '-'.
vm['state'] = data[-1]
else:
vm['state'] = 'running'
# When the status is requested of a single VM (by name) which is stopping,
# vmctl doesn't print the status line. So we'll parse the full list and
# return when we've found the requested VM.
if id and int(vm['id']) == id:
return {vmname: vm}
elif name and vmname == name:
return {vmname: vm}
else:
ret[vmname] = vm
# Assert we've not come this far when an id or name have been provided. That
# means the requested VM does not exist.
if id or name:
return {}
return ret
def stop(name=None, id=None):
'''
Stop (terminate) the VM identified by the given id or name.
When both a name and id are provided, the id is ignored.
name:
Name of the defined VM.
id:
VM id.
CLI Example:
.. code-block:: bash
salt '*' vmctl.stop name=alpine
'''
ret = {}
cmd = ['vmctl', 'stop']
if not (name or id):
raise SaltInvocationError('Must provide either "name" or "id"')
elif name:
cmd.append(name)
else:
cmd.append(id)
result = __salt__['cmd.run_all'](cmd,
output_loglevel='trace',
python_shell=False)
if result['retcode'] == 0:
if re.match('^vmctl: sent request to terminate vm.*', result['stderr']):
ret['changes'] = True
else:
ret['changes'] = False
else:
raise CommandExecutionError(
'Problem encountered running vmctl',
info={'errors': [result['stderr']], 'changes': ret}
)
return ret

View File

@ -0,0 +1,237 @@
# -*- coding: utf-8 -*-
# Import Python libs
from __future__ import absolute_import
# Import Salt Libs
import salt.modules.vmctl as vmctl
# Import Salt Testing Libs
from tests.support.mixins import LoaderModuleMockMixin
from tests.support.unit import TestCase
from tests.support.mock import (
MagicMock,
patch,
)
class VmctlTestCase(TestCase, LoaderModuleMockMixin):
'''
test modules.vmctl functions
'''
def setup_loader_modules(self):
return {vmctl: {}}
def test_create_disk(self):
'''
Tests creating a new disk image.
'''
ret = {}
ret['stdout'] = 'vmctl: imagefile created'
ret['stderr'] = ''
ret['retcode'] = 0
mock_cmd = MagicMock(return_value=ret)
with patch.dict(vmctl.__salt__, {'cmd.run_all': mock_cmd}):
self.assertTrue(vmctl.create_disk('/path/to/disk.img', '1G'))
def test_load(self):
'''
Tests loading a configuration file.
'''
ret = {}
ret['retcode'] = 0
mock_cmd = MagicMock(return_value=ret)
with patch.dict(vmctl.__salt__, {'cmd.run_all': mock_cmd}):
self.assertTrue(vmctl.load('/etc/vm.switches.conf'))
def test_reload(self):
'''
Tests reloading the configuration.
'''
ret = {}
ret['retcode'] = 0
mock_cmd = MagicMock(return_value=ret)
with patch.dict(vmctl.__salt__, {'cmd.run_all': mock_cmd}):
self.assertTrue(vmctl.reload())
def test_reset(self):
'''
Tests resetting VMM.
'''
ret = {}
ret['retcode'] = 0
mock_cmd = MagicMock(return_value=ret)
with patch.dict(vmctl.__salt__, {'cmd.run_all': mock_cmd}):
res = vmctl.reset()
mock_cmd.assert_called_once_with(['vmctl', 'reset'],
output_loglevel='trace', python_shell=False)
self.assertTrue(res)
def test_reset_vms(self):
'''
Tests resetting VMs.
'''
ret = {}
ret['retcode'] = 0
mock_cmd = MagicMock(return_value=ret)
with patch.dict(vmctl.__salt__, {'cmd.run_all': mock_cmd}):
res = vmctl.reset(vms=True)
mock_cmd.assert_called_once_with(['vmctl', 'reset', 'vms'],
output_loglevel='trace', python_shell=False)
self.assertTrue(res)
def test_reset_switches(self):
'''
Tests resetting switches.
'''
ret = {}
ret['retcode'] = 0
mock_cmd = MagicMock(return_value=ret)
with patch.dict(vmctl.__salt__, {'cmd.run_all': mock_cmd}):
res = vmctl.reset(switches=True)
mock_cmd.assert_called_once_with(['vmctl', 'reset', 'switches'],
output_loglevel='trace', python_shell=False)
self.assertTrue(res)
def test_reset_all(self):
'''
Tests resetting all.
'''
ret = {}
ret['retcode'] = 0
mock_cmd = MagicMock(return_value=ret)
with patch.dict(vmctl.__salt__, {'cmd.run_all': mock_cmd}):
res = vmctl.reset(all=True)
mock_cmd.assert_called_once_with(['vmctl', 'reset', 'all'],
output_loglevel='trace', python_shell=False)
self.assertTrue(res)
def test_start_existing_vm(self):
'''
Tests starting a VM that is already defined.
'''
ret = {}
ret['stderr'] = 'vmctl: started vm 4 successfully, tty /dev/ttyp4'
ret['retcode'] = 0
mock_cmd = MagicMock(return_value=ret)
expected = {'changes': True, 'console': '/dev/ttyp4'}
with patch.dict(vmctl.__salt__, {'cmd.run_all': mock_cmd}):
self.assertDictEqual(expected, vmctl.start('4'))
def test_start_new_vm(self):
'''
Tests starting a new VM.
'''
ret = {}
ret['stderr'] = 'vmctl: started vm 4 successfully, tty /dev/ttyp4'
ret['retcode'] = 0
mock_cmd = MagicMock(return_value=ret)
mock_status = MagicMock(return_value={})
expected = {'changes': True, 'console': '/dev/ttyp4'}
with patch.dict(vmctl.__salt__, {'cmd.run_all': mock_cmd}):
with patch('salt.modules.vmctl.status', mock_status):
res = vmctl.start('web1', bootpath='/bsd.rd', nics=2, disk='/disk.img')
mock_cmd.assert_called_once_with(['vmctl', 'start', 'web1', '-i 2', '-b', '/bsd.rd', '-d', '/disk.img'],
output_loglevel='trace', python_shell=False)
self.assertDictEqual(expected, res)
def test_status(self):
'''
Tests getting status for all VMs.
'''
ret = {}
ret['stdout'] = ' ID PID VCPUS MAXMEM CURMEM TTY OWNER NAME\n' \
' 1 123 1 2.9G 150M ttyp5 john web1 - stopping\n' \
' 2 456 1 512M 301M ttyp4 paul web2\n' \
' 3 - 1 512M - - george web3\n'
ret['retcode'] = 0
mock_cmd = MagicMock(return_value=ret)
expected = {
'web1': {
'curmem': '150M',
'id': '1',
'maxmem': '2.9G',
'owner': 'john',
'pid': '123',
'state': 'stopping',
'tty': 'ttyp5',
'vcpus': '1'
},
'web2': {
'curmem': '301M',
'id': '2',
'maxmem': '512M',
'owner': 'paul',
'pid': '456',
'state': 'running',
'tty': 'ttyp4',
'vcpus': '1'
},
'web3': {
'curmem': '-',
'id': '3',
'maxmem': '512M',
'owner': 'george',
'pid': '-',
'state': 'stopped',
'tty': '-',
'vcpus': '1'
},
}
with patch.dict(vmctl.__salt__, {'cmd.run_all': mock_cmd}):
self.assertEqual(expected, vmctl.status())
def test_status_single(self):
'''
Tests getting status for a single VM.
'''
ret = {}
ret['stdout'] = ' ID PID VCPUS MAXMEM CURMEM TTY OWNER NAME\n' \
' 1 123 1 2.9G 150M ttyp5 ringo web4\n' \
' 2 - 1 512M - - george web3\n'
ret['retcode'] = 0
mock_cmd = MagicMock(return_value=ret)
expected = {
'web4': {
'curmem': '150M',
'id': '1',
'maxmem': '2.9G',
'owner': 'ringo',
'pid': '123',
'state': 'running',
'tty': 'ttyp5',
'vcpus': '1'
},
}
with patch.dict(vmctl.__salt__, {'cmd.run_all': mock_cmd}):
self.assertEqual(expected, vmctl.status('web4'))
def test_stop_when_running(self):
'''
Tests stopping a VM that is running.
'''
ret = {}
ret['stdout'] = ''
ret['stderr'] = 'vmctl: sent request to terminate vm 14'
ret['retcode'] = 0
mock_cmd = MagicMock(return_value=ret)
with patch.dict(vmctl.__salt__, {'cmd.run_all': mock_cmd}):
res = vmctl.stop('web1')
mock_cmd.assert_called_once_with(['vmctl', 'stop', 'web1'],
output_loglevel='trace', python_shell=False)
self.assertTrue(res['changes'])
def test_stop_when_stopped(self):
'''
Tests stopping a VM that is already stopped/stopping.
'''
ret = {}
ret['stdout'] = ''
ret['stderr'] = 'vmctl: terminate vm command failed: Invalid argument'
ret['retcode'] = 0
mock_cmd = MagicMock(return_value=ret)
with patch.dict(vmctl.__salt__, {'cmd.run_all': mock_cmd}):
res = vmctl.stop('web1')
mock_cmd.assert_called_once_with(['vmctl', 'stop', 'web1'],
output_loglevel='trace', python_shell=False)
self.assertFalse(res['changes'])