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