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