mirror of
https://github.com/valitydev/salt.git
synced 2024-11-08 09:23:56 +00:00
Merge pull request #45164 from jasperla/vmctl
New vmctl module to interface with the OpenBSD VMM hypervisor
This commit is contained in:
commit
57b686033a
403
salt/modules/vmctl.py
Normal file
403
salt/modules/vmctl.py
Normal 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
|
237
tests/unit/modules/test_vmctl.py
Normal file
237
tests/unit/modules/test_vmctl.py
Normal 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'])
|
Loading…
Reference in New Issue
Block a user