On 05/19/2016 04:59 PM, Martin Babinsky wrote:
Patch 0146 implements lower-lever infrastructure for querying server
roles/attributes

Patch 0147 are some basic tests slapped together for the `serverroles`
backend to ensure that it works as expected.

The new/modified CLI commands specified in the design page [1] will be
coming soon.

Also coming soon will be an interface to construct DNS records for each
role requested by Petr^2 and Martin^2 which I will start implement when
we agree on the backend implementation.

https://fedorahosted.org/freeipa/ticket/5181

[1] https://www.freeipa.org/page/V4/Server_Roles#CLI



Thanks Martin and Honza for review.

I have reworked the patches based on your comments. I have split patch 146 into two (role/attribute definitions and backend code) so patches 146-147 are for code and 148 hosts test suite.

I hope that I will send API patches on monday after I resolve some framework-related questions with the local guru.

--
Martin^3 Babinsky
From 6994428e527760fdd6d5cb442af0a1f321a4e542 Mon Sep 17 00:00:00 2001
From: Martin Babinsky <mbabi...@redhat.com>
Date: Thu, 26 May 2016 19:24:22 +0200
Subject: [PATCH 1/3] Server roles: definitions of server roles and attributes

This patch introduces classes which define the properties of server roles and
attributes and their relationship to LDAP attributes representing the
role/attribute.

A brief documentation about defining and using roles is given at the beginning
of the module.

http://www.freeipa.org/page/V4/Server_Roles
https://fedorahosted.org/freeipa/ticket/5181
---
 ipaserver/servroles.py | 574 +++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 574 insertions(+)
 create mode 100644 ipaserver/servroles.py

diff --git a/ipaserver/servroles.py b/ipaserver/servroles.py
new file mode 100644
index 0000000000000000000000000000000000000000..0d5c997c9022fa256a5488772b15514570bc0dc2
--- /dev/null
+++ b/ipaserver/servroles.py
@@ -0,0 +1,574 @@
+#
+# Copyright (C) 2016 FreeIPA Contributors see COPYING for license
+#
+
+
+"""
+This module contains the set of classes which abstract various bits and pieces
+of information present in the LDAP tree about functionalities such as DNS
+server, Active Directory trust controller etc. These properties come in two
+distinct groups:
+
+    server roles
+        this group represents a genral functionality provided by one or more
+        IPA servers, such as DNS server, certificate authority and such. In
+        this case there is a many-to-many mapping between the roles and the
+        masters which provide them.
+
+    server attributes
+        these represent a functionality associated with the whole topology,
+        such as CA renewal master or DNSSec key master.
+
+See the corresponding design page (http://www.freeipa.org/page/V4/Server_Roles)
+for more info.
+
+Both of these groups use `LDAPBasedProperty` class as a base.
+
+Server Roles
+============
+
+Server role objects are usually consuming information from the master's service
+container (cn=FQDN,cn=masters,cn=ipa,cn=etc,$SUFFIX) are represented by
+`ServiceBasedRole`class. To create an instance of such role, you only need to
+specify role name and individual services comprising the role (more systemd
+services may be enabled to provide some function):
+
+>>> example_role = ServiceBasedRole(
+...     "Example Role",
+...     component_services = ['SERVICE1', 'SERVICE2'])
+>>> example_role.name
+'Example Role'
+
+Each role object has an `attr_name` property which returns its name transformed
+into an LDAP-like attribute name useful in higher-level commands implemented in
+API framework
+
+>>> example_role.attr_name
+'ipaexamplerole'
+
+The role object can then be queried for the status of the role in the whole
+topology or on a single master by using its `status` method. This method
+returns a list of dictionaries akin to LDAP entries comprised from server name,
+role name and role status (enabled if role is enabled, configured if the
+service entries are present but not marked as enabled by 'enabledService'
+config string, absent if the service entries are not present).
+
+Note that 'AD trust agent' role is based on membership of the master in the
+'adtrust agents' sysaccount group and is thus an instance of different class
+(`ADTrustBasedRole`). This role also does not have 'configured' status, since
+the master is either member of the group ('enabled') or not ('absent')
+
+Server Attributes
+=================
+
+Server attributes are implemented as instances of `ServerAttribute` class. The
+attribute is defined by some flag set ornf 'ipaConfigString' attribute of some
+service entry. To create your own server attribute, see the following example:
+
+>>> example_attribute = ServerAttribute("Example Attribute", example_role,
+...                                     "SERVICE1", "roleMaster")
+>>> example_attribute.name
+'Example Attribute'
+
+As in the case of serverroles, each instance has the `attr_name` property
+giving LDAP-like naming of the attribute:
+
+>>> example_attribute.attr_name
+'ipaexampleattribute'
+
+The list of all masters with this attribute set can be accessed via `get()`
+method. The attribute masters can be added/removed by the `update()` method
+which accepts a list on new masters as a parameter.
+
+Publicly accesible roles/attributes should be added to the
+role_instances/attribute_instances list
+"""
+
+import abc
+from collections import namedtuple, defaultdict
+
+import six
+
+from ipalib import _, errors
+from ipapython.dn import DN
+
+
+if six.PY3:
+    unicode = str
+
+
+ENABLED = u'enabled'
+CONFIGURED = u'configured'
+ABSENT = u'absent'
+
+
+@six.add_metaclass(abc.ABCMeta)
+class LDAPBasedProperty(object):
+    """
+    base class for all master properties defined by LDAP content
+    :param name: user-friendly name of the property
+    :param attrs_list: list of attributes to retrieve during search, defaults
+        to all
+    """
+    attrs_list = ['*']
+
+    def __init__(self, name):
+        self.name = name
+
+    @property
+    def attr_name(self):
+        """
+        return the attribute name suitable for use as entry attribute in API
+        framework
+        """
+        return 'ipa{}'.format(
+            ''.join(p.lower() for p in self.name.split())
+        )
+
+    def search(self, ldap, search_base, search_filter="(objectclass=*)"):
+        """
+        retrieve LDAP entries used for further processing. Returns nothing if
+        errors.EmptyModlist is raised by `ldap.get_entries()`. NotFound
+        exception (search base does not exist) is not handled.
+        """
+        try:
+            entries = ldap.get_entries(
+                search_base,
+                filter=search_filter,
+                attrs_list=self.attrs_list
+            )
+            return entries
+        except errors.EmptyResult:
+            return []
+
+
+@six.add_metaclass(abc.ABCMeta)
+class BaseServerRole(LDAPBasedProperty):
+    """
+    Server role hierarchy apex. All other server role definition should either
+    inherit from it or at least provide the 'status' method and 'providers'
+    property
+    """
+
+    def create_role_status_dict(self, server, status):
+        """
+        the output of `status()` method should be a list of dictionaries having
+        the following keys:
+            * servrole: name of role
+            * server: server FQDN
+            * status: role status on server
+
+        this methods returns such a dict given server and role status
+        """
+        return {
+            u'servrole': self.name,
+            u'servercn': server,
+            u'status': status}
+
+    @abc.abstractmethod
+    def create_search_params(self, ldap, api_instance, hostname=None):
+        """
+        create search base and filter
+        :param ldap: ldap connection
+        :param api_instance: API instance
+        :param hostname: server FQDN. if given, the method should generate
+        filter and search base matching only the status on this server
+        :returns: tuple of search base (a DN) and search filter
+        """
+        pass
+
+    @abc.abstractmethod
+    def get_result_from_entries(self, entries):
+        """
+        Get role status from returned LDAP entries
+
+        `create_role_status_dict` method
+        :param entries: LDAPEntry objects returned by `search()`
+        :returns: list of dicts generated by `create_role_status_dict()`
+                  method
+        """
+        pass
+
+    def status(self, api_instance, hostname=None):
+        """
+        probe and return status of the role either on single server or on the
+        whole topology
+
+        :param api_instance: API instance
+        :param hostname: server FQDN. If given, only the status of the role on
+                         this master will be returned
+        :returns: * 'enabled' if the role is enabled on the master
+                  * 'configured' if it is not enabled but has
+                    been configured by installer
+                  * 'absent' otherwise
+        """
+        ldap = api_instance.Backend.ldap2
+        search_base, search_filter = self.create_search_params(
+            ldap, api_instance, hostname=hostname)
+
+        entries = self.search(ldap, search_base, search_filter=search_filter)
+
+        if not entries and hostname is not None:
+            return [self.create_role_status_dict(hostname, ABSENT)]
+
+        result = self.get_result_from_entries(entries)
+        return sorted(result, key=lambda x: x[u'servercn'])
+
+
+class ServerAttribute(LDAPBasedProperty):
+    """
+    Class from which server attributes should be instantiated
+
+    :param associated_role: instance of a role which must be enabled
+        on the provider
+    :param associated_service_name: name of LDAP service on which the
+        attribute is set. Does not need to belong to the service entries
+        of associate role
+    :param ipa_config_string_value: value of `ipaConfigString` attribute
+        associated with the presence of server attribute
+    """
+
+    def __init__(self, name, associated_role, associated_service_name,
+                 ipa_config_string_value):
+        super(ServerAttribute, self).__init__(name)
+
+        self.associated_role = associated_role
+        self.associated_service_name = associated_service_name
+        self.ipa_config_string_value = ipa_config_string_value
+
+    def create_search_filter(self, ldap):
+        """
+        Create search filter which matches LDAP data corresponding to the
+        attribute
+        """
+        svc_filter = ldap.make_filter_from_attr(
+            'cn', self.associated_service_name)
+
+        configstring_filter = ldap.make_filter_from_attr(
+            'ipaConfigString', self.ipa_config_string_value)
+        return ldap.combine_filters(
+            [svc_filter, configstring_filter], rules=ldap.MATCH_ALL)
+
+    def get(self, api_instance):
+        """
+        get all masters which have the attribute set
+        :param api_instance: API instance
+        :returns: list of masters providing the attribute
+        """
+        ldap = api_instance.Backend.ldap2
+        search_base = DN(api_instance.env.container_masters,
+                         api_instance.env.basedn)
+
+        search_filter = self.create_search_filter(ldap)
+
+        entries = self.search(ldap, search_base, search_filter=search_filter)
+
+        masters = set(e.dn[1]['cn'] for e in entries)
+
+        associated_role_providers = set(
+            self._get_assoc_role_providers(api_instance))
+
+        if not masters.issubset(associated_role_providers):
+            raise errors.ValidationError(
+                name=self.name,
+                error=_("all masters must have %(role)s role enabled" %
+                        {'role': self.associated_role.name})
+            )
+
+        return sorted(masters)
+
+    def _get_master_dn(self, api_instance, hostname):
+        return DN(('cn', hostname), api_instance.env.container_masters,
+                  api_instance.env.basedn)
+
+    def _get_masters_service_entry(self, ldap, master_dn):
+        service_dn = DN(('cn', self.associated_service_name), master_dn)
+        return ldap.get_entry(service_dn)
+
+    def _add_attribute_to_svc_entry(self, ldap, service_entry):
+        """
+        add the server attribute to the entry of associated service
+
+        :param ldap: LDAP connection object
+        :param service_entry: associated service entry
+        """
+        ipa_config_string = service_entry.get('ipaConfigString', [])
+        service_entry['ipaConfigString'] = ipa_config_string.append(
+            self.ipa_config_string_value)
+
+    def _remove_attribute_from_svc_entry(self, ldap, service_entry):
+        """
+        remove the server attribute to the entry of associated service
+
+        single ipaConfigString attribute is case-insensitive, we must handle
+        arbitrary case of target value
+
+        :param ldap: LDAP connection object
+        :param service_entry: associated service entry
+        """
+        ipa_config_string = service_entry.get('ipaConfigString', [])
+
+        for value in ipa_config_string:
+            if value.lower() == self.ipa_config_string_value.lower():
+                service_entry['ipaConfigString'].remove(value)
+
+        ldap.update_entry(service_entry)
+
+    def _get_assoc_role_providers(self, api_instance):
+        """
+        get list of all servers on which the associated role is enabled
+        """
+        return [
+            r[u'servercn'] for r in self.associated_role.status(api_instance)
+            if r[u'status'] == ENABLED]
+
+    def _remove(self, api_instance, masters):
+        """
+        Remove masters from the attribute
+
+        :param api_instance: API instance
+        :param masters: list of masters to remove
+
+        :raises: AttrValueNotFound if there is an attempt to remove masters
+        which don't have the attribute set
+
+        """
+
+        masters_to_remove = set(masters)
+        curr_masters = set(self.get(api_instance))
+
+        if not masters_to_remove.issubset(curr_masters):
+            raise errors.AttrValueNotFound(
+                attr=self.attr_name, value=','.join(
+                    masters_to_remove.difference(curr_masters)))
+
+        ldap = api_instance.Backend.ldap2
+
+        for hostname in masters:
+            master_dn = self._get_master_dn(api_instance, hostname)
+            service_entry = self._get_masters_service_entry(ldap, master_dn)
+            self._remove_attribute_from_svc_entry(ldap, service_entry)
+
+    def _add(self, api_instance, masters):
+        """
+        add IPA masters as the attribute provider
+        :param api_instance: API instance
+        :param masters: list of masters to add
+
+        :raises: * errors.ValidationError if the associated role is not enabled
+                   on the master
+        """
+
+        assoc_role_providers = self._get_assoc_role_providers(api_instance)
+        ldap = api_instance.Backend.ldap2
+
+        for hostname in masters:
+            if hostname not in assoc_role_providers:
+                raise errors.ValidationError(
+                    name=hostname,
+                    error=_("must have %(role)s role enabled" %
+                            {'role': self.associated_role.name})
+                )
+
+            master_dn = self._get_master_dn(api_instance, hostname)
+            service_entry = self._get_masters_service_entry(ldap, master_dn)
+            self._add_attribute_to_svc_entry(ldap, service_entry)
+
+    def update(self, api_instance, masters):
+        """
+        update attribute masters
+
+        :param api_instance: API instance
+        :param masters: list of new masters
+
+        :returns: new list of masters
+        :raises: errors.EmptyModlist if the new masters are the same as
+                 original ones
+        """
+        old_masters = set(self.get(api_instance))
+        new_masters = set(masters)
+
+        if old_masters == new_masters:
+            raise errors.EmptyModlist
+
+        masters_to_remove = old_masters.difference(new_masters)
+        masters_to_add = new_masters.difference(old_masters)
+
+        self._add(api_instance, masters_to_add)
+
+        self._remove(api_instance, masters_to_remove)
+
+        return sorted(new_masters)
+
+
+_Service = namedtuple('Service', ['name', 'enabled'])
+
+
+class ServiceBasedRole(BaseServerRole):
+    """
+    class for all role instances whose status is defined by presence of one or
+    more entries in LDAP and/or their attributes
+    """
+
+    attrs_list = ('ipaConfigString', 'cn')
+
+    def __init__(self, name, component_services):
+        super(ServiceBasedRole, self).__init__(name)
+
+        self.component_services = component_services
+
+    def _validate_component_services(self, services):
+        svc_set = {s.name for s in services}
+        if svc_set != set(self.component_services):
+            raise ValueError(
+                "{}: Mismatch between component services and search result "
+                "(expected: {}, got: {})".format(
+                    self.__class__.__name__,
+                    ', '.join(sorted(self.component_services)),
+                    ', '.join(sorted(s.name for s in services))))
+
+    def _get_service(self, entry):
+        entry_cn = entry['cn'][0]
+
+        enabled = self._is_service_enabled(entry)
+
+        return _Service(name=entry_cn, enabled=enabled)
+
+    def _is_service_enabled(self, entry):
+        """
+        determine whether the service is enabled based on the presence of
+        enabledService attribute in ipaConfigString attribute.
+        Since the attribute is case-insensitive, we must first lowercase its
+        values and do the comparison afterwards.
+
+        :param entry: LDAPEntry of the service
+        :returns: True if the service entry is enabled, False otherwise
+        """
+        enabled_value = 'enabledservice'
+        ipaconfigstring_values = set(
+            e.lower() for e in entry.get('ipaConfigString', []))
+
+        return enabled_value in ipaconfigstring_values
+
+    def _get_services_by_masters(self, entries):
+        """
+        given list of entries, return a dictionary keyed by master FQDNs which
+        contains list of service entries belonging to the master
+        """
+        services_by_master = defaultdict(list)
+        for e in entries:
+            service = self._get_service(e)
+            master_cn = e.dn[1]['cn']
+
+            services_by_master[master_cn].append(service)
+
+        return services_by_master
+
+    def get_result_from_entries(self, entries):
+        result = []
+        services_by_master = self._get_services_by_masters(entries)
+        for master, services in services_by_master.items():
+            self._validate_component_services(services)
+            status = (
+                ENABLED if all(s.enabled for s in services) else
+                CONFIGURED)
+
+            result.append(self.create_role_status_dict(master, status))
+
+        return result
+
+    def create_search_params(self, ldap, api_instance, hostname=None):
+        search_base = DN(api_instance.env.container_masters,
+                         api_instance.env.basedn)
+
+        search_filter = ldap.make_filter_from_attr(
+            'cn',
+            self.component_services,
+            rules=ldap.MATCH_ANY,
+            exact=True
+        )
+
+        if hostname is not None:
+            search_base = DN(('cn', hostname), search_base)
+
+        return search_base, search_filter
+
+
+class ADtrustBasedRole(BaseServerRole):
+    """
+    Class which should instantiate roles besed on membership in 'adtrust agent'
+    sysaccount group.
+    """
+
+    def get_result_from_entries(self, entries):
+        result = []
+
+        for e in entries:
+            result.append(
+                self.create_role_status_dict(e['fqdn'][0], ENABLED)
+            )
+        return result
+
+    def create_search_params(self, ldap, api_instance, hostname=None):
+        search_base = DN(
+            api_instance.env.container_host, api_instance.env.basedn)
+
+        search_filter = ldap.make_filter_from_attr(
+            "memberof",
+            DN(('cn', 'adtrust agents'), ('cn', 'sysaccounts'),
+               ('cn', 'etc'), api_instance.env.basedn)
+        )
+        if hostname is not None:
+            hostname_filter = ldap.make_filter_from_attr(
+                'fqdn',
+                hostname,
+                exact=True
+            )
+            search_filter = ldap.combine_filters(
+                [search_filter, hostname_filter],
+                rules=ldap.MATCH_ALL
+            )
+
+        return search_base, search_filter
+
+
+ad_trust_agent = ADtrustBasedRole(u"AD trust agent")
+
+ad_trust_controller = ServiceBasedRole(
+    u"AD trust controller", component_services=['ADTRUST'])
+
+ca_server = ServiceBasedRole(u"CA server", component_services=['CA'])
+
+dns_server = ServiceBasedRole(
+    u"DNS server", component_services=['DNS', 'DNSKeySync'])
+
+ipa_master = ServiceBasedRole(
+    u"master", component_services=['HTTP', 'KDC', 'KPASSWD'])
+
+kra_server = ServiceBasedRole(u"KRA server", component_services=['KRA'])
+
+ca_renewal_master = ServerAttribute(
+    u"CA renewal master",
+    ca_server,
+    u"CA",
+    u"caRenewalMaster",
+)
+
+dnssec_key_master = ServerAttribute(
+    u"DNSSec key master",
+    dns_server,
+    u"DNSSEC",
+    u"dnssecKeyMaster",
+)
+
+
+role_instances = [
+    ad_trust_agent,
+    ad_trust_controller,
+    ca_server,
+    dns_server,
+    kra_server,
+    ipa_master
+]
+
+attribute_instances = [ca_renewal_master, dnssec_key_master]
-- 
2.5.5

From 3612360dc8dc54798630a3a60ffff83ec11b10ec Mon Sep 17 00:00:00 2001
From: Martin Babinsky <mbabi...@redhat.com>
Date: Thu, 19 May 2016 11:24:18 +0200
Subject: [PATCH 2/3] Server Roles: Backend plugin to query roles and
 attributes

`serverroles` backend consumes the role/attribute instances defined in
`ipaserver/servroles.py` module to provide low-level API for querying
role/attribute status in the topology. This plugin shall be used to implement
higher-level API commands.

https://www.freeipa.org/page/V4/Server_Roles
https://fedorahosted.org/freeipa/ticket/5181
---
 ipalib/errors.py                 |   1 +
 ipaserver/plugins/serverroles.py | 152 +++++++++++++++++++++++++++++++++++++++
 2 files changed, 153 insertions(+)
 create mode 100644 ipaserver/plugins/serverroles.py

diff --git a/ipalib/errors.py b/ipalib/errors.py
index 93333c2aafd0161944f1a49aa9017e1f1906da8a..548246fac19527c367280798c19dbf3d1c01150e 100644
--- a/ipalib/errors.py
+++ b/ipalib/errors.py
@@ -1392,6 +1392,7 @@ class InvalidDomainLevelError(ExecutionError):
     errno = 4032
     format = _('%(reason)s')
 
+
 class BuiltinError(ExecutionError):
     """
     **4100** Base class for builtin execution errors (*4100 - 4199*).
diff --git a/ipaserver/plugins/serverroles.py b/ipaserver/plugins/serverroles.py
new file mode 100644
index 0000000000000000000000000000000000000000..8dd52cda5bfcd6acc358378d7070456bc5cce78b
--- /dev/null
+++ b/ipaserver/plugins/serverroles.py
@@ -0,0 +1,152 @@
+#
+# Copyright (C) 2016 FreeIPA Contributors see COPYING for license
+#
+
+
+"""
+serverroles backend
+=======================================
+
+The `serverroles` backend has access to all roles and attributes stored in
+module-level lists exposed in `ipaserver/servroles.py` module. It uses these
+lists to populate populate its internal stores with instances of the
+role/attribute classes. The information contained in them can be accessed by
+the following methods:
+
+    *api.Backend.serverroles.server_role_search(hostname=None, role_names=())
+        search for roles matching the given substrings and return
+
+    *api.Backend.serverroles.server_role_retrieve(hostname, role_name)*
+        retrieve the status of a single role on a given master
+
+    *api.Backend.serverroles.config_retrieve(role_name)
+        return a configuration object given role name. This object is a
+        dictionary containing a list of enabled masters and all attributes
+        associated with the role along with master(s) on which they are set.
+
+    *api.Backend.serverroles.config_update(role_name, **attrs_values)
+        update configuration associated with the role. Since the roles are
+        currently considered immutable, only server attributes associated with
+        the role can be modified
+
+Note that attribute/role names are searched/matched case-insensitively. Also
+note that the `serverroles` backend does not create/destroy any LDAP connection
+by itself, so make sure `ldap2` backend connections are taken care of
+in the calling code
+"""
+
+
+import six
+
+from ipalib import errors, _
+from ipalib.backend import Backend
+from ipalib.plugable import Registry
+
+from ipaserver.servroles import (
+    attribute_instances, ENABLED, role_instances)
+
+
+if six.PY3:
+    unicode = str
+
+
+register = Registry()
+
+
+@register()
+class serverroles(Backend):
+    """
+    This Backend can be used to query various information about server roles
+    and attributes configured in the topology.
+    """
+
+    def __init__(self, api_instance):
+        super(serverroles, self).__init__(api_instance)
+
+        self.roles = {r.name.lower(): r for r in role_instances}
+        self.attributes = attribute_instances
+
+    def _get_role(self, role_name):
+        key = role_name.lower()
+
+        try:
+            return self.roles[key]
+        except KeyError:
+            raise errors.NotFound(
+                reason=_("{role}: role not found".format(role=role_name)))
+
+    def _get_enabled_masters(self, role):
+        enabled_masters = [
+            r[u'servercn'] for r in role.status(self.api, hostname=None) if
+            r[u'status'] == ENABLED]
+
+        return {role.attr_name: enabled_masters}
+
+    def _get_assoc_attributes(self, role):
+        assoc_attributes = [attr for attr in self.attributes if
+                            attr.associated_role is role]
+
+        if not assoc_attributes:
+            raise NotImplementedError(
+                "Role {} has no associated attribute to set".format(role.name))
+
+        return assoc_attributes
+
+    def server_role_search(self, hostname=None, role_names=None):
+        found_roles = []
+        if role_names is None:
+            found_roles = self.roles.keys()
+        else:
+            for role_name in role_names:
+                key = role_name.lower()
+                found_roles.extend(
+                    [name for name in self.roles if key in name])
+
+        result = []
+        for found_role in found_roles:
+            status = self.roles[found_role].status(
+                self.api, hostname=hostname)
+            if status:
+                result.extend(status)
+
+        return result
+
+    def server_role_retrieve(self, hostname, role_name):
+        return self._get_role(role_name).status(self.api, hostname=hostname)
+
+    def config_retrieve(self, role_name):
+        role = self._get_role(role_name)
+        result = self._get_enabled_masters(role)
+
+        try:
+            assoc_attributes = self._get_assoc_attributes(role)
+        except NotImplementedError:
+            return result
+
+        result.update(
+            {attr.attr_name: attr.get(self.api) for attr in
+             assoc_attributes})
+
+        return result
+
+    def config_update(self, role_name, **attrs_values):
+        role = self._get_role(role_name)
+        result = self._get_enabled_masters(role)
+
+        assoc_attributes = self._get_assoc_attributes(role)
+
+        for attr in assoc_attributes:
+            try:
+                masters = attrs_values.pop(attr.attr_name)
+            except KeyError:
+                continue
+
+            result.update({attr.attr_name: attr.update(self.api, masters)})
+
+        if attrs_values:
+            raise errors.NotFound(
+                reason=_(
+                    'No such attributes: {}'.format(
+                        ', '.join(sorted(a for a in attrs_values)))))
+
+        return result
-- 
2.5.5

From dfae9c7ca75f29d9569fdbe150d5e85b11896cf1 Mon Sep 17 00:00:00 2001
From: Martin Babinsky <mbabi...@redhat.com>
Date: Thu, 19 May 2016 13:40:12 +0200
Subject: [PATCH 3/3] Test suite for `serverroles` backend

Tests retrieving roles/attributes and setting server attributes in various
scenarios.

https://fedorahosted.org/freeipa/ticket/5181
---
 ipatests/test_ipaserver/test_serverroles.py | 758 ++++++++++++++++++++++++++++
 1 file changed, 758 insertions(+)
 create mode 100644 ipatests/test_ipaserver/test_serverroles.py

diff --git a/ipatests/test_ipaserver/test_serverroles.py b/ipatests/test_ipaserver/test_serverroles.py
new file mode 100644
index 0000000000000000000000000000000000000000..3a723e37dcee80c2c69de2a7c17c421b3ac540ce
--- /dev/null
+++ b/ipatests/test_ipaserver/test_serverroles.py
@@ -0,0 +1,758 @@
+#
+# Copyright (C) 2016 FreeIPA Contributors see COPYING for license
+#
+
+"""
+Tests for the serverroles backend
+"""
+
+from collections import namedtuple
+
+import ldap
+import pytest
+
+from ipalib import api, create_api, errors
+from ipapython.dn import DN
+from ipatests.util import MockLDAP
+
+
+def _make_service_entry_mods(enabled=True, other_config=None):
+    mods = {
+        b'objectClass': [b'top', b'nsContainer', b'ipaConfigObject'],
+    }
+    if enabled:
+        mods.update({b'ipaConfigString': [b'enabledService']})
+
+    if other_config is not None:
+        mods.setdefault(b'ipaConfigString', [])
+        mods[b'ipaConfigString'].extend(other_config)
+
+    return mods
+
+
+def _make_master_entry_mods(ca=False):
+    mods = {
+        b'objectClass': [
+            b'top',
+            b'nsContainer',
+            b'ipaReplTopoManagedServer',
+            b'ipaSupportedDomainLevelConfig',
+            b'ipaConfigObject',
+        ],
+        b'ipaMaxDomainLevel': [b'1'],
+        b'ipaMinDomainLevel': [b'0'],
+        b'ipaReplTopoManagedsuffix': [str(api.env.basedn)]
+    }
+    if ca:
+        mods[b'ipaReplTopoManagedsuffix'].append(b'o=ipaca')
+
+    return mods
+
+_adtrust_agents = DN(
+    ('cn', 'adtrust agents'),
+    ('cn', 'sysaccounts'),
+    ('cn', 'etc'),
+    api.env.basedn
+)
+
+
+master_data = {
+    'ca-dns-dnssec-keymaster': {
+        'services': {
+            'CA': {
+                'enabled': True,
+            },
+            'DNS': {
+                'enabled': True,
+            },
+            'DNSKeySync': {
+                'enabled': True,
+            },
+            'DNSSEC': {
+                'enabled': True,
+                'config': ['DNSSecKeyMaster']
+            }
+        },
+        'expected_roles': {
+            'enabled': ['master', 'CA server', 'DNS server']
+        },
+        'expected_attributes': {'DNS server': 'ipadnsseckeymaster'}
+    },
+    'ca-kra-renewal-master': {
+        'services': {
+            'CA': {
+                'enabled': True,
+                'config': ['caRenewalMaster']
+            },
+            'KRA': {
+                'enabled': True,
+            },
+        },
+        'expected_roles': {
+            'enabled': ['master', 'CA server', 'KRA server']
+        },
+        'expected_attributes': {'CA server': 'ipacarenewalmaster'}
+    },
+    'dns-trust-agent': {
+        'services': {
+            'DNS': {
+                'enabled': True,
+            },
+            'DNSKeySync': {
+                'enabled': True,
+            }
+        },
+        'attributes': {
+            _adtrust_agents: {
+                'member': ['host']
+            }
+        },
+        'expected_roles': {
+            'enabled': ['master', 'DNS server', 'AD trust agent']
+        }
+    },
+    'trust-agent': {
+        'attributes': {
+            _adtrust_agents: {
+                'member': ['host']
+            }
+        },
+        'expected_roles': {
+            'enabled': ['master', 'AD trust agent']
+        }
+    },
+    'trust-controller-dns': {
+        'services': {
+            'ADTRUST': {
+                'enabled': True,
+            },
+            'DNS': {
+                'enabled': True,
+            },
+            'DNSKeySync': {
+                'enabled': True,
+            }
+        },
+        'attributes': {
+            _adtrust_agents: {
+                'member': ['host', 'cifs']
+            }
+        },
+        'expected_roles': {
+            'enabled': ['master', 'AD trust agent', 'AD trust controller',
+                        'DNS server']
+        }
+    },
+    'trust-controller-ca': {
+        'services': {
+            'ADTRUST': {
+                'enabled': True,
+            },
+            'CA': {
+                'enabled': True,
+            },
+        },
+        'attributes': {
+            _adtrust_agents: {
+                'member': ['host', 'cifs']
+            }
+        },
+        'expected_roles': {
+            'enabled': ['master', 'AD trust agent', 'AD trust controller',
+                        'CA server']
+        }
+    },
+    'configured-ca': {
+        'services': {
+            'CA': {
+                'enabled': False,
+            },
+        },
+        'expected_roles': {
+            'enabled': ['master'],
+            'configured': ['CA server']
+        }
+    },
+    'configured-dns': {
+        'services': {
+            'DNS': {
+                'enabled': False,
+            },
+            'DNSKeySync': {
+                'enabled': False,
+            }
+        },
+        'expected_roles': {
+            'enabled': ['master'],
+            'configured': ['DNS server']
+        }
+    },
+    'mixed-state-dns': {
+        'services': {
+            'DNS': {
+                'enabled': False
+            },
+            'DNSKeySync': {
+                'enabled': True
+            }
+        },
+        'expected_roles': {
+            'enabled': ['master'],
+            'configured': ['DNS server']
+        }
+    },
+}
+
+
+class MockMasterTopology(object):
+    """
+    object that will set up and tear down entries in LDAP backend to mimic
+    a presence of real IPA masters with services running on them.
+    """
+
+    ipamaster_services = [u'KDC', u'HTTP', u'KPASSWD']
+
+    def __init__(self, api_instance, domain_data):
+        self.api = api_instance
+
+        self.domain = self.api.env.domain
+        self.domain_data = domain_data
+        self.masters_base = DN(
+            self.api.env.container_masters, self.api.env.basedn)
+
+        self.test_master_dn = DN(
+            ('cn', self.api.env.host), self.api.env.container_masters,
+            self.api.env.basedn)
+
+        self.ldap = MockLDAP()
+
+        self.existing_masters = {
+            m['cn'][0] for m in self.api.Command.server_find(
+                u'', sizelimit=0)['result']}
+
+        self.existing_attributes = self._check_test_host_attributes()
+
+    def iter_domain_data(self):
+        MasterData = namedtuple('MasterData',
+                                ['dn', 'fqdn', 'services', 'attrs'])
+        for name in self.domain_data:
+            fqdn = self.get_fqdn(name)
+            master_dn = self.get_master_dn(name)
+            master_services = self.domain_data[name].get('services', {})
+
+            master_attributes = self.domain_data[name].get('attributes', {})
+
+            yield MasterData(
+                dn=master_dn,
+                fqdn=fqdn,
+                services=master_services,
+                attrs=master_attributes
+            )
+
+    def get_fqdn(self, name):
+        return '.'.join([name, self.domain])
+
+    def get_master_dn(self, name):
+        return DN(('cn', self.get_fqdn(name)), self.masters_base)
+
+    def get_service_dn(self, name, master_dn):
+        return DN(('cn', name), master_dn)
+
+    def _add_host_entry(self, fqdn):
+        try:
+            self.api.Command.host_add(fqdn, force=True)
+            self.api.Command.hostgroup_add_member(u'ipaservers', host=fqdn)
+        except errors.DuplicateEntry:
+            pass
+
+    def _del_host_entry(self, fqdn):
+        try:
+            self.api.Command.host_del(fqdn)
+        except errors.NotFound:
+            pass
+
+    def _add_service_entry(self, service, fqdn):
+        return self.api.Command.service_add(
+            '/'.join([service, fqdn]),
+            force=True
+        )
+
+    def _del_service_entry(self, service, fqdn):
+        try:
+            self.api.Command.service_del(
+                '/'.join([service, fqdn]),
+            )
+        except errors.NotFound:
+            pass
+
+    def _add_svc_entries(self, master_dn, svc_desc):
+        self._add_ipamaster_services(master_dn)
+        for name in svc_desc:
+            svc_dn = self.get_service_dn(name, master_dn)
+            svc_mods = svc_desc[name]
+
+            self.ldap.add_entry(
+                str(svc_dn),
+                _make_service_entry_mods(
+                    enabled=svc_mods['enabled'],
+                    other_config=svc_mods.get('config', None)))
+
+    def _remove_svc_master_entries(self, master_dn):
+        try:
+            entries = self.ldap.connection.search_s(
+                str(master_dn), ldap.SCOPE_SUBTREE
+            )
+        except ldap.NO_SUCH_OBJECT:
+            return
+
+        if entries:
+            entries.sort(key=lambda x: len(x[0]), reverse=True)
+            for entry_dn, attrs in entries:
+                self.ldap.del_entry(str(entry_dn))
+
+    def _add_ipamaster_services(self, master_dn):
+        """
+        add all the service entries which are part of the IPA Master role
+        """
+        for svc_name in self.ipamaster_services:
+            svc_dn = self.get_service_dn(svc_name, master_dn)
+            self.ldap.add_entry(str(svc_dn), _make_service_entry_mods())
+
+    def _add_members(self, dn, fqdn, member_attrs):
+        entry, attrs = self.ldap.connection.search_s(
+            str(dn), ldap.SCOPE_SUBTREE)[0]
+        mods = []
+        value = attrs.get('member', [])
+        mod_op = ldap.MOD_REPLACE
+        if not value:
+            mod_op = ldap.MOD_ADD
+
+        for a in member_attrs:
+
+            if a == 'host':
+                value.append(
+                    str(self.api.Object.host.get_dn(fqdn)))
+            else:
+                result = self._add_service_entry(a, fqdn)['result']
+                value.append(str(result['dn']))
+
+        mods.append(
+            (mod_op, 'member', value)
+        )
+
+        self.ldap.connection.modify_s(str(dn), mods)
+
+    def _remove_members(self, dn, fqdn, member_attrs):
+        entry, attrs = self.ldap.connection.search_s(
+            str(dn), ldap.SCOPE_SUBTREE)[0]
+        mods = []
+        for a in member_attrs:
+            value = set(attrs.get('member', []))
+            if not value:
+                continue
+
+            if a == 'host':
+                try:
+                    value.remove(
+                        str(self.api.Object.host.get_dn(fqdn)))
+                except KeyError:
+                    pass
+            else:
+                try:
+                    value.remove(
+                        str(self.api.Object.service.get_dn(
+                            '/'.join([a, fqdn]))))
+                except KeyError:
+                    pass
+                self._del_service_entry(a, fqdn)
+
+        mods.append(
+            (ldap.MOD_REPLACE, 'member', list(value))
+        )
+
+        try:
+            self.ldap.connection.modify_s(str(dn), mods)
+        except (ldap.NO_SUCH_OBJECT, ldap.NO_SUCH_ATTRIBUTE):
+            pass
+
+    def _check_test_host_attributes(self):
+        existing_attributes = set()
+
+        for service, value, attr_name in (
+                ('CA', 'caRenewalMaster', 'ca renewal master'),
+                ('DNSSEC', 'DNSSecKeyMaster', 'dnssec key master')):
+
+            svc_dn = DN(('cn', service), self.test_master_dn)
+            try:
+                svc_entry = self.api.Backend.ldap2.get_entry(svc_dn)
+            except errors.NotFound:
+                continue
+            else:
+                config_string_val = svc_entry.get('ipaConfigString', [])
+
+                if value in config_string_val:
+                    existing_attributes.add(attr_name)
+
+        return existing_attributes
+
+    def _remove_ca_renewal_master(self):
+        if 'ca renewal master' not in self.existing_attributes:
+            return
+
+        ca_dn = DN(('cn', 'CA'), self.test_master_dn)
+        ca_entry = self.api.Backend.ldap2.get_entry(ca_dn)
+
+        config_string_val = ca_entry.get('ipaConfigString', [])
+        try:
+            config_string_val.remove('caRenewalMaster')
+        except KeyError:
+            return
+
+        ca_entry.update({'ipaConfigString': config_string_val})
+        self.api.Backend.ldap2.update_entry(ca_entry)
+
+    def _restore_ca_renewal_master(self):
+        if 'ca renewal master' not in self.existing_attributes:
+            return
+
+        ca_dn = DN(('cn', 'CA'), self.test_master_dn)
+        ca_entry = self.api.Backend.ldap2.get_entry(ca_dn)
+
+        config_string_val = ca_entry.get('ipaConfigString', [])
+        config_string_val.append('caRenewalMaster')
+
+        ca_entry.update({'ipaConfigString': config_string_val})
+        self.api.Backend.ldap2.update_entry(ca_entry)
+
+    def setup_data(self):
+        self._remove_ca_renewal_master()
+        for master_data in self.iter_domain_data():
+            # create host
+            self._add_host_entry(master_data.fqdn)
+
+            # create master entry first
+            self.ldap.add_entry(
+                str(master_data.dn), _make_master_entry_mods(
+                    ca='CA' in master_data.services))
+
+            # now add service entries
+            self._add_svc_entries(master_data.dn, master_data.services)
+
+            # optionally add some attributes required e.g. by AD trust roles
+            for entry_dn, attrs in master_data.attrs.items():
+                if 'member' in attrs:
+                    self._add_members(
+                        entry_dn,
+                        master_data.fqdn,
+                        attrs['member']
+                    )
+
+    def teardown_data(self):
+        self._restore_ca_renewal_master()
+        for master_data in self.iter_domain_data():
+            # first remove the master entries and service containers
+            self._remove_svc_master_entries(master_data.dn)
+
+            # optionally clean up leftover attributes
+            for entry_dn, attrs in master_data.attrs.items():
+                if 'member' in attrs:
+                    self._remove_members(
+                        entry_dn,
+                        master_data.fqdn,
+                        attrs['member'],
+                    )
+
+            # finally remove host entry
+            self._del_host_entry(master_data.fqdn)
+
+
+@pytest.fixture(scope='module')
+def mock_api(request):
+    test_api = create_api(mode=None)
+    test_api.bootstrap(in_server=True, in_tree=True)
+    test_api.finalize()
+
+    if not test_api.Backend.ldap2.isconnected():
+        test_api.Backend.ldap2.connect()
+
+    return test_api
+
+
+@pytest.fixture(scope='module')
+def mock_masters(request, mock_api):
+    """
+    Populate the LDAP backend with test data
+    """
+
+    if not api.Backend.rpcclient.isconnected():
+        api.Backend.rpcclient.connect()
+
+    master_topo = MockMasterTopology(mock_api, master_data)
+
+    def finalize():
+        master_topo.teardown_data()
+        if api.Backend.rpcclient.isconnected():
+            api.Backend.rpcclient.disconnect()
+
+    request.addfinalizer(finalize)
+
+    master_topo.setup_data()
+
+    return master_topo
+
+
+def enabled_role_iter(master_data):
+    for m, data in master_data.items():
+        for role in data['expected_roles']['enabled']:
+            yield m, role
+
+
+def provided_role_iter(master_data):
+    for m, data in master_data.items():
+        yield m, data['expected_roles']['enabled']
+
+
+def configured_role_iter(master_data):
+    for m, data in master_data.items():
+        if 'configured' in data['expected_roles']:
+            for role in data['expected_roles']['configured']:
+                yield m, role
+
+
+def role_provider_iter(master_data):
+    result = {}
+    for m, data in master_data.items():
+        for role in data['expected_roles']['enabled']:
+            if role not in result:
+                result[role] = []
+
+            result[role].append(m)
+
+    for role_name, masters in result.items():
+        yield role_name, masters
+
+
+def attribute_masters_iter(master_data):
+    for m, data in master_data.items():
+        if 'expected_attributes' in data:
+            for assoc_role, attr in data['expected_attributes'].items():
+                yield m, assoc_role, attr
+
+
+def dns_servers_iter(master_data):
+    for m, data in master_data.items():
+        if "DNS server" in data['expected_roles']['enabled']:
+            yield m
+
+
+@pytest.fixture(params=list(enabled_role_iter(master_data)),
+                ids=['role: {}, master: {}, enabled'.format(role, m)
+                     for m, role in enabled_role_iter(master_data)])
+def enabled_role(request):
+    return request.param
+
+
+@pytest.fixture(params=list(provided_role_iter(master_data)),
+                ids=["{}: {}".format(m, ', '.join(roles)) for m, roles in
+                     provided_role_iter(master_data)])
+def provided_roles(request):
+    return request.param
+
+
+@pytest.fixture(params=list(configured_role_iter(master_data)),
+                ids=['role: {}, master: {}, configured'.format(role, m)
+                     for m, role in configured_role_iter(master_data)])
+def configured_role(request):
+    return request.param
+
+
+@pytest.fixture(params=list(role_provider_iter(master_data)),
+                ids=['{} providers'.format(role_name)
+                     for role_name, m in
+                     role_provider_iter(master_data)])
+def role_providers(request):
+    return request.param
+
+
+@pytest.fixture(params=list(attribute_masters_iter(master_data)),
+                ids=['{} of {}: {}'.format(attr, role, m) for m, role, attr in
+                     attribute_masters_iter(master_data)])
+def attribute_providers(request):
+    return request.param
+
+
+@pytest.fixture(params=list(dns_servers_iter(master_data)),
+                ids=list(dns_servers_iter(master_data)))
+def dns_server(request):
+    return request.param
+
+
+class TestServerRoleStatusRetrieval(object):
+    def retrieve_role(self, master, role, mock_api, mock_masters):
+        fqdn = mock_masters.get_fqdn(master)
+        return mock_api.Backend.serverroles.server_role_retrieve(fqdn, role)
+
+    def find_role(self, role_name, mock_api, mock_masters, master=None):
+        if master is not None:
+            hostname = mock_masters.get_fqdn(master)
+        else:
+            hostname = None
+
+        result = mock_api.Backend.serverroles.server_role_search(
+            hostname=hostname,
+            role_names=(role_name,))
+
+        return [
+            r for r in result if r[u'servercn'] not in
+            mock_masters.existing_masters]
+
+    def get_enabled_roles_on_master(self, master, mock_api, mock_masters):
+        fqdn = mock_masters.get_fqdn(master)
+        result = mock_api.Backend.serverroles.server_role_search(
+            hostname=fqdn, role_names=['']
+        )
+        return sorted(
+            set(r[u'servrole'] for r in result
+                if r[u'status'] == u'enabled'))
+
+    def get_masters_with_enabled_role(self, role_name, mock_api, mock_masters):
+        result = mock_api.Backend.serverroles.server_role_search(
+            hostname=None, role_names=[role_name])
+
+        return sorted(
+            r[u'servercn'] for r in result if r[u'status'] == u'enabled' and
+            r[u'servercn'] not in mock_masters.existing_masters)
+
+    def test_listing_of_enabled_role(
+            self, mock_api, mock_masters, enabled_role):
+        master, role_name = enabled_role
+        result = self.retrieve_role(master, role_name, mock_api, mock_masters)
+
+        assert result[0][u'status'] == u'enabled'
+
+    def test_listing_of_configured_role(
+            self, mock_api, mock_masters, configured_role):
+        master, role_name = configured_role
+        result = self.retrieve_role(master, role_name, mock_api, mock_masters)
+
+        assert result[0][u'status'] == u'configured'
+
+    def test_role_providers(
+            self, mock_api, mock_masters, role_providers):
+        role_name, providers = role_providers
+        expected_masters = sorted(mock_masters.get_fqdn(m) for m in providers)
+
+        actual_masters = self.get_masters_with_enabled_role(
+            role_name, mock_api, mock_masters)
+
+        assert expected_masters == actual_masters
+
+    def test_provided_roles_on_master(
+            self, mock_api, mock_masters, provided_roles):
+        master, expected_roles = provided_roles
+        expected_roles.sort()
+        actual_roles = self.get_enabled_roles_on_master(
+            master, mock_api, mock_masters)
+        assert expected_roles == actual_roles
+
+    def test_unknown_role_status_raises_notfound(self, mock_api, mock_masters):
+        unknown_role = 'IAP maestr'
+        fqdn = mock_masters.get_fqdn('ca-dns-dnssec-keymaster')
+        with pytest.raises(errors.NotFound):
+            mock_api.Backend.serverroles.server_role_retrieve(
+                fqdn, unknown_role)
+
+    def test_substring_search_for_dns_server(self, mock_api, mock_masters,
+                                             dns_server):
+        substring = 'dns'
+
+        result = self.find_role(substring, mock_api, mock_masters,
+                                master=dns_server)
+
+        assert result[0]['status'] == u'enabled'
+
+    def test_empty_substring_queries_all_roles_on_server(self, mock_api,
+                                                         mock_masters):
+        master_name = 'ca-dns-dnssec-keymaster'
+        enabled_roles = master_data[master_name]['expected_roles']['enabled']
+        result = self.find_role('', mock_api, mock_masters,
+                                master=master_name)
+
+        for r in result:
+            if r[u'servrole'] in enabled_roles:
+                assert r[u'status'] == u'enabled'
+            else:
+                assert r[u'status'] == u'absent'
+
+    def test_invalid_substring_search_returns_nothing(self, mock_api,
+                                                      mock_masters):
+        invalid_substr = 'fwfgbb'
+
+        assert (self.find_role(invalid_substr, mock_api, mock_masters,
+                               'ca-dns-dnssec-keymaster') == [])
+
+
+class TestServerAttributes(object):
+    def config_retrieve(self, assoc_role_name, mock_api):
+        return mock_api.Backend.serverroles.config_retrieve(
+            assoc_role_name)
+
+    def config_update(self, assoc_role_name, mock_api, **attrs_values):
+        return mock_api.Backend.serverroles.config_update(
+            assoc_role_name, **attrs_values
+        )
+
+    def test_attribute_master(self, mock_api, mock_masters,
+                              attribute_providers):
+        master, assoc_role, attr_name = attribute_providers
+        fqdn = mock_masters.get_fqdn(master)
+        actual_attr_masters = self.config_retrieve(
+            assoc_role, mock_api)[attr_name]
+
+        assert actual_attr_masters == [fqdn]
+
+    def test_set_attribute_on_the_same_provider_raises_emptymodlist(
+            self, mock_api, mock_masters):
+        attr_name = "ipacarenewalmaster"
+        role_name = "CA server"
+
+        existing_renewal_master = self.config_retrieve(
+            role_name, mock_api)[attr_name]
+
+        with pytest.raises(errors.EmptyModlist):
+            self.config_update(
+                role_name, mock_api, **{attr_name: existing_renewal_master})
+
+    def test_set_attribute_on_master_without_assoc_role_raises_validationerror(
+            self, mock_api, mock_masters):
+        attr_name = "ipacarenewalmaster"
+        role_name = "CA server"
+        non_ca_fqdn = mock_masters.get_fqdn('trust-controller-dns')
+
+        with pytest.raises(errors.ValidationError):
+            self.config_update(role_name, mock_api,
+                               **{attr_name: [non_ca_fqdn]})
+
+    def test_set_unknown_attribute_on_master_raises_notfound(
+            self, mock_api, mock_masters):
+        attr_name = "ipacarenuwalmaztah"
+        role_name = "CA server"
+        fqdn = mock_masters.get_fqdn('trust-controller-ca')
+
+        with pytest.raises(errors.NotFound):
+            self.config_update(role_name, mock_api, **{attr_name: [fqdn]})
+
+    def test_set_ca_renewal_master_on_other_ca_and_back(self, mock_api,
+                                                        mock_masters):
+        attr_name = "ipacarenewalmaster"
+        role_name = "CA server"
+        original_renewal_master = self.config_retrieve(
+            role_name, mock_api)[attr_name][0]
+
+        other_ca_server = mock_masters.get_fqdn('trust-controller-ca')
+
+        for host in (other_ca_server, original_renewal_master):
+            result = self.config_update(role_name, mock_api,
+                                        **{attr_name: [host]})
+
+            assert result[attr_name] == [host]
-- 
2.5.5

-- 
Manage your subscription for the Freeipa-devel mailing list:
https://www.redhat.com/mailman/listinfo/freeipa-devel
Contribute to FreeIPA: http://www.freeipa.org/page/Contribute/Code

Reply via email to