1# -*- coding: utf-8 -*- 2# (c) Matthias Dellweg (ATIX AG) 2017 3 4# pylint: disable=raise-missing-from 5# pylint: disable=super-with-arguments 6 7from __future__ import absolute_import, division, print_function 8__metaclass__ = type 9 10 11import hashlib 12import json 13import os 14import operator 15import re 16import time 17import traceback 18 19from contextlib import contextmanager 20 21from collections import defaultdict 22from functools import wraps 23 24from ansible.module_utils.basic import AnsibleModule, missing_required_lib, env_fallback 25from ansible.module_utils._text import to_bytes, to_native 26from ansible.module_utils import six 27 28from distutils.version import LooseVersion 29 30try: 31 try: 32 from ansible_collections.theforeman.foreman.plugins.module_utils import _apypie as apypie 33 except ImportError: 34 from plugins.module_utils import _apypie as apypie 35 import requests.exceptions 36 HAS_APYPIE = True 37 inflector = apypie.Inflector() 38except ImportError: 39 HAS_APYPIE = False 40 APYPIE_IMP_ERR = traceback.format_exc() 41 42try: 43 import yaml 44 HAS_PYYAML = True 45except ImportError: 46 HAS_PYYAML = False 47 PYYAML_IMP_ERR = traceback.format_exc() 48 49parameter_foreman_spec = dict( 50 id=dict(invisible=True), 51 name=dict(required=True), 52 value=dict(type='raw', required=True), 53 parameter_type=dict(default='string', choices=['string', 'boolean', 'integer', 'real', 'array', 'hash', 'yaml', 'json']), 54) 55 56parameter_ansible_spec = {k: v for (k, v) in parameter_foreman_spec.items() if k != 'id'} 57 58_PLUGIN_RESOURCES = { 59 'ansible': 'ansible_roles', 60 'discovery': 'discovery_rules', 61 'katello': 'subscriptions', 62 'openscap': 'scap_contents', 63 'remote_execution': 'remote_execution_features', 64 'scc_manager': 'scc_accounts', 65 'snapshot_management': 'snapshots', 66 'templates': 'templates', 67} 68 69ENTITY_KEYS = dict( 70 hostgroups='title', 71 locations='title', 72 operatingsystems='title', 73 # TODO: Organizations should be search by title (as foreman allows nested orgs) but that's not the case ATM. 74 # Applying this will need to record a lot of tests that is out of scope for the moment. 75 # organizations='title', 76 scap_contents='title', 77 users='login', 78) 79 80 81class NoEntity(object): 82 pass 83 84 85def _exception2fail_json(msg='Generic failure: {0}'): 86 """ 87 Decorator to convert Python exceptions into Ansible errors that can be reported to the user. 88 """ 89 90 def decor(f): 91 @wraps(f) 92 def inner(self, *args, **kwargs): 93 try: 94 return f(self, *args, **kwargs) 95 except Exception as e: 96 err_msg = "{0}: {1}".format(e.__class__.__name__, to_native(e)) 97 self.fail_from_exception(e, msg.format(err_msg)) 98 return inner 99 return decor 100 101 102def _check_patch_needed(introduced_version=None, fixed_version=None, plugins=None): 103 """ 104 Decorator to check whether a specific apidoc patch is required. 105 106 :param introduced_version: The version of Foreman the API bug was introduced. 107 :type introduced_version: str, optional 108 :param fixed_version: The version of Foreman the API bug was fixed. 109 :type fixed_version: str, optional 110 :param plugins: Which plugins are required for this patch. 111 :type plugins: list, optional 112 """ 113 114 def decor(f): 115 @wraps(f) 116 def inner(self, *args, **kwargs): 117 if plugins is not None and not all(self.has_plugin(plugin) for plugin in plugins): 118 return 119 120 if fixed_version is not None and self.foreman_version >= LooseVersion(fixed_version): 121 return 122 123 if introduced_version is not None and self.foreman_version < LooseVersion(introduced_version): 124 return 125 126 return f(self, *args, **kwargs) 127 return inner 128 return decor 129 130 131class KatelloMixin(): 132 """ 133 Katello Mixin to extend a :class:`ForemanAnsibleModule` (or any subclass) to work with Katello entities. 134 135 This includes: 136 137 * add a required ``organization`` parameter to the module 138 * add Katello to the list of required plugins 139 """ 140 141 def __init__(self, **kwargs): 142 foreman_spec = dict( 143 organization=dict(type='entity', required=True), 144 ) 145 foreman_spec.update(kwargs.pop('foreman_spec', {})) 146 required_plugins = kwargs.pop('required_plugins', []) 147 required_plugins.append(('katello', ['*'])) 148 super(KatelloMixin, self).__init__(foreman_spec=foreman_spec, required_plugins=required_plugins, **kwargs) 149 150 151class TaxonomyMixin(object): 152 """ 153 Taxonomy Mixin to extend a :class:`ForemanAnsibleModule` (or any subclass) to work with taxonomic entities. 154 155 This adds optional ``organizations`` and ``locations`` parameters to the module. 156 """ 157 158 def __init__(self, **kwargs): 159 foreman_spec = dict( 160 organizations=dict(type='entity_list'), 161 locations=dict(type='entity_list'), 162 ) 163 foreman_spec.update(kwargs.pop('foreman_spec', {})) 164 super(TaxonomyMixin, self).__init__(foreman_spec=foreman_spec, **kwargs) 165 166 167class ParametersMixinBase(object): 168 """ 169 Base Class for the Parameters Mixins. 170 171 Provides a function to verify no duplicate parameters are set. 172 """ 173 174 def validate_parameters(self): 175 parameters = self.foreman_params.get('parameters') 176 if parameters is not None: 177 parameter_names = [param['name'] for param in parameters] 178 duplicate_params = set([x for x in parameter_names if parameter_names.count(x) > 1]) 179 if duplicate_params: 180 self.fail_json(msg="There are duplicate keys in 'parameters': {0}.".format(duplicate_params)) 181 182 183class ParametersMixin(ParametersMixinBase): 184 """ 185 Parameters Mixin to extend a :class:`ForemanAnsibleModule` (or any subclass) to work with entities that support parameters. 186 187 This allows to submit parameters to Foreman in the same request as modifying the main entity, thus making the parameters 188 available to any action that might be triggered when the entity is saved. 189 190 By default, parametes are submited to the API using the ``<entity_name>_parameters_attributes`` key. 191 If you need to override this, set the ``PARAMETERS_FLAT_NAME`` attribute to the key that shall be used instead. 192 193 This adds optional ``parameters`` parameter to the module. It also enhances the ``run()`` method to properly handle the 194 provided parameters. 195 """ 196 197 def __init__(self, **kwargs): 198 self.entity_name = kwargs.pop('entity_name', self.entity_name_from_class) 199 parameters_flat_name = getattr(self, "PARAMETERS_FLAT_NAME", None) or '{0}_parameters_attributes'.format(self.entity_name) 200 foreman_spec = dict( 201 parameters=dict(type='list', elements='dict', options=parameter_ansible_spec, flat_name=parameters_flat_name), 202 ) 203 foreman_spec.update(kwargs.pop('foreman_spec', {})) 204 super(ParametersMixin, self).__init__(foreman_spec=foreman_spec, **kwargs) 205 206 self.validate_parameters() 207 208 def run(self, **kwargs): 209 entity = self.lookup_entity('entity') 210 if not self.desired_absent: 211 if entity and 'parameters' in entity: 212 entity['parameters'] = parameters_list_to_str_list(entity['parameters']) 213 parameters = self.foreman_params.get('parameters') 214 if parameters is not None: 215 self.foreman_params['parameters'] = parameters_list_to_str_list(parameters) 216 217 return super(ParametersMixin, self).run(**kwargs) 218 219 220class NestedParametersMixin(ParametersMixinBase): 221 """ 222 Nested Parameters Mixin to extend a :class:`ForemanAnsibleModule` (or any subclass) to work with entities that support parameters, 223 but require them to be managed in separate API requests. 224 225 This adds optional ``parameters`` parameter to the module. It also enhances the ``run()`` method to properly handle the 226 provided parameters. 227 """ 228 229 def __init__(self, **kwargs): 230 foreman_spec = dict( 231 parameters=dict(type='nested_list', foreman_spec=parameter_foreman_spec), 232 ) 233 foreman_spec.update(kwargs.pop('foreman_spec', {})) 234 super(NestedParametersMixin, self).__init__(foreman_spec=foreman_spec, **kwargs) 235 236 self.validate_parameters() 237 238 def run(self, **kwargs): 239 new_entity = super(NestedParametersMixin, self).run(**kwargs) 240 if new_entity: 241 scope = {'{0}_id'.format(self.entity_name): new_entity['id']} 242 self.ensure_scoped_parameters(scope) 243 return new_entity 244 245 def ensure_scoped_parameters(self, scope): 246 parameters = self.foreman_params.get('parameters') 247 if parameters is not None: 248 entity = self.lookup_entity('entity') 249 if self.state == 'present' or (self.state == 'present_with_defaults' and entity is None): 250 if entity: 251 current_parameters = {parameter['name']: parameter for parameter in self.list_resource('parameters', params=scope)} 252 else: 253 current_parameters = {} 254 desired_parameters = {parameter['name']: parameter for parameter in parameters} 255 256 for name in desired_parameters: 257 desired_parameter = desired_parameters[name] 258 desired_parameter['value'] = parameter_value_to_str(desired_parameter['value'], desired_parameter['parameter_type']) 259 current_parameter = current_parameters.pop(name, None) 260 if current_parameter: 261 if 'parameter_type' not in current_parameter: 262 current_parameter['parameter_type'] = 'string' 263 current_parameter['value'] = parameter_value_to_str(current_parameter['value'], current_parameter['parameter_type']) 264 self.ensure_entity( 265 'parameters', desired_parameter, current_parameter, state="present", foreman_spec=parameter_foreman_spec, params=scope) 266 for current_parameter in current_parameters.values(): 267 self.ensure_entity( 268 'parameters', None, current_parameter, state="absent", foreman_spec=parameter_foreman_spec, params=scope) 269 270 271class HostMixin(ParametersMixin): 272 """ 273 Host Mixin to extend a :class:`ForemanAnsibleModule` (or any subclass) to work with host-related entities (Hosts, Hostgroups). 274 275 This adds many optional parameters that are specific to Hosts and Hostgroups to the module. 276 It also includes :class:`ParametersMixin`. 277 """ 278 279 def __init__(self, **kwargs): 280 foreman_spec = dict( 281 compute_resource=dict(type='entity'), 282 compute_profile=dict(type='entity'), 283 domain=dict(type='entity'), 284 subnet=dict(type='entity'), 285 subnet6=dict(type='entity', resource_type='subnets'), 286 root_pass=dict(no_log=True), 287 realm=dict(type='entity'), 288 architecture=dict(type='entity'), 289 operatingsystem=dict(type='entity'), 290 medium=dict(aliases=['media'], type='entity'), 291 ptable=dict(type='entity'), 292 pxe_loader=dict(choices=['PXELinux BIOS', 'PXELinux UEFI', 'Grub UEFI', 'Grub2 BIOS', 'Grub2 ELF', 293 'Grub2 UEFI', 'Grub2 UEFI SecureBoot', 'Grub2 UEFI HTTP', 'Grub2 UEFI HTTPS', 294 'Grub2 UEFI HTTPS SecureBoot', 'iPXE Embedded', 'iPXE UEFI HTTP', 'iPXE Chain BIOS', 'iPXE Chain UEFI', 'None']), 295 environment=dict(type='entity'), 296 puppetclasses=dict(type='entity_list', resolve=False), 297 config_groups=dict(type='entity_list'), 298 puppet_proxy=dict(type='entity', resource_type='smart_proxies'), 299 puppet_ca_proxy=dict(type='entity', resource_type='smart_proxies'), 300 openscap_proxy=dict(type='entity', resource_type='smart_proxies'), 301 content_source=dict(type='entity', scope=['organization'], resource_type='smart_proxies'), 302 lifecycle_environment=dict(type='entity', scope=['organization']), 303 kickstart_repository=dict(type='entity', scope=['organization'], optional_scope=['lifecycle_environment', 'content_view'], 304 resource_type='repositories'), 305 content_view=dict(type='entity', scope=['organization'], optional_scope=['lifecycle_environment']), 306 activation_keys=dict(no_log=False), 307 ) 308 foreman_spec.update(kwargs.pop('foreman_spec', {})) 309 required_plugins = kwargs.pop('required_plugins', []) + [ 310 ('katello', ['activation_keys', 'content_source', 'lifecycle_environment', 'kickstart_repository', 'content_view']), 311 ('openscap', ['openscap_proxy']), 312 ] 313 mutually_exclusive = kwargs.pop('mutually_exclusive', []) + [['medium', 'kickstart_repository']] 314 super(HostMixin, self).__init__(foreman_spec=foreman_spec, required_plugins=required_plugins, mutually_exclusive=mutually_exclusive, **kwargs) 315 316 def run(self, **kwargs): 317 entity = self.lookup_entity('entity') 318 319 if not self.desired_absent: 320 if 'activation_keys' in self.foreman_params: 321 if 'parameters' not in self.foreman_params: 322 parameters = [param for param in (entity or {}).get('parameters', []) if param['name'] != 'kt_activation_keys'] 323 else: 324 parameters = self.foreman_params['parameters'] 325 ak_param = {'name': 'kt_activation_keys', 'parameter_type': 'string', 'value': self.foreman_params.pop('activation_keys')} 326 self.foreman_params['parameters'] = parameters + [ak_param] 327 elif 'parameters' in self.foreman_params and entity is not None: 328 current_ak_param = next((param for param in entity.get('parameters') if param['name'] == 'kt_activation_keys'), None) 329 desired_ak_param = next((param for param in self.foreman_params['parameters'] if param['name'] == 'kt_activation_keys'), None) 330 if current_ak_param and desired_ak_param is None: 331 self.foreman_params['parameters'].append(current_ak_param) 332 333 self.validate_parameters() 334 335 return super(HostMixin, self).run(**kwargs) 336 337 338class ForemanAnsibleModule(AnsibleModule): 339 """ Baseclass for all foreman related Ansible modules. 340 It handles connection parameters and adds the concept of the `foreman_spec`. 341 This adds automatic entities resolution based on provided attributes/ sub entities options. 342 343 It adds the following options to foreman_spec 'entity' and 'entity_list' types: 344 345 * search_by (str): Field used to search the sub entity. Defaults to 'name' unless `parent` was set, in which case it defaults to `title`. 346 * search_operator (str): Operator used to search the sub entity. Defaults to '='. For fuzzy search use '~'. 347 * resource_type (str): Resource type used to build API resource PATH. Defaults to pluralized entity key. 348 * resolve (boolean): Defaults to 'True'. If set to false, the sub entity will not be resolved automatically 349 * ensure (boolean): Defaults to 'True'. If set to false, it will be removed before sending data to the foreman server. 350 """ 351 352 def __init__(self, **kwargs): 353 # State recording for changed and diff reporting 354 self._changed = False 355 self._before = defaultdict(list) 356 self._after = defaultdict(list) 357 self._after_full = defaultdict(list) 358 359 self.foreman_spec, gen_args = _foreman_spec_helper(kwargs.pop('foreman_spec', {})) 360 argument_spec = dict( 361 server_url=dict(required=True, fallback=(env_fallback, ['FOREMAN_SERVER_URL', 'FOREMAN_SERVER', 'FOREMAN_URL'])), 362 username=dict(required=True, fallback=(env_fallback, ['FOREMAN_USERNAME', 'FOREMAN_USER'])), 363 password=dict(required=True, no_log=True, fallback=(env_fallback, ['FOREMAN_PASSWORD'])), 364 validate_certs=dict(type='bool', default=True, fallback=(env_fallback, ['FOREMAN_VALIDATE_CERTS'])), 365 ) 366 argument_spec.update(gen_args) 367 argument_spec.update(kwargs.pop('argument_spec', {})) 368 supports_check_mode = kwargs.pop('supports_check_mode', True) 369 370 self.required_plugins = kwargs.pop('required_plugins', []) 371 372 super(ForemanAnsibleModule, self).__init__(argument_spec=argument_spec, supports_check_mode=supports_check_mode, **kwargs) 373 374 aliases = {alias for arg in argument_spec.values() for alias in arg.get('aliases', [])} 375 self.foreman_params = _recursive_dict_without_none(self.params, aliases) 376 377 self.check_requirements() 378 379 self._foremanapi_server_url = self.foreman_params.pop('server_url') 380 self._foremanapi_username = self.foreman_params.pop('username') 381 self._foremanapi_password = self.foreman_params.pop('password') 382 self._foremanapi_validate_certs = self.foreman_params.pop('validate_certs') 383 384 self.task_timeout = 60 385 self.task_poll = 4 386 387 self._thin_default = False 388 self.state = 'undefined' 389 390 @contextmanager 391 def api_connection(self): 392 """ 393 Execute a given code block after connecting to the API. 394 395 When the block has finished, call :func:`exit_json` to report that the module has finished to Ansible. 396 """ 397 398 self.connect() 399 yield 400 self.exit_json() 401 402 @property 403 def changed(self): 404 return self._changed 405 406 def set_changed(self): 407 self._changed = True 408 409 def _patch_host_update(self): 410 _host_methods = self.foremanapi.apidoc['docs']['resources']['hosts']['methods'] 411 412 _host_update = next(x for x in _host_methods if x['name'] == 'update') 413 for param in ['location_id', 'organization_id']: 414 _host_update_taxonomy_param = next(x for x in _host_update['params'] if x['name'] == param) 415 _host_update['params'].remove(_host_update_taxonomy_param) 416 417 @_check_patch_needed(fixed_version='2.0.0') 418 def _patch_templates_resource_name(self): 419 """ 420 Need to support both singular and plural form. 421 Not checking for the templates plugin here, as the check relies on the new name. 422 The resource was made plural per https://projects.theforeman.org/issues/28750 423 """ 424 if 'template' in self.foremanapi.apidoc['docs']['resources']: 425 self.foremanapi.apidoc['docs']['resources']['templates'] = self.foremanapi.apidoc['docs']['resources']['template'] 426 427 @_check_patch_needed(fixed_version='1.23.0') 428 def _patch_location_api(self): 429 """This is a workaround for the broken taxonomies apidoc in foreman. 430 see https://projects.theforeman.org/issues/10359 431 """ 432 433 _location_organizations_parameter = { 434 u'validations': [], 435 u'name': u'organization_ids', 436 u'show': True, 437 u'description': u'\n<p>Organization IDs</p>\n', 438 u'required': False, 439 u'allow_nil': True, 440 u'allow_blank': False, 441 u'full_name': u'location[organization_ids]', 442 u'expected_type': u'array', 443 u'metadata': None, 444 u'validator': u'', 445 } 446 _location_methods = self.foremanapi.apidoc['docs']['resources']['locations']['methods'] 447 448 _location_create = next(x for x in _location_methods if x['name'] == 'create') 449 _location_create_params_location = next(x for x in _location_create['params'] if x['name'] == 'location') 450 _location_create_params_location['params'].append(_location_organizations_parameter) 451 452 _location_update = next(x for x in _location_methods if x['name'] == 'update') 453 _location_update_params_location = next(x for x in _location_update['params'] if x['name'] == 'location') 454 _location_update_params_location['params'].append(_location_organizations_parameter) 455 456 @_check_patch_needed(fixed_version='2.2.0', plugins=['remote_execution']) 457 def _patch_subnet_rex_api(self): 458 """ 459 This is a workaround for the broken subnet apidoc in foreman remote execution. 460 See https://projects.theforeman.org/issues/19086 and https://projects.theforeman.org/issues/30651 461 """ 462 463 _subnet_rex_proxies_parameter = { 464 u'validations': [], 465 u'name': u'remote_execution_proxy_ids', 466 u'show': True, 467 u'description': u'\n<p>Remote Execution Proxy IDs</p>\n', 468 u'required': False, 469 u'allow_nil': True, 470 u'allow_blank': False, 471 u'full_name': u'subnet[remote_execution_proxy_ids]', 472 u'expected_type': u'array', 473 u'metadata': None, 474 u'validator': u'', 475 } 476 _subnet_methods = self.foremanapi.apidoc['docs']['resources']['subnets']['methods'] 477 478 _subnet_create = next(x for x in _subnet_methods if x['name'] == 'create') 479 _subnet_create_params_subnet = next(x for x in _subnet_create['params'] if x['name'] == 'subnet') 480 _subnet_create_params_subnet['params'].append(_subnet_rex_proxies_parameter) 481 482 _subnet_update = next(x for x in _subnet_methods if x['name'] == 'update') 483 _subnet_update_params_subnet = next(x for x in _subnet_update['params'] if x['name'] == 'subnet') 484 _subnet_update_params_subnet['params'].append(_subnet_rex_proxies_parameter) 485 486 @_check_patch_needed(introduced_version='2.1.0', fixed_version='2.3.0') 487 def _patch_subnet_externalipam_group_api(self): 488 """ 489 This is a workaround for the broken subnet apidoc for External IPAM. 490 See https://projects.theforeman.org/issues/30890 491 """ 492 493 _subnet_externalipam_group_parameter = { 494 u'validations': [], 495 u'name': u'externalipam_group', 496 u'show': True, 497 u'description': u'\n<p>External IPAM group - only relevant when IPAM is set to external</p>\n', 498 u'required': False, 499 u'allow_nil': True, 500 u'allow_blank': False, 501 u'full_name': u'subnet[externalipam_group]', 502 u'expected_type': u'string', 503 u'metadata': None, 504 u'validator': u'', 505 } 506 _subnet_methods = self.foremanapi.apidoc['docs']['resources']['subnets']['methods'] 507 508 _subnet_create = next(x for x in _subnet_methods if x['name'] == 'create') 509 _subnet_create_params_subnet = next(x for x in _subnet_create['params'] if x['name'] == 'subnet') 510 _subnet_create_params_subnet['params'].append(_subnet_externalipam_group_parameter) 511 512 _subnet_update = next(x for x in _subnet_methods if x['name'] == 'update') 513 _subnet_update_params_subnet = next(x for x in _subnet_update['params'] if x['name'] == 'subnet') 514 _subnet_update_params_subnet['params'].append(_subnet_externalipam_group_parameter) 515 516 @_check_patch_needed(fixed_version='1.24.0', plugins=['katello']) 517 def _patch_content_uploads_update_api(self): 518 """ 519 This is a workaround for the broken content_uploads update apidoc in Katello. 520 See https://projects.theforeman.org/issues/27590 521 """ 522 523 _content_upload_methods = self.foremanapi.apidoc['docs']['resources']['content_uploads']['methods'] 524 525 _content_upload_update = next(x for x in _content_upload_methods if x['name'] == 'update') 526 _content_upload_update_params_id = next(x for x in _content_upload_update['params'] if x['name'] == 'id') 527 _content_upload_update_params_id['expected_type'] = 'string' 528 529 _content_upload_destroy = next(x for x in _content_upload_methods if x['name'] == 'destroy') 530 _content_upload_destroy_params_id = next(x for x in _content_upload_destroy['params'] if x['name'] == 'id') 531 _content_upload_destroy_params_id['expected_type'] = 'string' 532 533 @_check_patch_needed(plugins=['katello']) 534 def _patch_organization_update_api(self): 535 """ 536 This is a workaround for the broken organization update apidoc in Katello. 537 See https://projects.theforeman.org/issues/27538 538 """ 539 540 _organization_methods = self.foremanapi.apidoc['docs']['resources']['organizations']['methods'] 541 542 _organization_update = next(x for x in _organization_methods if x['name'] == 'update') 543 _organization_update_params_organization = next(x for x in _organization_update['params'] if x['name'] == 'organization') 544 _organization_update_params_organization['required'] = False 545 546 @_check_patch_needed(fixed_version='1.24.0', plugins=['katello']) 547 def _patch_subscription_index_api(self): 548 """ 549 This is a workaround for the broken subscriptions apidoc in Katello. 550 See https://projects.theforeman.org/issues/27575 551 """ 552 553 _subscription_methods = self.foremanapi.apidoc['docs']['resources']['subscriptions']['methods'] 554 555 _subscription_index = next(x for x in _subscription_methods if x['name'] == 'index') 556 _subscription_index_params_organization_id = next(x for x in _subscription_index['params'] if x['name'] == 'organization_id') 557 _subscription_index_params_organization_id['required'] = False 558 559 @_check_patch_needed(fixed_version='1.24.0', plugins=['katello']) 560 def _patch_sync_plan_api(self): 561 """ 562 This is a workaround for the broken sync_plan apidoc in Katello. 563 See https://projects.theforeman.org/issues/27532 564 """ 565 566 _organization_parameter = { 567 u'validations': [], 568 u'name': u'organization_id', 569 u'show': True, 570 u'description': u'\n<p>Filter sync plans by organization name or label</p>\n', 571 u'required': False, 572 u'allow_nil': False, 573 u'allow_blank': False, 574 u'full_name': u'organization_id', 575 u'expected_type': u'numeric', 576 u'metadata': None, 577 u'validator': u'Must be a number.', 578 } 579 580 _sync_plan_methods = self.foremanapi.apidoc['docs']['resources']['sync_plans']['methods'] 581 582 _sync_plan_add_products = next(x for x in _sync_plan_methods if x['name'] == 'add_products') 583 if next((x for x in _sync_plan_add_products['params'] if x['name'] == 'organization_id'), None) is None: 584 _sync_plan_add_products['params'].append(_organization_parameter) 585 586 _sync_plan_remove_products = next(x for x in _sync_plan_methods if x['name'] == 'remove_products') 587 if next((x for x in _sync_plan_remove_products['params'] if x['name'] == 'organization_id'), None) is None: 588 _sync_plan_remove_products['params'].append(_organization_parameter) 589 590 @_check_patch_needed(plugins=['katello']) 591 def _patch_cv_filter_rule_api(self): 592 """ 593 This is a workaround for missing params of CV Filter Rule update controller in Katello. 594 See https://projects.theforeman.org/issues/30908 595 """ 596 597 _content_view_filter_rule_methods = self.foremanapi.apidoc['docs']['resources']['content_view_filter_rules']['methods'] 598 599 _content_view_filter_rule_create = next(x for x in _content_view_filter_rule_methods if x['name'] == 'create') 600 _content_view_filter_rule_update = next(x for x in _content_view_filter_rule_methods if x['name'] == 'update') 601 602 for param_name in ['uuid', 'errata_ids', 'date_type', 'module_stream_ids']: 603 create_param = next((x for x in _content_view_filter_rule_create['params'] if x['name'] == param_name), None) 604 update_param = next((x for x in _content_view_filter_rule_update['params'] if x['name'] == param_name), None) 605 if create_param is not None and update_param is None: 606 _content_view_filter_rule_update['params'].append(create_param) 607 608 def check_requirements(self): 609 if not HAS_APYPIE: 610 self.fail_json(msg=missing_required_lib("requests"), exception=APYPIE_IMP_ERR) 611 612 @_exception2fail_json(msg="Failed to connect to Foreman server: {0}") 613 def connect(self): 614 """ 615 Connect to the Foreman API. 616 617 This will create a new ``apypie.Api`` instance using the provided server information, 618 check that the API is actually reachable (by calling :func:`status`), 619 apply any required patches to the apidoc and ensure the server has all the plugins installed 620 that are required by the module. 621 """ 622 623 self.foremanapi = apypie.Api( 624 uri=self._foremanapi_server_url, 625 username=to_bytes(self._foremanapi_username), 626 password=to_bytes(self._foremanapi_password), 627 api_version=2, 628 verify_ssl=self._foremanapi_validate_certs, 629 ) 630 631 _status = self.status() 632 self.foreman_version = LooseVersion(_status.get('version', '0.0.0')) 633 self.apply_apidoc_patches() 634 self.check_required_plugins() 635 636 def apply_apidoc_patches(self): 637 """ 638 Apply patches to the local apidoc representation. 639 When adding another patch, consider that the endpoint in question may depend on a plugin to be available. 640 If possible, make the patch only execute on specific server/plugin versions. 641 """ 642 643 self._patch_host_update() 644 645 self._patch_templates_resource_name() 646 self._patch_location_api() 647 self._patch_subnet_rex_api() 648 self._patch_subnet_externalipam_group_api() 649 650 # Katello 651 self._patch_content_uploads_update_api() 652 self._patch_organization_update_api() 653 self._patch_subscription_index_api() 654 self._patch_sync_plan_api() 655 self._patch_cv_filter_rule_api() 656 657 @_exception2fail_json(msg="Failed to connect to Foreman server: {0}") 658 def status(self): 659 """ 660 Call the ``status`` API endpoint to ensure the server is reachable. 661 662 :return: The full API response 663 :rtype: dict 664 """ 665 666 return self.foremanapi.resource('home').call('status') 667 668 def _resource(self, resource): 669 if resource not in self.foremanapi.resources: 670 raise Exception("The server doesn't know about {0}, is the right plugin installed?".format(resource)) 671 return self.foremanapi.resource(resource) 672 673 def _resource_call(self, resource, *args, **kwargs): 674 return self._resource(resource).call(*args, **kwargs) 675 676 def _resource_prepare_params(self, resource, action, params): 677 api_action = self._resource(resource).action(action) 678 return api_action.prepare_params(params) 679 680 @_exception2fail_json(msg='Failed to show resource: {0}') 681 def show_resource(self, resource, resource_id, params=None): 682 """ 683 Execute the ``show`` action on an entity. 684 685 :param resource: Plural name of the api resource to show 686 :type resource: str 687 :param resource_id: The ID of the entity to show 688 :type resource_id: int 689 :param params: Lookup parameters (i.e. parent_id for nested entities) 690 :type params: Union[dict,None], optional 691 """ 692 693 if params is None: 694 params = {} 695 else: 696 params = params.copy() 697 698 params['id'] = resource_id 699 700 params = self._resource_prepare_params(resource, 'show', params) 701 702 return self._resource_call(resource, 'show', params) 703 704 @_exception2fail_json(msg='Failed to list resource: {0}') 705 def list_resource(self, resource, search=None, params=None): 706 """ 707 Execute the ``index`` action on an resource. 708 709 :param resource: Plural name of the api resource to show 710 :type resource: str 711 :param search: Search string as accepted by the API to limit the results 712 :type search: str, optional 713 :param params: Lookup parameters (i.e. parent_id for nested entities) 714 :type params: Union[dict,None], optional 715 """ 716 717 if params is None: 718 params = {} 719 else: 720 params = params.copy() 721 722 if search is not None: 723 params['search'] = search 724 params['per_page'] = 2 << 31 725 726 params = self._resource_prepare_params(resource, 'index', params) 727 728 return self._resource_call(resource, 'index', params)['results'] 729 730 def find_resource(self, resource, search, params=None, failsafe=False, thin=None): 731 list_params = {} 732 if params is not None: 733 list_params.update(params) 734 if thin is None: 735 thin = self._thin_default 736 list_params['thin'] = thin 737 results = self.list_resource(resource, search, list_params) 738 if len(results) == 1: 739 result = results[0] 740 elif failsafe: 741 result = None 742 else: 743 if len(results) > 1: 744 error_msg = "too many ({0})".format(len(results)) 745 else: 746 error_msg = "no" 747 self.fail_json(msg="Found {0} results while searching for {1} with {2}".format(error_msg, resource, search)) 748 if result and not thin: 749 result = self.show_resource(resource, result['id'], params=params) 750 return result 751 752 def find_resource_by(self, resource, search_field, value, **kwargs): 753 if not value: 754 return NoEntity 755 search = '{0}{1}"{2}"'.format(search_field, kwargs.pop('search_operator', '='), value) 756 return self.find_resource(resource, search, **kwargs) 757 758 def find_resource_by_name(self, resource, name, **kwargs): 759 return self.find_resource_by(resource, 'name', name, **kwargs) 760 761 def find_resource_by_title(self, resource, title, **kwargs): 762 return self.find_resource_by(resource, 'title', title, **kwargs) 763 764 def find_resource_by_id(self, resource, obj_id, **kwargs): 765 return self.find_resource_by(resource, 'id', obj_id, **kwargs) 766 767 def find_resources_by_name(self, resource, names, **kwargs): 768 return [self.find_resource_by_name(resource, name, **kwargs) for name in names] 769 770 def find_operatingsystem(self, name, failsafe=False, **kwargs): 771 result = self.find_resource_by_title('operatingsystems', name, failsafe=True, **kwargs) 772 if not result: 773 result = self.find_resource_by('operatingsystems', 'title', name, search_operator='~', failsafe=failsafe, **kwargs) 774 return result 775 776 def find_puppetclass(self, name, environment=None, params=None, failsafe=False, thin=None): 777 if thin is None: 778 thin = self._thin_default 779 if environment: 780 scope = {'environment_id': environment} 781 else: 782 scope = {} 783 if params is not None: 784 scope.update(params) 785 search = 'name="{0}"'.format(name) 786 results = self.list_resource('puppetclasses', search, params=scope) 787 788 # verify that only one puppet module is returned with only one puppet class inside 789 # as provided search results have to be like "results": { "ntp": [{"id": 1, "name": "ntp" ...}]} 790 # and get the puppet class id 791 if len(results) == 1 and len(list(results.values())[0]) == 1: 792 result = list(results.values())[0][0] 793 if thin: 794 return {'id': result['id']} 795 else: 796 return result 797 798 if failsafe: 799 return None 800 else: 801 self.fail_json(msg='No data found for name="%s"' % search) 802 803 def find_puppetclasses(self, names, **kwargs): 804 return [self.find_puppetclass(name, **kwargs) for name in names] 805 806 def find_cluster(self, name, compute_resource): 807 cluster = self.find_compute_resource_parts('clusters', name, compute_resource, None, ['ovirt', 'vmware']) 808 809 # workaround for https://projects.theforeman.org/issues/31874 810 if compute_resource['provider'].lower() == 'vmware': 811 cluster['_api_identifier'] = cluster['name'] 812 else: 813 cluster['_api_identifier'] = cluster['id'] 814 815 return cluster 816 817 def find_network(self, name, compute_resource, cluster=None): 818 return self.find_compute_resource_parts('networks', name, compute_resource, cluster, ['ovirt', 'vmware', 'google', 'azurerm']) 819 820 def find_storage_domain(self, name, compute_resource, cluster=None): 821 return self.find_compute_resource_parts('storage_domains', name, compute_resource, cluster, ['ovirt', 'vmware']) 822 823 def find_storage_pod(self, name, compute_resource, cluster=None): 824 return self.find_compute_resource_parts('storage_pods', name, compute_resource, cluster, ['vmware']) 825 826 def find_compute_resource_parts(self, part_name, name, compute_resource, cluster=None, supported_crs=None): 827 if supported_crs is None: 828 supported_crs = [] 829 830 if compute_resource['provider'].lower() not in supported_crs: 831 return {'id': name, 'name': name} 832 833 additional_params = {'id': compute_resource['id']} 834 if cluster is not None: 835 additional_params['cluster_id'] = cluster['_api_identifier'] 836 api_name = 'available_{0}'.format(part_name) 837 available_parts = self.resource_action('compute_resources', api_name, params=additional_params, 838 ignore_check_mode=True, record_change=False)['results'] 839 part = next((part for part in available_parts if str(part['name']) == str(name) or str(part['id']) == str(name)), None) 840 if part is None: 841 err_msg = "Could not find {0} '{1}' on compute resource '{2}'.".format(part_name, name, compute_resource.get('name')) 842 self.fail_json(msg=err_msg) 843 return part 844 845 def scope_for(self, key, scoped_resource=None): 846 # workaround for https://projects.theforeman.org/issues/31714 847 if scoped_resource in ['content_views', 'repositories'] and key == 'lifecycle_environment': 848 scope_key = 'environment' 849 else: 850 scope_key = key 851 return {'{0}_id'.format(scope_key): self.lookup_entity(key)['id']} 852 853 def set_entity(self, key, entity): 854 self.foreman_params[key] = entity 855 856 def lookup_entity(self, key, params=None): 857 if key not in self.foreman_params: 858 return None 859 860 entity_spec = self.foreman_spec[key] 861 if _is_resolved(entity_spec, self.foreman_params[key]): 862 # Already looked up or not an entity(_list) so nothing to do 863 return self.foreman_params[key] 864 865 result = self._lookup_entity(self.foreman_params[key], entity_spec, params) 866 self.set_entity(key, result) 867 return result 868 869 def _lookup_entity(self, identifier, entity_spec, params=None): 870 resource_type = entity_spec['resource_type'] 871 failsafe = entity_spec.get('failsafe', False) 872 thin = entity_spec.get('thin', True) 873 if params: 874 params = params.copy() 875 else: 876 params = {} 877 try: 878 for scope in entity_spec.get('scope', []): 879 params.update(self.scope_for(scope, resource_type)) 880 for optional_scope in entity_spec.get('optional_scope', []): 881 if optional_scope in self.foreman_params: 882 params.update(self.scope_for(optional_scope, resource_type)) 883 884 except TypeError: 885 if failsafe: 886 if entity_spec.get('type') == 'entity': 887 result = None 888 else: 889 result = [None for value in identifier] 890 else: 891 self.fail_json(msg="Failed to lookup scope {0} while searching for {1}.".format(entity_spec['scope'], resource_type)) 892 else: 893 # No exception happend => scope is in place 894 if resource_type == 'operatingsystems': 895 if entity_spec.get('type') == 'entity': 896 result = self.find_operatingsystem(identifier, params=params, failsafe=failsafe, thin=thin) 897 else: 898 result = [self.find_operatingsystem(value, params=params, failsafe=failsafe, thin=thin) for value in identifier] 899 elif resource_type == 'puppetclasses': 900 if entity_spec.get('type') == 'entity': 901 result = self.find_puppetclass(identifier, params=params, failsafe=failsafe, thin=thin) 902 else: 903 result = [self.find_puppetclass(value, params=params, failsafe=failsafe, thin=thin) for value in identifier] 904 else: 905 if entity_spec.get('type') == 'entity': 906 result = self.find_resource_by( 907 resource=resource_type, 908 value=identifier, 909 search_field=entity_spec.get('search_by', ENTITY_KEYS.get(resource_type, 'name')), 910 search_operator=entity_spec.get('search_operator', '='), 911 failsafe=failsafe, thin=thin, params=params, 912 ) 913 else: 914 result = [self.find_resource_by( 915 resource=resource_type, 916 value=value, 917 search_field=entity_spec.get('search_by', ENTITY_KEYS.get(resource_type, 'name')), 918 search_operator=entity_spec.get('search_operator', '='), 919 failsafe=failsafe, thin=thin, params=params, 920 ) for value in identifier] 921 return result 922 923 def auto_lookup_entities(self): 924 self.auto_lookup_nested_entities() 925 return [ 926 self.lookup_entity(key) 927 for key, entity_spec in self.foreman_spec.items() 928 if entity_spec.get('resolve', True) and entity_spec.get('type') in {'entity', 'entity_list'} 929 ] 930 931 def auto_lookup_nested_entities(self): 932 for key, entity_spec in self.foreman_spec.items(): 933 if entity_spec.get('type') in {'nested_list'}: 934 for nested_key, nested_spec in self.foreman_spec[key]['foreman_spec'].items(): 935 for item in self.foreman_params.get(key, []): 936 if (nested_key in item and nested_spec.get('resolve', True) 937 and not _is_resolved(nested_spec, item[nested_key])): 938 item[nested_key] = self._lookup_entity(item[nested_key], nested_spec) 939 940 def record_before(self, resource, entity): 941 self._before[resource].append(entity) 942 943 def record_after(self, resource, entity): 944 self._after[resource].append(entity) 945 946 def record_after_full(self, resource, entity): 947 self._after_full[resource].append(entity) 948 949 @_exception2fail_json(msg='Failed to ensure entity state: {0}') 950 def ensure_entity(self, resource, desired_entity, current_entity, params=None, state=None, foreman_spec=None): 951 """ 952 Ensure that a given entity has a certain state 953 954 :param resource: Plural name of the api resource to manipulate 955 :type resource: str 956 :param desired_entity: Desired properties of the entity 957 :type desired_entity: dict 958 :param current_entity: Current properties of the entity or None if nonexistent 959 :type current_entity: Union[dict,None] 960 :param params: Lookup parameters (i.e. parent_id for nested entities) 961 :type params: dict, optional 962 :param state: Desired state of the entity (optionally taken from the module) 963 :type state: str, optional 964 :param foreman_spec: Description of the entity structure (optionally taken from module) 965 :type foreman_spec: dict, optional 966 967 :return: The new current state of the entity 968 :rtype: Union[dict,None] 969 """ 970 if state is None: 971 state = self.state 972 if foreman_spec is None: 973 foreman_spec = self.foreman_spec 974 else: 975 foreman_spec, _dummy = _foreman_spec_helper(foreman_spec) 976 977 updated_entity = None 978 979 self.record_before(resource, _flatten_entity(current_entity, foreman_spec)) 980 981 if state == 'present_with_defaults': 982 if current_entity is None: 983 updated_entity = self._create_entity(resource, desired_entity, params, foreman_spec) 984 elif state == 'present': 985 if current_entity is None: 986 updated_entity = self._create_entity(resource, desired_entity, params, foreman_spec) 987 else: 988 updated_entity = self._update_entity(resource, desired_entity, current_entity, params, foreman_spec) 989 elif state == 'copied': 990 if current_entity is not None: 991 updated_entity = self._copy_entity(resource, desired_entity, current_entity, params) 992 elif state == 'reverted': 993 if current_entity is not None: 994 updated_entity = self._revert_entity(resource, current_entity, params) 995 elif state == 'absent': 996 if current_entity is not None: 997 updated_entity = self._delete_entity(resource, current_entity, params) 998 else: 999 self.fail_json(msg='Not a valid state: {0}'.format(state)) 1000 1001 self.record_after(resource, _flatten_entity(updated_entity, foreman_spec)) 1002 self.record_after_full(resource, updated_entity) 1003 1004 return updated_entity 1005 1006 def _validate_supported_payload(self, resource, action, payload): 1007 """ 1008 Check whether the payload only contains supported keys. 1009 Emits a warning for keys that are not part of the apidoc. 1010 1011 :param resource: Plural name of the api resource to check 1012 :type resource: str 1013 :param action: Name of the action to check payload against 1014 :type action: str 1015 :param payload: API paylod to be checked 1016 :type payload: dict 1017 1018 :return: The payload as it can be submitted to the API 1019 :rtype: dict 1020 """ 1021 filtered_payload = self._resource_prepare_params(resource, action, payload) 1022 # On Python 2 dict.keys() is just a list, but we need a set here. 1023 unsupported_parameters = set(payload.keys()) - set(_recursive_dict_keys(filtered_payload)) 1024 if unsupported_parameters: 1025 warn_msg = "The following parameters are not supported by your server when performing {0} on {1}: {2}. They were ignored." 1026 self.warn(warn_msg.format(action, resource, unsupported_parameters)) 1027 return filtered_payload 1028 1029 def _create_entity(self, resource, desired_entity, params, foreman_spec): 1030 """ 1031 Create entity with given properties 1032 1033 :param resource: Plural name of the api resource to manipulate 1034 :type resource: str 1035 :param desired_entity: Desired properties of the entity 1036 :type desired_entity: dict 1037 :param params: Lookup parameters (i.e. parent_id for nested entities) 1038 :type params: dict, optional 1039 :param foreman_spec: Description of the entity structure 1040 :type foreman_spec: dict 1041 1042 :return: The new current state of the entity 1043 :rtype: dict 1044 """ 1045 payload = _flatten_entity(desired_entity, foreman_spec) 1046 self._validate_supported_payload(resource, 'create', payload) 1047 if not self.check_mode: 1048 if params: 1049 payload.update(params) 1050 return self.resource_action(resource, 'create', payload) 1051 else: 1052 fake_entity = desired_entity.copy() 1053 fake_entity['id'] = -1 1054 self.set_changed() 1055 return fake_entity 1056 1057 def _update_entity(self, resource, desired_entity, current_entity, params, foreman_spec): 1058 """ 1059 Update a given entity with given properties if any diverge 1060 1061 :param resource: Plural name of the api resource to manipulate 1062 :type resource: str 1063 :param desired_entity: Desired properties of the entity 1064 :type desired_entity: dict 1065 :param current_entity: Current properties of the entity 1066 :type current_entity: dict 1067 :param params: Lookup parameters (i.e. parent_id for nested entities) 1068 :type params: dict, optional 1069 :param foreman_spec: Description of the entity structure 1070 :type foreman_spec: dict 1071 1072 :return: The new current state of the entity 1073 :rtype: dict 1074 """ 1075 payload = {} 1076 desired_entity = _flatten_entity(desired_entity, foreman_spec) 1077 current_entity = _flatten_entity(current_entity, foreman_spec) 1078 for key, value in desired_entity.items(): 1079 foreman_type = foreman_spec[key].get('type', 'str') 1080 new_value = value 1081 old_value = current_entity.get(key) 1082 # String comparison needs extra care in face of unicode 1083 if foreman_type == 'str': 1084 old_value = to_native(old_value) 1085 new_value = to_native(new_value) 1086 # ideally the type check would happen via foreman_spec.elements 1087 # however this is not set for flattened entries and setting it 1088 # confuses _flatten_entity 1089 elif foreman_type == 'list' and value and isinstance(value[0], dict): 1090 if 'name' in value[0]: 1091 sort_key = 'name' 1092 else: 1093 sort_key = list(value[0].keys())[0] 1094 new_value = sorted(new_value, key=operator.itemgetter(sort_key)) 1095 old_value = sorted(old_value, key=operator.itemgetter(sort_key)) 1096 if new_value != old_value: 1097 payload[key] = value 1098 if self._validate_supported_payload(resource, 'update', payload): 1099 payload['id'] = current_entity['id'] 1100 if not self.check_mode: 1101 if params: 1102 payload.update(params) 1103 return self.resource_action(resource, 'update', payload) 1104 else: 1105 # In check_mode we emulate the server updating the entity 1106 fake_entity = current_entity.copy() 1107 fake_entity.update(payload) 1108 self.set_changed() 1109 return fake_entity 1110 else: 1111 # Nothing needs changing 1112 return current_entity 1113 1114 def _copy_entity(self, resource, desired_entity, current_entity, params): 1115 """ 1116 Copy a given entity 1117 1118 :param resource: Plural name of the api resource to manipulate 1119 :type resource: str 1120 :param desired_entity: Desired properties of the entity 1121 :type desired_entity: dict 1122 :param current_entity: Current properties of the entity 1123 :type current_entity: dict 1124 :param params: Lookup parameters (i.e. parent_id for nested entities) 1125 :type params: dict, optional 1126 1127 :return: The new current state of the entity 1128 :rtype: dict 1129 """ 1130 payload = { 1131 'id': current_entity['id'], 1132 'new_name': desired_entity['new_name'], 1133 } 1134 if params: 1135 payload.update(params) 1136 return self.resource_action(resource, 'copy', payload) 1137 1138 def _revert_entity(self, resource, current_entity, params): 1139 """ 1140 Revert a given entity 1141 1142 :param resource: Plural name of the api resource to manipulate 1143 :type resource: str 1144 :param current_entity: Current properties of the entity 1145 :type current_entity: dict 1146 :param params: Lookup parameters (i.e. parent_id for nested entities) 1147 :type params: dict, optional 1148 1149 :return: The new current state of the entity 1150 :rtype: dict 1151 """ 1152 payload = {'id': current_entity['id']} 1153 if params: 1154 payload.update(params) 1155 return self.resource_action(resource, 'revert', payload) 1156 1157 def _delete_entity(self, resource, current_entity, params): 1158 """ 1159 Delete a given entity 1160 1161 :param resource: Plural name of the api resource to manipulate 1162 :type resource: str 1163 :param current_entity: Current properties of the entity 1164 :type current_entity: dict 1165 :param params: Lookup parameters (i.e. parent_id for nested entities) 1166 :type params: dict, optional 1167 1168 :return: The new current state of the entity 1169 :rtype: Union[dict,None] 1170 """ 1171 payload = {'id': current_entity['id']} 1172 if params: 1173 payload.update(params) 1174 entity = self.resource_action(resource, 'destroy', payload) 1175 1176 # this is a workaround for https://projects.theforeman.org/issues/26937 1177 if entity and isinstance(entity, dict) and 'error' in entity and 'message' in entity['error']: 1178 self.fail_json(msg=entity['error']['message']) 1179 1180 return None 1181 1182 def resource_action(self, resource, action, params, options=None, data=None, files=None, 1183 ignore_check_mode=False, record_change=True, ignore_task_errors=False): 1184 resource_payload = self._resource_prepare_params(resource, action, params) 1185 if options is None: 1186 options = {} 1187 try: 1188 result = None 1189 if ignore_check_mode or not self.check_mode: 1190 result = self._resource_call(resource, action, resource_payload, options=options, data=data, files=files) 1191 is_foreman_task = isinstance(result, dict) and 'action' in result and 'state' in result and 'started_at' in result 1192 if is_foreman_task: 1193 result = self.wait_for_task(result, ignore_errors=ignore_task_errors) 1194 except Exception as e: 1195 msg = 'Error while performing {0} on {1}: {2}'.format( 1196 action, resource, to_native(e)) 1197 self.fail_from_exception(e, msg) 1198 if record_change and not ignore_check_mode: 1199 # If we were supposed to ignore check_mode we can assume this action was not a changing one. 1200 self.set_changed() 1201 return result 1202 1203 def wait_for_task(self, task, ignore_errors=False): 1204 duration = self.task_timeout 1205 while task['state'] not in ['paused', 'stopped']: 1206 duration -= self.task_poll 1207 if duration <= 0: 1208 self.fail_json(msg="Timout waiting for Task {0}".format(task['id'])) 1209 time.sleep(self.task_poll) 1210 1211 resource_payload = self._resource_prepare_params('foreman_tasks', 'show', {'id': task['id']}) 1212 task = self._resource_call('foreman_tasks', 'show', resource_payload) 1213 if not ignore_errors and task['result'] != 'success': 1214 self.fail_json(msg='Task {0}({1}) did not succeed. Task information: {2}'.format(task['action'], task['id'], task['humanized']['errors'])) 1215 return task 1216 1217 def fail_from_exception(self, exc, msg): 1218 fail = {'msg': msg} 1219 if isinstance(exc, requests.exceptions.HTTPError): 1220 try: 1221 response = exc.response.json() 1222 if 'error' in response: 1223 fail['error'] = response['error'] 1224 else: 1225 fail['error'] = response 1226 except Exception: 1227 fail['error'] = exc.response.text 1228 self.fail_json(**fail) 1229 1230 def exit_json(self, changed=False, **kwargs): 1231 kwargs['changed'] = changed or self.changed 1232 if 'diff' not in kwargs and (self._before or self._after): 1233 kwargs['diff'] = {'before': self._before, 1234 'after': self._after} 1235 if 'entity' not in kwargs and self._after_full: 1236 kwargs['entity'] = self._after_full 1237 super(ForemanAnsibleModule, self).exit_json(**kwargs) 1238 1239 def has_plugin(self, plugin_name): 1240 try: 1241 resource_name = _PLUGIN_RESOURCES[plugin_name] 1242 except KeyError: 1243 raise Exception("Unknown plugin: {0}".format(plugin_name)) 1244 return resource_name in self.foremanapi.resources 1245 1246 def check_required_plugins(self): 1247 missing_plugins = [] 1248 for (plugin, params) in self.required_plugins: 1249 for param in params: 1250 if (param in self.foreman_params or param == '*') and not self.has_plugin(plugin): 1251 if param == '*': 1252 param = 'the whole module' 1253 missing_plugins.append("{0} (for {1})".format(plugin, param)) 1254 if missing_plugins: 1255 missing_msg = "The server is missing required plugins: {0}.".format(', '.join(missing_plugins)) 1256 self.fail_json(msg=missing_msg) 1257 1258 1259class ForemanStatelessEntityAnsibleModule(ForemanAnsibleModule): 1260 """ Base class for Foreman entities without a state. To use it, subclass it with the following convention: 1261 To manage my_entity entity, create the following sub class:: 1262 1263 class ForemanMyEntityModule(ForemanStatelessEntityAnsibleModule): 1264 pass 1265 1266 and use that class to instantiate module:: 1267 1268 module = ForemanMyEntityModule( 1269 argument_spec=dict( 1270 [...] 1271 ), 1272 foreman_spec=dict( 1273 [...] 1274 ), 1275 ) 1276 1277 It adds the following attributes: 1278 1279 * entity_key (str): field used to search current entity. Defaults to value provided by `ENTITY_KEYS` or 'name' if no value found. 1280 * entity_name (str): name of the current entity. 1281 By default deduce the entity name from the class name (eg: 'ForemanProvisioningTemplateModule' class will produce 'provisioning_template'). 1282 * entity_opts (dict): Dict of options for base entity. Same options can be provided for subentities described in foreman_spec. 1283 1284 The main entity is referenced with the key `entity` in the `foreman_spec`. 1285 """ 1286 1287 def __init__(self, **kwargs): 1288 self.entity_key = kwargs.pop('entity_key', 'name') 1289 self.entity_name = kwargs.pop('entity_name', self.entity_name_from_class) 1290 entity_opts = kwargs.pop('entity_opts', {}) 1291 1292 super(ForemanStatelessEntityAnsibleModule, self).__init__(**kwargs) 1293 1294 if 'resource_type' not in entity_opts: 1295 entity_opts['resource_type'] = inflector.pluralize(self.entity_name) 1296 if 'thin' not in entity_opts: 1297 # Explicit None to trigger the _thin_default mechanism lazily 1298 entity_opts['thin'] = None 1299 if 'failsafe' not in entity_opts: 1300 entity_opts['failsafe'] = True 1301 if 'search_operator' not in entity_opts: 1302 entity_opts['search_operator'] = '=' 1303 if 'search_by' not in entity_opts: 1304 entity_opts['search_by'] = ENTITY_KEYS.get(entity_opts['resource_type'], 'name') 1305 1306 self.foreman_spec.update(_foreman_spec_helper(dict( 1307 entity=dict( 1308 type='entity', 1309 flat_name='id', 1310 ensure=False, 1311 **entity_opts 1312 ), 1313 ))[0]) 1314 1315 if 'parent' in self.foreman_spec and self.foreman_spec['parent'].get('type') == 'entity': 1316 if 'resouce_type' not in self.foreman_spec['parent']: 1317 self.foreman_spec['parent']['resource_type'] = self.foreman_spec['entity']['resource_type'] 1318 current, parent = split_fqn(self.foreman_params[self.entity_key]) 1319 if isinstance(self.foreman_params.get('parent'), six.string_types): 1320 if parent: 1321 self.fail_json(msg="Please specify the parent either separately, or as part of the title.") 1322 parent = self.foreman_params['parent'] 1323 elif parent: 1324 self.foreman_params['parent'] = parent 1325 self.foreman_params[self.entity_key] = current 1326 self.foreman_params['entity'] = build_fqn(current, parent) 1327 else: 1328 self.foreman_params['entity'] = self.foreman_params.get(self.entity_key) 1329 1330 @property 1331 def entity_name_from_class(self): 1332 """ 1333 The entity name derived from the class name. 1334 1335 The class name must follow the following name convention: 1336 1337 * It starts with ``Foreman`` or ``Katello``. 1338 * It ends with ``Module``. 1339 1340 This will convert the class name ``ForemanMyEntityModule`` to the entity name ``my_entity``. 1341 1342 Examples: 1343 1344 * ``ForemanArchitectureModule`` => ``architecture`` 1345 * ``ForemanProvisioningTemplateModule`` => ``provisioning_template`` 1346 * ``KatelloProductMudule`` => ``product`` 1347 """ 1348 # Convert current class name from CamelCase to snake_case 1349 class_name = re.sub(r'(?<=[a-z])[A-Z]|[A-Z](?=[^A-Z])', r'_\g<0>', self.__class__.__name__).lower().strip('_') 1350 # Get entity name from snake case class name 1351 return '_'.join(class_name.split('_')[1:-1]) 1352 1353 1354class ForemanInfoAnsibleModule(ForemanStatelessEntityAnsibleModule): 1355 """ 1356 Base class for Foreman info modules that fetch information about entities 1357 """ 1358 def __init__(self, **kwargs): 1359 self._resources = [] 1360 foreman_spec = dict( 1361 name=dict(), 1362 search=dict(), 1363 organization=dict(type='entity'), 1364 location=dict(type='entity'), 1365 ) 1366 foreman_spec.update(kwargs.pop('foreman_spec', {})) 1367 mutually_exclusive = kwargs.pop('mutually_exclusive', []) 1368 if not foreman_spec['name'].get('invisible', False): 1369 mutually_exclusive.extend([['name', 'search']]) 1370 super(ForemanInfoAnsibleModule, self).__init__(foreman_spec=foreman_spec, mutually_exclusive=mutually_exclusive, **kwargs) 1371 1372 def run(self, **kwargs): 1373 """ 1374 lookup entities 1375 """ 1376 self.auto_lookup_entities() 1377 1378 resource = self.foreman_spec['entity']['resource_type'] 1379 1380 if 'name' in self.foreman_params: 1381 self._info_result = {self.entity_name: self.lookup_entity('entity')} 1382 else: 1383 _flat_entity = _flatten_entity(self.foreman_params, self.foreman_spec) 1384 self._info_result = {resource: self.list_resource(resource, self.foreman_params.get('search'), _flat_entity)} 1385 1386 def exit_json(self, **kwargs): 1387 kwargs.update(self._info_result) 1388 super(ForemanInfoAnsibleModule, self).exit_json(**kwargs) 1389 1390 1391class ForemanEntityAnsibleModule(ForemanStatelessEntityAnsibleModule): 1392 """ Base class for Foreman entities. To use it, subclass it with the following convention: 1393 To manage my_entity entity, create the following sub class:: 1394 1395 class ForemanMyEntityModule(ForemanEntityAnsibleModule): 1396 pass 1397 1398 and use that class to instantiate module:: 1399 1400 module = ForemanMyEntityModule( 1401 argument_spec=dict( 1402 [...] 1403 ), 1404 foreman_spec=dict( 1405 [...] 1406 ), 1407 ) 1408 1409 This adds a `state` parameter to the module and provides the `run` method for the most 1410 common usecases. 1411 """ 1412 1413 def __init__(self, **kwargs): 1414 argument_spec = dict( 1415 state=dict(choices=['present', 'absent'], default='present'), 1416 ) 1417 argument_spec.update(kwargs.pop('argument_spec', {})) 1418 super(ForemanEntityAnsibleModule, self).__init__(argument_spec=argument_spec, **kwargs) 1419 1420 self.state = self.foreman_params.pop('state') 1421 self.desired_absent = self.state == 'absent' 1422 self._thin_default = self.desired_absent 1423 1424 def run(self, **kwargs): 1425 """ lookup entities, ensure entity, remove sensitive data, manage parameters. 1426 """ 1427 if ('parent' in self.foreman_spec and self.foreman_spec['parent'].get('type') == 'entity' 1428 and self.desired_absent and 'parent' in self.foreman_params and self.lookup_entity('parent') is None): 1429 # Parent does not exist so just exit here 1430 return None 1431 if not self.desired_absent: 1432 self.auto_lookup_entities() 1433 entity = self.lookup_entity('entity') 1434 1435 if not self.desired_absent: 1436 updated_key = "updated_" + self.entity_key 1437 if entity and updated_key in self.foreman_params: 1438 self.foreman_params[self.entity_key] = self.foreman_params.pop(updated_key) 1439 1440 params = kwargs.get('params', {}) 1441 for scope in self.foreman_spec['entity'].get('scope', []): 1442 params.update(self.scope_for(scope)) 1443 for optional_scope in self.foreman_spec['entity'].get('optional_scope', []): 1444 if optional_scope in self.foreman_params: 1445 params.update(self.scope_for(optional_scope)) 1446 new_entity = self.ensure_entity(self.foreman_spec['entity']['resource_type'], self.foreman_params, entity, params=params) 1447 new_entity = self.remove_sensitive_fields(new_entity) 1448 1449 return new_entity 1450 1451 def remove_sensitive_fields(self, entity): 1452 """ Set fields with 'no_log' option to None """ 1453 if entity: 1454 for blacklisted_field in self.blacklisted_fields: 1455 entity[blacklisted_field] = None 1456 return entity 1457 1458 @property 1459 def blacklisted_fields(self): 1460 return [key for key, value in self.foreman_spec.items() if value.get('no_log', False)] 1461 1462 1463class ForemanTaxonomicAnsibleModule(TaxonomyMixin, ForemanAnsibleModule): 1464 """ 1465 Combine :class:`ForemanAnsibleModule` with the :class:`TaxonomyMixin` Mixin. 1466 """ 1467 1468 pass 1469 1470 1471class ForemanTaxonomicEntityAnsibleModule(TaxonomyMixin, ForemanEntityAnsibleModule): 1472 """ 1473 Combine :class:`ForemanEntityAnsibleModule` with the :class:`TaxonomyMixin` Mixin. 1474 """ 1475 1476 pass 1477 1478 1479class ForemanScapDataStreamModule(ForemanTaxonomicEntityAnsibleModule): 1480 def __init__(self, **kwargs): 1481 foreman_spec = dict( 1482 original_filename=dict(type='str'), 1483 scap_file=dict(type='path'), 1484 ) 1485 foreman_spec.update(kwargs.pop('foreman_spec', {})) 1486 super(ForemanScapDataStreamModule, self).__init__(foreman_spec=foreman_spec, **kwargs) 1487 1488 def run(self, **kwargs): 1489 entity = self.lookup_entity('entity') 1490 1491 if not self.desired_absent: 1492 if not entity and 'scap_file' not in self.foreman_params: 1493 self.fail_json(msg="Content of scap_file not provided. XML containing SCAP content is required.") 1494 1495 if 'scap_file' in self.foreman_params and 'original_filename' not in self.foreman_params: 1496 self.foreman_params['original_filename'] = os.path.basename(self.foreman_params['scap_file']) 1497 1498 if 'scap_file' in self.foreman_params: 1499 with open(self.foreman_params['scap_file']) as input_file: 1500 self.foreman_params['scap_file'] = input_file.read() 1501 1502 if entity and 'scap_file' in self.foreman_params: 1503 digest = hashlib.sha256(self.foreman_params['scap_file'].encode("utf-8")).hexdigest() 1504 # workaround for https://projects.theforeman.org/issues/29409 1505 digest_stripped = hashlib.sha256(self.foreman_params['scap_file'].strip().encode("utf-8")).hexdigest() 1506 if entity['digest'] in [digest, digest_stripped]: 1507 self.foreman_params.pop('scap_file') 1508 1509 return super(ForemanScapDataStreamModule, self).run(**kwargs) 1510 1511 1512class KatelloAnsibleModule(KatelloMixin, ForemanAnsibleModule): 1513 """ 1514 Combine :class:`ForemanAnsibleModule` with the :class:`KatelloMixin` Mixin. 1515 """ 1516 1517 pass 1518 1519 1520class KatelloScopedMixin(KatelloMixin): 1521 """ 1522 Enhances :class:`KatelloMixin` with scoping by ``organization`` as required by Katello. 1523 """ 1524 1525 def __init__(self, **kwargs): 1526 entity_opts = kwargs.pop('entity_opts', {}) 1527 if 'scope' not in entity_opts: 1528 entity_opts['scope'] = ['organization'] 1529 elif 'organization' not in entity_opts['scope']: 1530 entity_opts['scope'].append('organization') 1531 super(KatelloScopedMixin, self).__init__(entity_opts=entity_opts, **kwargs) 1532 1533 1534class KatelloInfoAnsibleModule(KatelloScopedMixin, ForemanInfoAnsibleModule): 1535 """ 1536 Combine :class:`ForemanInfoAnsibleModule` with the :class:`KatelloScopedMixin` Mixin. 1537 """ 1538 1539 pass 1540 1541 1542class KatelloEntityAnsibleModule(KatelloScopedMixin, ForemanEntityAnsibleModule): 1543 """ 1544 Combine :class:`ForemanEntityAnsibleModule` with the :class:`KatelloScopedMixin` Mixin. 1545 """ 1546 1547 pass 1548 1549 1550def _foreman_spec_helper(spec): 1551 """Extend an entity spec by adding entries for all flat_names. 1552 Extract Ansible compatible argument_spec on the way. 1553 """ 1554 foreman_spec = {} 1555 argument_spec = {} 1556 1557 _FILTER_SPEC_KEYS = { 1558 'ensure', 1559 'failsafe', 1560 'flat_name', 1561 'foreman_spec', 1562 'invisible', 1563 'optional_scope', 1564 'resolve', 1565 'resource_type', 1566 'scope', 1567 'search_by', 1568 'search_operator', 1569 'thin', 1570 'type', 1571 } 1572 _VALUE_SPEC_KEYS = { 1573 'ensure', 1574 'type', 1575 } 1576 _ENTITY_SPEC_KEYS = { 1577 'failsafe', 1578 'optional_scope', 1579 'resolve', 1580 'resource_type', 1581 'scope', 1582 'search_by', 1583 'search_operator', 1584 'thin', 1585 } 1586 1587 # _foreman_spec_helper() is called before we call check_requirements() in the __init__ of ForemanAnsibleModule 1588 # and thus before the if HAS APYPIE check happens. 1589 # We have to ensure that apypie is available before using it. 1590 # There is two cases where we can call _foreman_spec_helper() without apypie available: 1591 # * When the user calls the module but doesn't have the right Python libraries installed. 1592 # In this case nothing will works and the module will warn the user to install the required library. 1593 # * When Ansible generates docs from the argument_spec. As the inflector is only used to build foreman_spec and not argument_spec, 1594 # This is not a problem. 1595 # 1596 # So in conclusion, we only have to verify that apypie is available before using it. 1597 # Lazy evaluation helps there. 1598 for key, value in spec.items(): 1599 foreman_value = {k: v for (k, v) in value.items() if k in _VALUE_SPEC_KEYS} 1600 argument_value = {k: v for (k, v) in value.items() if k not in _FILTER_SPEC_KEYS} 1601 1602 foreman_type = value.get('type') 1603 ansible_invisible = value.get('invisible', False) 1604 flat_name = value.get('flat_name') 1605 1606 if foreman_type == 'entity': 1607 if not flat_name: 1608 flat_name = '{0}_id'.format(key) 1609 foreman_value['resource_type'] = HAS_APYPIE and inflector.pluralize(key) 1610 foreman_value.update({k: v for (k, v) in value.items() if k in _ENTITY_SPEC_KEYS}) 1611 elif foreman_type == 'entity_list': 1612 argument_value['type'] = 'list' 1613 argument_value['elements'] = value.get('elements', 'str') 1614 if not flat_name: 1615 flat_name = '{0}_ids'.format(HAS_APYPIE and inflector.singularize(key)) 1616 foreman_value['resource_type'] = key 1617 foreman_value.update({k: v for (k, v) in value.items() if k in _ENTITY_SPEC_KEYS}) 1618 elif foreman_type == 'nested_list': 1619 argument_value['type'] = 'list' 1620 argument_value['elements'] = 'dict' 1621 foreman_value['foreman_spec'], argument_value['options'] = _foreman_spec_helper(value['foreman_spec']) 1622 foreman_value['ensure'] = value.get('ensure', False) 1623 elif foreman_type: 1624 argument_value['type'] = foreman_type 1625 1626 if flat_name: 1627 foreman_value['flat_name'] = flat_name 1628 foreman_spec[flat_name] = {} 1629 # When translating to a flat name, the flattened entry should get the same "type" 1630 # as Ansible expects so that comparison still works for non-strings 1631 if argument_value.get('type') is not None: 1632 foreman_spec[flat_name]['type'] = argument_value['type'] 1633 1634 foreman_spec[key] = foreman_value 1635 1636 if not ansible_invisible: 1637 argument_spec[key] = argument_value 1638 1639 return foreman_spec, argument_spec 1640 1641 1642def _flatten_entity(entity, foreman_spec): 1643 """Flatten entity according to spec""" 1644 result = {} 1645 if entity is None: 1646 entity = {} 1647 for key, value in entity.items(): 1648 if key in foreman_spec and foreman_spec[key].get('ensure', True) and value is not None: 1649 spec = foreman_spec[key] 1650 flat_name = spec.get('flat_name', key) 1651 property_type = spec.get('type', 'str') 1652 if property_type == 'entity': 1653 if value is not NoEntity: 1654 result[flat_name] = value['id'] 1655 else: 1656 result[flat_name] = None 1657 elif property_type == 'entity_list': 1658 result[flat_name] = sorted(val['id'] for val in value) 1659 elif property_type == 'nested_list': 1660 result[flat_name] = [_flatten_entity(ent, foreman_spec[key]['foreman_spec']) for ent in value] 1661 else: 1662 result[flat_name] = value 1663 return result 1664 1665 1666def _recursive_dict_keys(a_dict): 1667 """Find all keys of a nested dictionary""" 1668 keys = set(a_dict.keys()) 1669 for _k, v in a_dict.items(): 1670 if isinstance(v, dict): 1671 keys.update(_recursive_dict_keys(v)) 1672 return keys 1673 1674 1675def _recursive_dict_without_none(a_dict, exclude=None): 1676 """ 1677 Remove all entries with `None` value from a dict, recursively. 1678 Also drops all entries with keys in `exclude` in the top level. 1679 """ 1680 if exclude is None: 1681 exclude = [] 1682 1683 result = {} 1684 1685 for (k, v) in a_dict.items(): 1686 if v is not None and k not in exclude: 1687 if isinstance(v, dict): 1688 v = _recursive_dict_without_none(v) 1689 elif isinstance(v, list) and v and isinstance(v[0], dict): 1690 v = [_recursive_dict_without_none(element) for element in v] 1691 result[k] = v 1692 1693 return result 1694 1695 1696def _is_resolved(spec, what): 1697 if spec.get('type') not in ('entity', 'entity_list'): 1698 return True 1699 1700 if spec.get('type') == 'entity' and (what is None or isinstance(what, dict)): 1701 return True 1702 1703 if spec.get('type') == 'entity_list' and isinstance(what, list) and what and (what[0] is None or isinstance(what[0], dict)): 1704 return True 1705 1706 return False 1707 1708 1709# Helper for (global, operatingsystem, ...) parameters 1710def parameter_value_to_str(value, parameter_type): 1711 """Helper to convert the value of parameters to string according to their parameter_type.""" 1712 if parameter_type in ['real', 'integer']: 1713 parameter_string = str(value) 1714 elif parameter_type in ['array', 'hash', 'yaml', 'json']: 1715 parameter_string = json.dumps(value, sort_keys=True) 1716 else: 1717 parameter_string = value 1718 return parameter_string 1719 1720 1721# Helper for converting lists of parameters 1722def parameters_list_to_str_list(parameters): 1723 filtered_params = [] 1724 for param in parameters: 1725 new_param = {k: v for (k, v) in param.items() if k in parameter_ansible_spec.keys()} 1726 new_param['value'] = parameter_value_to_str(new_param['value'], new_param['parameter_type']) 1727 filtered_params.append(new_param) 1728 return filtered_params 1729 1730 1731# Helper for templates 1732def parse_template(template_content, module): 1733 if not HAS_PYYAML: 1734 module.fail_json(msg=missing_required_lib("PyYAML"), exception=PYYAML_IMP_ERR) 1735 1736 try: 1737 template_dict = {} 1738 data = re.search( 1739 r'<%#([^%]*([^%]*%*[^>%])*%*)%>', template_content) 1740 if data: 1741 datalist = data.group(1) 1742 if datalist[-1] == '-': 1743 datalist = datalist[:-1] 1744 template_dict = yaml.safe_load(datalist) 1745 # No metadata, import template anyway 1746 template_dict['template'] = template_content 1747 except Exception as e: 1748 module.fail_json(msg='Error while parsing template: ' + to_native(e)) 1749 return template_dict 1750 1751 1752def parse_template_from_file(file_name, module): 1753 try: 1754 with open(file_name) as input_file: 1755 template_content = input_file.read() 1756 template_dict = parse_template(template_content, module) 1757 except Exception as e: 1758 module.fail_json(msg='Error while reading template file: ' + to_native(e)) 1759 return template_dict 1760 1761 1762# Helper for titles 1763def split_fqn(title): 1764 """ Split fully qualified name (title) in name and parent title """ 1765 fqn = title.split('/') 1766 if len(fqn) > 1: 1767 name = fqn.pop() 1768 return (name, '/'.join(fqn)) 1769 else: 1770 return (title, None) 1771 1772 1773def build_fqn(name, parent=None): 1774 if parent: 1775 return "%s/%s" % (parent, name) 1776 else: 1777 return name 1778 1779 1780# Helper for puppetclasses 1781def ensure_puppetclasses(module, entity_type, entity, expected_puppetclasses=None): 1782 puppetclasses_resource = '{0}_classes'.format(entity_type) 1783 if expected_puppetclasses: 1784 expected_puppetclasses = module.find_puppetclasses(expected_puppetclasses, environment=entity['environment_id'], thin=True) 1785 current_puppetclasses = entity.pop('puppetclass_ids', []) 1786 if expected_puppetclasses: 1787 for puppetclass in expected_puppetclasses: 1788 if puppetclass['id'] in current_puppetclasses: 1789 current_puppetclasses.remove(puppetclass['id']) 1790 else: 1791 payload = {'{0}_id'.format(entity_type): entity['id'], 'puppetclass_id': puppetclass['id']} 1792 module.ensure_entity(puppetclasses_resource, {}, None, params=payload, state='present', foreman_spec={}) 1793 if len(current_puppetclasses) > 0: 1794 for leftover_puppetclass in current_puppetclasses: 1795 module.ensure_entity(puppetclasses_resource, {}, {'id': leftover_puppetclass}, {'hostgroup_id': entity['id']}, state='absent', foreman_spec={}) 1796 1797 1798# Helper constants 1799OS_LIST = ['AIX', 1800 'Altlinux', 1801 'Archlinux', 1802 'Coreos', 1803 'Debian', 1804 'Freebsd', 1805 'Gentoo', 1806 'Junos', 1807 'NXOS', 1808 'Rancheros', 1809 'Redhat', 1810 'Solaris', 1811 'Suse', 1812 'Windows', 1813 'Xenserver', 1814 ] 1815 1816TEMPLATE_KIND_LIST = [ 1817 'Bootdisk', 1818 'cloud-init', 1819 'finish', 1820 'iPXE', 1821 'job_template', 1822 'kexec', 1823 'POAP', 1824 'provision', 1825 'ptable', 1826 'PXEGrub', 1827 'PXEGrub2', 1828 'PXELinux', 1829 'registration', 1830 'script', 1831 'user_data', 1832 'ZTP', 1833] 1834 1835# interface specs 1836interfaces_spec = dict( 1837 id=dict(invisible=True), 1838 mac=dict(), 1839 ip=dict(), 1840 ip6=dict(), 1841 type=dict(choices=['interface', 'bmc', 'bond', 'bridge']), 1842 name=dict(), 1843 subnet=dict(type='entity'), 1844 subnet6=dict(type='entity', resource_type='subnets'), 1845 domain=dict(type='entity'), 1846 identifier=dict(), 1847 managed=dict(type='bool'), 1848 primary=dict(type='bool'), 1849 provision=dict(type='bool'), 1850 username=dict(), 1851 password=dict(no_log=True), 1852 provider=dict(choices=['IPMI', 'Redfish', 'SSH']), 1853 virtual=dict(type='bool'), 1854 tag=dict(), 1855 mtu=dict(type='int'), 1856 attached_to=dict(), 1857 mode=dict(choices=[ 1858 'balance-rr', 1859 'active-backup', 1860 'balance-xor', 1861 'broadcast', 1862 '802.3ad', 1863 'balance-tlb', 1864 'balance-alb', 1865 ]), 1866 attached_devices=dict(type='list', elements='str'), 1867 bond_options=dict(), 1868 compute_attributes=dict(type='dict'), 1869) 1870