kubevirt: Add new kubevirt_vm module (#50768)

This module is managing virtual machines using KubeVirt.

Signed-off-by: Ondra Machacek <omachace@redhat.com>
pull/52015/head
Ondra Machacek 5 years ago committed by John R Barker
parent 6e409a91ba
commit 797a5218fb

@ -128,6 +128,7 @@ files:
maintainers: $team_google
ignored: supertom
$modules/cloud/google/gc_storage.py: supertom
$modules/cloud/kubevirt/: machacekondra mmazur pkliczewski
$modules/cloud/linode/: $team_linode
$modules/cloud/lxd/: hnakamur
$modules/cloud/memset/: glitchcrab

@ -0,0 +1,193 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2018, KubeVirt Team <@kubevirt>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from collections import defaultdict
from ansible.module_utils.k8s.common import list_dict_str
from ansible.module_utils.k8s.raw import KubernetesRawModule
try:
from openshift import watch
from openshift.helper.exceptions import KubernetesException
except ImportError:
# Handled in k8s common:
pass
API_VERSION = 'kubevirt.io/v1alpha3'
VM_COMMON_ARG_SPEC = {
'name': {},
'force': {
'type': 'bool',
'default': False,
},
'resource_definition': {
'type': list_dict_str,
'aliases': ['definition', 'inline']
},
'src': {
'type': 'path',
},
'namespace': {},
'api_version': {'type': 'str', 'default': API_VERSION, 'aliases': ['api', 'version']},
'merge_type': {'type': 'list', 'choices': ['json', 'merge', 'strategic-merge']},
'wait': {'type': 'bool', 'default': True},
'wait_timeout': {'type': 'int', 'default': 120},
'memory': {'type': 'str'},
'disks': {'type': 'list'},
'labels': {'type': 'dict'},
'interfaces': {'type': 'list'},
'machine_type': {'type': 'str'},
'cloud_init_nocloud': {'type': 'dict'},
}
def virtdict():
"""
This function create dictionary, with defaults to dictionary.
"""
return defaultdict(virtdict)
class KubeVirtRawModule(KubernetesRawModule):
def __init__(self, *args, **kwargs):
self.api_version = API_VERSION
super(KubeVirtRawModule, self).__init__(*args, **kwargs)
@staticmethod
def merge_dicts(x, y):
"""
This function merge two dictionaries, where the first dict has
higher priority in merging two same keys.
"""
for k in set(x.keys()).union(y.keys()):
if k in x and k in y:
if isinstance(x[k], dict) and isinstance(y[k], dict):
yield (k, dict(KubeVirtRawModule.merge_dicts(x[k], y[k])))
else:
yield (k, y[k])
elif k in x:
yield (k, x[k])
else:
yield (k, y[k])
def _create_stream(self, resource, namespace, wait_timeout):
""" Create a stream of events for the object """
w = None
stream = None
try:
w = watch.Watch()
w._api_client = self.client.client
stream = w.stream(resource.get, serialize=False, namespace=namespace, timeout_seconds=wait_timeout)
except KubernetesException as exc:
self.fail_json(msg='Failed to initialize watch: {0}'.format(exc.message))
return w, stream
def get_resource(self, resource):
try:
existing = resource.get(name=self.name, namespace=self.namespace)
except Exception:
existing = None
return existing
def _define_cloud_init(self, cloud_init_nocloud, template_spec):
"""
Takes the user's cloud_init_nocloud parameter and fill it in kubevirt
API strucuture. The name of the volume is hardcoded to ansiblecloudinitvolume
and the name for disk is hardcoded to ansiblecloudinitdisk.
"""
if cloud_init_nocloud:
if not template_spec['volumes']:
template_spec['volumes'] = []
if not template_spec['domain']['devices']['disks']:
template_spec['domain']['devices']['disks'] = []
template_spec['volumes'].append({'name': 'ansiblecloudinitvolume', 'cloudInitNoCloud': cloud_init_nocloud})
template_spec['domain']['devices']['disks'].append({
'name': 'ansiblecloudinitdisk',
'volumeName': 'ansiblecloudinitvolume',
'disk': {'bus': 'virtio'},
})
def _define_interfaces(self, interfaces, template_spec):
"""
Takes interfaces parameter of Ansible and create kubevirt API interfaces
and networks strucutre out from it.
"""
if interfaces:
# Extract interfaces k8s specification from interfaces list passed to Ansible:
spec_interfaces = []
for i in interfaces:
spec_interfaces.append(dict((k, v) for k, v in i.items() if k != 'network'))
template_spec['domain']['devices']['interfaces'] = spec_interfaces
# Extract networks k8s specification from interfaces list passed to Ansible:
spec_networks = []
for i in interfaces:
net = i['network']
net['name'] = i['name']
spec_networks.append(net)
template_spec['networks'] = spec_networks
def _define_disks(self, disks, template_spec):
"""
Takes disks parameter of Ansible and create kubevirt API disks and
volumes strucutre out from it.
"""
if disks:
# Extract k8s specification from disks list passed to Ansible:
spec_disks = []
for d in disks:
spec_disks.append(dict((k, v) for k, v in d.items() if k != 'volume'))
template_spec['domain']['devices']['disks'] = spec_disks
# Extract volumes k8s specification from disks list passed to Ansible:
spec_volumes = []
for d in disks:
volume = d['volume']
volume['name'] = d['name']
spec_volumes.append(volume)
template_spec['volumes'] = spec_volumes
def execute_crud(self, kind, definition, template):
""" Module execution """
self.client = self.get_api_client()
disks = self.params.get('disks', [])
memory = self.params.get('memory')
labels = self.params.get('labels')
interfaces = self.params.get('interfaces')
cloud_init_nocloud = self.params.get('cloud_init_nocloud')
machine_type = self.params.get('machine_type')
template_spec = template['spec']
# Merge additional flat parameters:
if memory:
template_spec['domain']['resources']['requests']['memory'] = memory
if labels:
template['metadata']['labels'] = labels
if machine_type:
template_spec['domain']['machine']['type'] = machine_type
# Define cloud init disk if defined:
self._define_cloud_init(cloud_init_nocloud, template_spec)
# Define disks
self._define_disks(disks, template_spec)
# Define interfaces:
self._define_interfaces(interfaces, template_spec)
# Perform create/absent action:
definition = dict(self.merge_dicts(self.resource_definitions[0], definition))
resource = self.find_resource(kind, self.api_version, fail=True)
definition = self.set_defaults(resource, definition)
return self.perform_action(resource, definition)

@ -0,0 +1,330 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright: (c) 2019, Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
ANSIBLE_METADATA = {'metadata_version': '1.1',
'status': ['preview'],
'supported_by': 'community'}
DOCUMENTATION = '''
---
module: kubevirt_vm
short_description: Manage KubeVirt virtual machine
description:
- Use Openshift Python SDK to manage the state of KubeVirt virtual machines.
version_added: "2.8"
author: KubeVirt Team (@kubevirt)
options:
state:
description:
- Set the virtual machine to either I(present), I(absent), I(running) or I(stopped).
- "I(present) - Create or update virtual machine."
- "I(absent) - Removes virtual machine."
- "I(running) - Create or update virtual machine and run it."
- "I(stopped) - Stops the virtual machine."
default: "present"
choices:
- present
- absent
- running
- stopped
type: str
name:
description:
- Name of the virtual machine.
required: true
type: str
namespace:
description:
- Namespace where the virtual machine exists.
required: true
type: str
ephemeral:
description:
- If (true) ephemeral vitual machine will be created. When destroyed it won't be accessible again.
- Works only with C(state) I(present) and I(absent).
type: bool
default: false
extends_documentation_fragment:
- k8s_auth_options
- k8s_resource_options
- kubevirt_vm_options
- kubevirt_common_options
requirements:
- python >= 2.7
- openshift >= 0.8.2
'''
EXAMPLES = '''
- name: Start virtual machine 'myvm'
kubevirt_vm:
state: running
name: myvm
namespace: vms
- name: Create virtual machine 'myvm' and start it
kubevirt_vm:
state: running
name: myvm
namespace: vms
memory: 64M
disks:
- name: containerdisk
volume:
containerDisk:
image: kubevirt/cirros-container-disk-demo:latest
path: /custom-disk/cirros.img
disk:
bus: virtio
- name: Create virtual machine 'myvm' with multus network interface
kubevirt_vm:
name: myvm
namespace: vms
memory: 512M
interfaces:
- name: default
bridge: {}
network:
pod: {}
- name: mynet
bridge: {}
network:
multus:
networkName: mynetconf
- name: Combine inline definition with Ansible parameters
kubevirt_vm:
# Kubernetes specification:
definition:
metadata:
labels:
app: galaxy
service: web
origin: vmware
# Ansible parameters:
state: running
name: myvm
namespace: vms
memory: 64M
disks:
- name: containerdisk
volume:
containerDisk:
image: kubevirt/cirros-container-disk-demo:latest
path: /custom-disk/cirros.img
disk:
bus: virtio
- name: Start ephemeral virtual machine 'myvm' and wait to be running
kubevirt_vm:
ephemeral: true
state: running
wait: true
wait_timeout: 180
name: myvm
namespace: vms
memory: 64M
labels:
kubevirt.io/vm: myvm
disks:
- name: containerdisk
volume:
containerDisk:
image: kubevirt/cirros-container-disk-demo:latest
path: /custom-disk/cirros.img
disk:
bus: virtio
- name: Start fedora vm with cloud init
kubevirt_vm:
state: running
wait: true
name: myvm
namespace: vms
memory: 1024M
cloud_init_nocloud:
userData: |-
password: fedora
chpasswd: { expire: False }
disks:
- name: containerdisk
volume:
containerDisk:
image: kubevirt/fedora-cloud-container-disk-demo:latest
path: /disk/fedora.qcow2
disk:
bus: virtio
- name: Remove virtual machine 'myvm'
kubevirt_vm:
state: absent
name: myvm
namespace: vms
'''
RETURN = '''
kubevirt_vm:
description:
- The virtual machine dictionary specification returned by the API.
- "This dictionary contains all values returned by the KubeVirt API all options
are described here U(https://kubevirt.io/api-reference/master/definitions.html#_v1_virtualmachine)"
returned: success
type: complex
contains: {}
'''
import copy
import traceback
try:
from openshift.dynamic.client import ResourceInstance
except ImportError:
# Handled in module_utils
pass
from ansible.module_utils.k8s.common import AUTH_ARG_SPEC
from ansible.module_utils.kubevirt import (
virtdict,
KubeVirtRawModule,
VM_COMMON_ARG_SPEC,
API_VERSION,
)
VM_ARG_SPEC = {
'ephemeral': {'type': 'bool', 'default': False},
'state': {
'type': 'str',
'choices': [
'present', 'absent', 'running', 'stopped'
],
'default': 'present'
},
}
class KubeVirtVM(KubeVirtRawModule):
@property
def argspec(self):
""" argspec property builder """
argument_spec = copy.deepcopy(AUTH_ARG_SPEC)
argument_spec.update(VM_COMMON_ARG_SPEC)
argument_spec.update(VM_ARG_SPEC)
return argument_spec
def _manage_state(self, running, resource, existing, wait, wait_timeout):
definition = {'metadata': {'name': self.name, 'namespace': self.namespace}, 'spec': {'running': running}}
self.patch_resource(resource, definition, existing, self.name, self.namespace, merge_type='merge')
if wait:
resource = self.find_resource('VirtualMachineInstance', self.api_version, fail=True)
w, stream = self._create_stream(resource, self.namespace, wait_timeout)
if wait and stream is not None:
self._read_stream(resource, w, stream, self.name, running)
def _read_stream(self, resource, watcher, stream, name, running):
""" Wait for ready_replicas to equal the requested number of replicas. """
for event in stream:
if event.get('object'):
obj = ResourceInstance(resource, event['object'])
if running:
if obj.metadata.name == name and hasattr(obj, 'status'):
phase = getattr(obj.status, 'phase', None)
if phase:
if phase == 'Running' and running:
watcher.stop()
return
else:
# TODO: wait for stopped state:
watcher.stop()
return
self.fail_json(msg="Error waiting for virtual machine. Try a higher wait_timeout value. %s" % obj.to_dict())
def manage_state(self, state):
wait = self.params.get('wait')
wait_timeout = self.params.get('wait_timeout')
resource_version = self.params.get('resource_version')
resource_vm = self.find_resource('VirtualMachine', self.api_version)
existing = self.get_resource(resource_vm)
if resource_version and resource_version != existing.metadata.resourceVersion:
return False
existing_running = False
resource_vmi = self.find_resource('VirtualMachineInstance', self.api_version)
existing_running_vmi = self.get_resource(resource_vmi)
if existing_running_vmi and hasattr(existing_running_vmi.status, 'phase'):
existing_running = existing_running_vmi.status.phase == 'Running'
if state == 'running':
if existing_running:
return False
else:
self._manage_state(True, resource_vm, existing, wait, wait_timeout)
return True
elif state == 'stopped':
if not existing_running:
return False
else:
self._manage_state(False, resource_vm, existing, wait, wait_timeout)
return True
def execute_module(self):
# Parse parameters specific for this module:
definition = virtdict()
ephemeral = self.params.get('ephemeral')
state = self.params.get('state')
if not ephemeral:
definition['spec']['running'] = state == 'running'
# Execute the CURD of VM:
template = definition['spec']['template']
kind = 'VirtualMachineInstance' if ephemeral else 'VirtualMachine'
result = self.execute_crud(kind, definition, template)
changed = result['changed']
# Manage state of the VM:
if state in ['running', 'stopped']:
if not self.check_mode:
ret = self.manage_state(state)
changed = changed or ret
# Return from the module:
self.exit_json(**{
'changed': changed,
'kubevirt_vm': result.pop('result'),
'result': result,
})
def main():
module = KubeVirtVM()
try:
module.api_version = API_VERSION
module.execute_module()
except Exception as e:
module.fail_json(msg=str(e), exception=traceback.format_exc())
if __name__ == '__main__':
main()

@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2018, KubeVirt Team <@kubevirt>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
class ModuleDocFragment(object):
# Standard oVirt documentation fragment
DOCUMENTATION = '''
options:
wait:
description:
- "I(True) if the module should wait for the resource to get into desired state."
default: true
type: bool
force:
description:
- If set to C(True), and I(state) is C(present), an existing object will be replaced.
default: false
type: bool
wait_timeout:
description:
- "The amount of time in seconds the module should wait for the resource to get into desired state."
default: 120
type: int
api_version:
description:
- "Specify the API version to be used."
type: str
default: kubevirt.io/v1alpha3
aliases:
- api
- version
memory:
description:
- "The amount of memory to be requested by virtual machine."
- "For example 1024Mi."
type: str
machine_type:
description:
- QEMU machine type is the actual chipset of the virtual machine.
type: str
merge_type:
description:
- Whether to override the default patch merge approach with a specific type. By default, the strategic
merge will typically be used.
- For example, Custom Resource Definitions typically aren't updatable by the usual strategic merge. You may
want to use C(merge) if you see "strategic merge patch format is not supported"
- See U(https://kubernetes.io/docs/tasks/run-application/update-api-object-kubectl-patch/#use-a-json-merge-patch-to-update-a-deployment)
- Requires openshift >= 0.6.2
- If more than one merge_type is given, the merge_types will be tried in order
- If openshift >= 0.6.2, this defaults to C(['strategic-merge', 'merge']), which is ideal for using the same parameters
on resource kinds that combine Custom Resources and built-in resources. For openshift < 0.6.2, the default
is simply C(strategic-merge).
choices:
- json
- merge
- strategic-merge
type: list
requirements:
- python >= 2.7
- openshift >= 0.8.2
notes:
- "In order to use this module you have to install Openshift Python SDK.
To ensure it's installed with correct version you can create the following task:
I(pip: name=openshift version=0.8.2)"
'''

@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2018, KubeVirt Team <@kubevirt>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
class ModuleDocFragment(object):
# Standard oVirt documentation fragment
DOCUMENTATION = '''
options:
disks:
description:
- List of dictionaries which specify disks of the virtual machine.
- "A disk can be made accessible via four different types: I(disk), I(lun), I(cdrom), I(floppy)."
- "All possible configuration options are available in U(https://kubevirt.io/api-reference/master/definitions.html#_v1_disk)"
- Each disk must have specified a I(volume) that declares which volume type of the disk
All possible configuration options of volume are available in U(https://kubevirt.io/api-reference/master/definitions.html#_v1_volume).
type: list
labels:
description:
- Labels are key/value pairs that are attached to virtual machines. Labels are intended to be used to
specify identifying attributes of virtual machines that are meaningful and relevant to users, but do not directly
imply semantics to the core system. Labels can be used to organize and to select subsets of virtual machines.
Labels can be attached to virtual machines at creation time and subsequently added and modified at any time.
- More on labels that are used for internal implementation U(https://kubevirt.io/user-guide/#/misc/annotations_and_labels)
type: dict
interfaces:
description:
- An interface defines a virtual network interface of a virtual machine (also called a frontend).
- All possible configuration options interfaces are available in U(https://kubevirt.io/api-reference/master/definitions.html#_v1_interface)
- Each interface must have specified a I(network) that declares which logical or physical device it is connected to (also called as backend).
All possible configuration options of network are available in U(https://kubevirt.io/api-reference/master/definitions.html#_v1_network).
type: list
cloud_init_nocloud:
description:
- "Represents a cloud-init NoCloud user-data source. The NoCloud data will be added
as a disk to the virtual machine. A proper cloud-init installation is required inside the guest.
More information U(https://kubevirt.io/api-reference/master/definitions.html#_v1_cloudinitnocloudsource)"
type: dict
'''

@ -43,3 +43,6 @@ linode_api4 ; python_version > '2.6' # APIv4
# requirement for the gitlab module
python-gitlab
httmock
# requirment for kubevirt modules
openshift ; python_version >= '2.7'

@ -0,0 +1,153 @@
import json
import pytest
from units.compat.mock import patch, MagicMock
from ansible.module_utils import basic
from ansible.module_utils._text import to_bytes
from ansible.module_utils.k8s.common import K8sAnsibleMixin
from ansible.module_utils.k8s.raw import KubernetesRawModule
from ansible.modules.cloud.kubevirt import kubevirt_vm as mymodule
openshiftdynamic = pytest.importorskip("openshift.dynamic", minversion="0.6.2")
helpexceptions = pytest.importorskip("openshift.helper.exceptions", minversion="0.6.2")
KIND = 'VirtulMachine'
RESOURCE_DEFAULT_ARGS = {'api_version': 'v1', 'group': 'kubevirt.io',
'prefix': 'apis', 'namespaced': True}
def set_module_args(args):
args = json.dumps({'ANSIBLE_MODULE_ARGS': args})
basic._ANSIBLE_ARGS = to_bytes(args)
class AnsibleExitJson(Exception):
"""Exception class to be raised by module.exit_json and caught
by the test case"""
def __init__(self, **kwargs):
for k in kwargs:
setattr(self, k, kwargs[k])
def __getitem__(self, attr):
return getattr(self, attr)
class AnsibleFailJson(Exception):
"""Exception class to be raised by module.fail_json and caught
by the test case"""
def __init__(self, **kwargs):
for k in kwargs:
setattr(self, k, kwargs[k])
def __getitem__(self, attr):
return getattr(self, attr)
def exit_json(*args, **kwargs):
if 'changed' not in kwargs:
kwargs['changed'] = False
raise AnsibleExitJson(**kwargs)
def fail_json(*args, **kwargs):
raise AnsibleFailJson(**kwargs)
@pytest.fixture(autouse=True)
def setup_mixtures(self, monkeypatch):
monkeypatch.setattr(
KubernetesRawModule, "exit_json", exit_json)
monkeypatch.setattr(
KubernetesRawModule, "fail_json", fail_json)
# Create mock methods in Resource directly, otherwise dyn client
# tries binding those to corresponding methods in DynamicClient
# (with partial()), which is more problematic to intercept
openshiftdynamic.Resource.get = MagicMock()
openshiftdynamic.Resource.create = MagicMock()
openshiftdynamic.Resource.delete = MagicMock()
openshiftdynamic.Resource.patch = MagicMock()
# Globally mock some methods, since all tests will use this
K8sAnsibleMixin.get_api_client = MagicMock()
K8sAnsibleMixin.get_api_client.return_value = None
K8sAnsibleMixin.find_resource = MagicMock()
def test_vm_multus_creation(self):
args = dict(
state='present', name='testvm',
namespace='vms', api_version='v1',
interfaces=[
{'bridge': {}, 'name': 'default', 'network': {'pod': {}}},
{'bridge': {}, 'name': 'mynet', 'network': {'multus': {'networkName': 'mynet'}}},
],
wait=False,
)
set_module_args(args)
openshiftdynamic.Resource.get.return_value = None
resource_args = dict(kind=KIND, **RESOURCE_DEFAULT_ARGS)
K8sAnsibleMixin.find_resource.return_value = openshiftdynamic.Resource(**resource_args)
# Actual test:
with pytest.raises(AnsibleExitJson) as result:
mymodule.KubeVirtVM().execute_module()
assert result.value['changed']
assert result.value['result']['method'] == 'create'
@pytest.mark.parametrize("_wait", (False, True))
def test_resource_absent(self, _wait):
# Desired state:
args = dict(
state='absent', name='testvmi',
namespace='vms', api_version='v1',
wait=_wait,
)
set_module_args(args)
openshiftdynamic.Resource.get.return_value = None
resource_args = dict(kind=KIND, **RESOURCE_DEFAULT_ARGS)
K8sAnsibleMixin.find_resource.return_value = openshiftdynamic.Resource(**resource_args)
# Actual test:
with pytest.raises(AnsibleExitJson) as result:
mymodule.KubeVirtVM().execute_module()
assert result.value['result']['method'] == 'delete'
@patch('openshift.watch.Watch')
def test_stream_creation(self, mock_watch):
# Desired state:
args = dict(
state='running', name='testvmi', namespace='vms',
api_version='v1', wait=True,
)
set_module_args(args)
# Actual test:
mock_watch.side_effect = helpexceptions.KubernetesException("Test", value=42)
with pytest.raises(AnsibleFailJson):
mymodule.KubeVirtVM().execute_module()
def test_simple_merge_dicts(self):
dict1 = {'labels': {'label1': 'value'}}
dict2 = {'labels': {'label2': 'value'}}
dict3 = json.dumps({'labels': {'label1': 'value', 'label2': 'value'}}, sort_keys=True)
assert dict3 == json.dumps(dict(mymodule.KubeVirtVM.merge_dicts(dict1, dict2)), sort_keys=True)
def test_simple_multi_merge_dicts(self):
dict1 = {'labels': {'label1': 'value', 'label3': 'value'}}
dict2 = {'labels': {'label2': 'value'}}
dict3 = json.dumps({'labels': {'label1': 'value', 'label2': 'value', 'label3': 'value'}}, sort_keys=True)
assert dict3 == json.dumps(dict(mymodule.KubeVirtVM.merge_dicts(dict1, dict2)), sort_keys=True)
def test_double_nested_merge_dicts(self):
dict1 = {'metadata': {'labels': {'label1': 'value', 'label3': 'value'}}}
dict2 = {'metadata': {'labels': {'label2': 'value'}}}
dict3 = json.dumps({'metadata': {'labels': {'label1': 'value', 'label2': 'value', 'label3': 'value'}}}, sort_keys=True)
assert dict3 == json.dumps(dict(mymodule.KubeVirtVM.merge_dicts(dict1, dict2)), sort_keys=True)
Loading…
Cancel
Save