1# -*- coding: utf-8 -*-
2#
3
4# Copyright (c) 2018, KubeVirt Team <@kubevirt>
5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
6
7from collections import defaultdict
8from distutils.version import Version
9
10from ansible.module_utils.common import dict_transformations
11from ansible.module_utils.common._collections_compat import Sequence
12from ansible.module_utils.k8s.common import list_dict_str
13from ansible.module_utils.k8s.raw import KubernetesRawModule
14
15import copy
16import re
17
18MAX_SUPPORTED_API_VERSION = 'v1alpha3'
19API_GROUP = 'kubevirt.io'
20
21
22# Put all args that (can) modify 'spec:' here:
23VM_SPEC_DEF_ARG_SPEC = {
24    'resource_definition': {
25        'type': 'dict',
26        'aliases': ['definition', 'inline']
27    },
28    'memory': {'type': 'str'},
29    'memory_limit': {'type': 'str'},
30    'cpu_cores': {'type': 'int'},
31    'disks': {'type': 'list'},
32    'labels': {'type': 'dict'},
33    'interfaces': {'type': 'list'},
34    'machine_type': {'type': 'str'},
35    'cloud_init_nocloud': {'type': 'dict'},
36    'bootloader': {'type': 'str'},
37    'smbios_uuid': {'type': 'str'},
38    'cpu_model': {'type': 'str'},
39    'headless': {'type': 'str'},
40    'hugepage_size': {'type': 'str'},
41    'tablets': {'type': 'list'},
42    'cpu_limit': {'type': 'int'},
43    'cpu_shares': {'type': 'int'},
44    'cpu_features': {'type': 'list'},
45    'affinity': {'type': 'dict'},
46    'anti_affinity': {'type': 'dict'},
47    'node_affinity': {'type': 'dict'},
48}
49# And other common args go here:
50VM_COMMON_ARG_SPEC = {
51    'name': {'required': True},
52    'namespace': {'required': True},
53    'hostname': {'type': 'str'},
54    'subdomain': {'type': 'str'},
55    'state': {
56        'default': 'present',
57        'choices': ['present', 'absent'],
58    },
59    'force': {
60        'type': 'bool',
61        'default': False,
62    },
63    'merge_type': {'type': 'list', 'choices': ['json', 'merge', 'strategic-merge']},
64    'wait': {'type': 'bool', 'default': True},
65    'wait_timeout': {'type': 'int', 'default': 120},
66    'wait_sleep': {'type': 'int', 'default': 5},
67}
68VM_COMMON_ARG_SPEC.update(VM_SPEC_DEF_ARG_SPEC)
69
70
71def virtdict():
72    """
73    This function create dictionary, with defaults to dictionary.
74    """
75    return defaultdict(virtdict)
76
77
78class KubeAPIVersion(Version):
79    component_re = re.compile(r'(\d+ | [a-z]+)', re.VERBOSE)
80
81    def __init__(self, vstring=None):
82        if vstring:
83            self.parse(vstring)
84
85    def parse(self, vstring):
86        self.vstring = vstring
87        components = [x for x in self.component_re.split(vstring) if x]
88        for i, obj in enumerate(components):
89            try:
90                components[i] = int(obj)
91            except ValueError:
92                pass
93
94        errmsg = "version '{0}' does not conform to kubernetes api versioning guidelines".format(vstring)
95        c = components
96
97        if len(c) not in (2, 4) or c[0] != 'v' or not isinstance(c[1], int):
98            raise ValueError(errmsg)
99        if len(c) == 4 and (c[2] not in ('alpha', 'beta') or not isinstance(c[3], int)):
100            raise ValueError(errmsg)
101
102        self.version = components
103
104    def __str__(self):
105        return self.vstring
106
107    def __repr__(self):
108        return "KubeAPIVersion ('{0}')".format(str(self))
109
110    def _cmp(self, other):
111        if isinstance(other, str):
112            other = KubeAPIVersion(other)
113
114        myver = self.version
115        otherver = other.version
116
117        for ver in myver, otherver:
118            if len(ver) == 2:
119                ver.extend(['zeta', 9999])
120
121        if myver == otherver:
122            return 0
123        if myver < otherver:
124            return -1
125        if myver > otherver:
126            return 1
127
128    # python2 compatibility
129    def __cmp__(self, other):
130        return self._cmp(other)
131
132
133class KubeVirtRawModule(KubernetesRawModule):
134    def __init__(self, *args, **kwargs):
135        super(KubeVirtRawModule, self).__init__(*args, **kwargs)
136
137    @staticmethod
138    def merge_dicts(base_dict, merging_dicts):
139        """This function merges a base dictionary with one or more other dictionaries.
140        The base dictionary takes precedence when there is a key collision.
141        merging_dicts can be a dict or a list or tuple of dicts.  In the latter case, the
142        dictionaries at the front of the list have higher precedence over the ones at the end.
143        """
144        if not merging_dicts:
145            merging_dicts = ({},)
146
147        if not isinstance(merging_dicts, Sequence):
148            merging_dicts = (merging_dicts,)
149
150        new_dict = {}
151        for d in reversed(merging_dicts):
152            new_dict = dict_transformations.dict_merge(new_dict, d)
153
154        new_dict = dict_transformations.dict_merge(new_dict, base_dict)
155
156        return new_dict
157
158    def get_resource(self, resource):
159        try:
160            existing = resource.get(name=self.name, namespace=self.namespace)
161        except Exception:
162            existing = None
163
164        return existing
165
166    def _define_datavolumes(self, datavolumes, spec):
167        """
168        Takes datavoulmes parameter of Ansible and create kubevirt API datavolumesTemplateSpec
169        structure from it
170        """
171        if not datavolumes:
172            return
173
174        spec['dataVolumeTemplates'] = []
175        for dv in datavolumes:
176            # Add datavolume to datavolumetemplates spec:
177            dvt = virtdict()
178            dvt['metadata']['name'] = dv.get('name')
179            dvt['spec']['pvc'] = {
180                'accessModes': dv.get('pvc').get('accessModes'),
181                'resources': {
182                    'requests': {
183                        'storage': dv.get('pvc').get('storage'),
184                    }
185                }
186            }
187            dvt['spec']['source'] = dv.get('source')
188            spec['dataVolumeTemplates'].append(dvt)
189
190            # Add datavolume to disks spec:
191            if not spec['template']['spec']['domain']['devices']['disks']:
192                spec['template']['spec']['domain']['devices']['disks'] = []
193
194            spec['template']['spec']['domain']['devices']['disks'].append(
195                {
196                    'name': dv.get('name'),
197                    'disk': dv.get('disk', {'bus': 'virtio'}),
198                }
199            )
200
201            # Add datavolume to volumes spec:
202            if not spec['template']['spec']['volumes']:
203                spec['template']['spec']['volumes'] = []
204
205            spec['template']['spec']['volumes'].append(
206                {
207                    'dataVolume': {
208                        'name': dv.get('name')
209                    },
210                    'name': dv.get('name'),
211                }
212            )
213
214    def _define_cloud_init(self, cloud_init_nocloud, template_spec):
215        """
216        Takes the user's cloud_init_nocloud parameter and fill it in kubevirt
217        API strucuture. The name for disk is hardcoded to ansiblecloudinitdisk.
218        """
219        if cloud_init_nocloud:
220            if not template_spec['volumes']:
221                template_spec['volumes'] = []
222            if not template_spec['domain']['devices']['disks']:
223                template_spec['domain']['devices']['disks'] = []
224
225            template_spec['volumes'].append({'name': 'ansiblecloudinitdisk', 'cloudInitNoCloud': cloud_init_nocloud})
226            template_spec['domain']['devices']['disks'].append({
227                'name': 'ansiblecloudinitdisk',
228                'disk': {'bus': 'virtio'},
229            })
230
231    def _define_interfaces(self, interfaces, template_spec, defaults):
232        """
233        Takes interfaces parameter of Ansible and create kubevirt API interfaces
234        and networks strucutre out from it.
235        """
236        if not interfaces and defaults and 'interfaces' in defaults:
237            interfaces = copy.deepcopy(defaults['interfaces'])
238            for d in interfaces:
239                d['network'] = defaults['networks'][0]
240
241        if interfaces:
242            # Extract interfaces k8s specification from interfaces list passed to Ansible:
243            spec_interfaces = []
244            for i in interfaces:
245                spec_interfaces.append(
246                    self.merge_dicts(dict((k, v) for k, v in i.items() if k != 'network'), defaults['interfaces'])
247                )
248            if 'interfaces' not in template_spec['domain']['devices']:
249                template_spec['domain']['devices']['interfaces'] = []
250            template_spec['domain']['devices']['interfaces'].extend(spec_interfaces)
251
252            # Extract networks k8s specification from interfaces list passed to Ansible:
253            spec_networks = []
254            for i in interfaces:
255                net = i['network']
256                net['name'] = i['name']
257                spec_networks.append(self.merge_dicts(net, defaults['networks']))
258            if 'networks' not in template_spec:
259                template_spec['networks'] = []
260            template_spec['networks'].extend(spec_networks)
261
262    def _define_disks(self, disks, template_spec, defaults):
263        """
264        Takes disks parameter of Ansible and create kubevirt API disks and
265        volumes strucutre out from it.
266        """
267        if not disks and defaults and 'disks' in defaults:
268            disks = copy.deepcopy(defaults['disks'])
269            for d in disks:
270                d['volume'] = defaults['volumes'][0]
271
272        if disks:
273            # Extract k8s specification from disks list passed to Ansible:
274            spec_disks = []
275            for d in disks:
276                spec_disks.append(
277                    self.merge_dicts(dict((k, v) for k, v in d.items() if k != 'volume'), defaults['disks'])
278                )
279            if 'disks' not in template_spec['domain']['devices']:
280                template_spec['domain']['devices']['disks'] = []
281            template_spec['domain']['devices']['disks'].extend(spec_disks)
282
283            # Extract volumes k8s specification from disks list passed to Ansible:
284            spec_volumes = []
285            for d in disks:
286                volume = d['volume']
287                volume['name'] = d['name']
288                spec_volumes.append(self.merge_dicts(volume, defaults['volumes']))
289            if 'volumes' not in template_spec:
290                template_spec['volumes'] = []
291            template_spec['volumes'].extend(spec_volumes)
292
293    def find_supported_resource(self, kind):
294        results = self.client.resources.search(kind=kind, group=API_GROUP)
295        if not results:
296            self.fail('Failed to find resource {0} in {1}'.format(kind, API_GROUP))
297        sr = sorted(results, key=lambda r: KubeAPIVersion(r.api_version), reverse=True)
298        for r in sr:
299            if KubeAPIVersion(r.api_version) <= KubeAPIVersion(MAX_SUPPORTED_API_VERSION):
300                return r
301        self.fail("API versions {0} are too recent. Max supported is {1}/{2}.".format(
302            str([r.api_version for r in sr]), API_GROUP, MAX_SUPPORTED_API_VERSION))
303
304    def _construct_vm_definition(self, kind, definition, template, params, defaults=None):
305        self.client = self.get_api_client()
306
307        disks = params.get('disks', [])
308        memory = params.get('memory')
309        memory_limit = params.get('memory_limit')
310        cpu_cores = params.get('cpu_cores')
311        cpu_model = params.get('cpu_model')
312        cpu_features = params.get('cpu_features')
313        labels = params.get('labels')
314        datavolumes = params.get('datavolumes')
315        interfaces = params.get('interfaces')
316        bootloader = params.get('bootloader')
317        cloud_init_nocloud = params.get('cloud_init_nocloud')
318        machine_type = params.get('machine_type')
319        headless = params.get('headless')
320        smbios_uuid = params.get('smbios_uuid')
321        hugepage_size = params.get('hugepage_size')
322        tablets = params.get('tablets')
323        cpu_shares = params.get('cpu_shares')
324        cpu_limit = params.get('cpu_limit')
325        node_affinity = params.get('node_affinity')
326        vm_affinity = params.get('affinity')
327        vm_anti_affinity = params.get('anti_affinity')
328        hostname = params.get('hostname')
329        subdomain = params.get('subdomain')
330        template_spec = template['spec']
331
332        # Merge additional flat parameters:
333        if memory:
334            template_spec['domain']['resources']['requests']['memory'] = memory
335
336        if cpu_shares:
337            template_spec['domain']['resources']['requests']['cpu'] = cpu_shares
338
339        if cpu_limit:
340            template_spec['domain']['resources']['limits']['cpu'] = cpu_limit
341
342        if tablets:
343            for tablet in tablets:
344                tablet['type'] = 'tablet'
345            template_spec['domain']['devices']['inputs'] = tablets
346
347        if memory_limit:
348            template_spec['domain']['resources']['limits']['memory'] = memory_limit
349
350        if hugepage_size is not None:
351            template_spec['domain']['memory']['hugepages']['pageSize'] = hugepage_size
352
353        if cpu_features is not None:
354            template_spec['domain']['cpu']['features'] = cpu_features
355
356        if cpu_cores is not None:
357            template_spec['domain']['cpu']['cores'] = cpu_cores
358
359        if cpu_model:
360            template_spec['domain']['cpu']['model'] = cpu_model
361
362        if labels:
363            template['metadata']['labels'] = self.merge_dicts(labels, template['metadata']['labels'])
364
365        if machine_type:
366            template_spec['domain']['machine']['type'] = machine_type
367
368        if bootloader:
369            template_spec['domain']['firmware']['bootloader'] = {bootloader: {}}
370
371        if smbios_uuid:
372            template_spec['domain']['firmware']['uuid'] = smbios_uuid
373
374        if headless is not None:
375            template_spec['domain']['devices']['autoattachGraphicsDevice'] = not headless
376
377        if vm_affinity or vm_anti_affinity:
378            vms_affinity = vm_affinity or vm_anti_affinity
379            affinity_name = 'podAffinity' if vm_affinity else 'podAntiAffinity'
380            for affinity in vms_affinity.get('soft', []):
381                if not template_spec['affinity'][affinity_name]['preferredDuringSchedulingIgnoredDuringExecution']:
382                    template_spec['affinity'][affinity_name]['preferredDuringSchedulingIgnoredDuringExecution'] = []
383                template_spec['affinity'][affinity_name]['preferredDuringSchedulingIgnoredDuringExecution'].append({
384                    'weight': affinity.get('weight'),
385                    'podAffinityTerm': {
386                        'labelSelector': {
387                            'matchExpressions': affinity.get('term').get('match_expressions'),
388                        },
389                        'topologyKey': affinity.get('topology_key'),
390                    },
391                })
392            for affinity in vms_affinity.get('hard', []):
393                if not template_spec['affinity'][affinity_name]['requiredDuringSchedulingIgnoredDuringExecution']:
394                    template_spec['affinity'][affinity_name]['requiredDuringSchedulingIgnoredDuringExecution'] = []
395                template_spec['affinity'][affinity_name]['requiredDuringSchedulingIgnoredDuringExecution'].append({
396                    'labelSelector': {
397                        'matchExpressions': affinity.get('term').get('match_expressions'),
398                    },
399                    'topologyKey': affinity.get('topology_key'),
400                })
401
402        if node_affinity:
403            for affinity in node_affinity.get('soft', []):
404                if not template_spec['affinity']['nodeAffinity']['preferredDuringSchedulingIgnoredDuringExecution']:
405                    template_spec['affinity']['nodeAffinity']['preferredDuringSchedulingIgnoredDuringExecution'] = []
406                template_spec['affinity']['nodeAffinity']['preferredDuringSchedulingIgnoredDuringExecution'].append({
407                    'weight': affinity.get('weight'),
408                    'preference': {
409                        'matchExpressions': affinity.get('term').get('match_expressions'),
410                    }
411                })
412            for affinity in node_affinity.get('hard', []):
413                if not template_spec['affinity']['nodeAffinity']['requiredDuringSchedulingIgnoredDuringExecution']['nodeSelectorTerms']:
414                    template_spec['affinity']['nodeAffinity']['requiredDuringSchedulingIgnoredDuringExecution']['nodeSelectorTerms'] = []
415                template_spec['affinity']['nodeAffinity']['requiredDuringSchedulingIgnoredDuringExecution']['nodeSelectorTerms'].append({
416                    'matchExpressions': affinity.get('term').get('match_expressions'),
417                })
418
419        if hostname:
420            template_spec['hostname'] = hostname
421
422        if subdomain:
423            template_spec['subdomain'] = subdomain
424
425        # Define disks
426        self._define_disks(disks, template_spec, defaults)
427
428        # Define cloud init disk if defined:
429        # Note, that this must be called after _define_disks, so the cloud_init
430        # is not first in order and it's not used as boot disk:
431        self._define_cloud_init(cloud_init_nocloud, template_spec)
432
433        # Define interfaces:
434        self._define_interfaces(interfaces, template_spec, defaults)
435
436        # Define datavolumes:
437        self._define_datavolumes(datavolumes, definition['spec'])
438
439        return self.merge_dicts(definition, self.resource_definitions[0])
440
441    def construct_vm_definition(self, kind, definition, template, defaults=None):
442        definition = self._construct_vm_definition(kind, definition, template, self.params, defaults)
443        resource = self.find_supported_resource(kind)
444        definition = self.set_defaults(resource, definition)
445        return resource, definition
446
447    def construct_vm_template_definition(self, kind, definition, template, params):
448        definition = self._construct_vm_definition(kind, definition, template, params)
449        resource = self.find_resource(kind, definition['apiVersion'], fail=True)
450
451        # Set defaults:
452        definition['kind'] = kind
453        definition['metadata']['name'] = params.get('name')
454        definition['metadata']['namespace'] = params.get('namespace')
455
456        return resource, definition
457
458    def execute_crud(self, kind, definition):
459        """ Module execution """
460        resource = self.find_supported_resource(kind)
461        definition = self.set_defaults(resource, definition)
462        return self.perform_action(resource, definition)
463