]> Frank Brehm's Git Trees - pixelpark/create-vmware-tpl.git/commitdiff
Start LDAP querying for authkeys of root 2.6.0
authorFrank Brehm <frank@brehm-online.com>
Tue, 28 Jun 2022 15:47:42 +0000 (17:47 +0200)
committerFrank Brehm <frank@brehm-online.com>
Tue, 28 Jun 2022 15:47:42 +0000 (17:47 +0200)
lib/cr_vmware_tpl/__init__.py
lib/cr_vmware_tpl/app.py
lib/cr_vmware_tpl/config.py
lib/cr_vmware_tpl/handler.py
requirements.txt

index 1b8d05667f8bff178f4eb23239e71d39b8b8c70c..d35d3959934c3acc4f755f3be47dc344ed775856 100644 (file)
@@ -3,10 +3,11 @@
 
 import time
 
-__version__ = '2.5.1'
+__version__ = '2.6.0'
 
 DEFAULT_CONFIG_DIR = 'pixelpark'
 DEFAULT_DISTRO_ARCH = 'x86_64'
+MAX_PORT_NUMBER = (2 ** 16) - 1
 
 # -------------------------------------------------------------------------
 def print_section_start(name, header=None, collapsed=False):
index 86967955c1cce255fc7b5d8381ad75cf1c2a4e85..966bbc09ee0413e5f419eda541f4cc230e799038 100644 (file)
@@ -38,7 +38,7 @@ from .xlate import __base_dir__ as __xlate_base_dir__
 from .xlate import __mo_file__ as __xlate_mo_file__
 from .xlate import XLATOR, LOCALE_DIR, DOMAIN
 
-__version__ = '1.5.0'
+__version__ = '1.5.1'
 LOG = logging.getLogger(__name__)
 
 _ = XLATOR.gettext
@@ -377,10 +377,13 @@ class CrTplApplication(FbConfigApplication):
 
         try:
             ret = self.handler()
+            self.handler = None
             self.exit(ret)
         except ExpectedHandlerError as e:
             self.handle_error(str(e), _("Temporary VM"))
             self.exit(5)
+        finally:
+            self.handler = None
 
 
 # =============================================================================
index 7654c2e37b1cf95c7a8066e25ca36bb67d2e3536..b1869f64bb0f774ed37f42cee0b6fb054885b94a 100644 (file)
@@ -18,8 +18,8 @@ import crypt
 from pathlib import Path
 
 # Own modules
-from fb_tools.common import is_sequence, pp
-from fb_tools.obj import FbGenericBaseObject
+from fb_tools.common import is_sequence, pp, to_bool
+from fb_tools.obj import FbGenericBaseObject, FbBaseObject
 from fb_tools.collections import CIStringSet
 from fb_tools.multi_config import MultiConfigError, BaseMultiConfig
 from fb_tools.multi_config import DEFAULT_ENCODING
@@ -27,16 +27,25 @@ from fb_tools.xlate import format_list
 
 from fb_vmware.config import VSPhereConfigInfo
 
-from . import DEFAULT_CONFIG_DIR, DEFAULT_DISTRO_ARCH
+from . import DEFAULT_CONFIG_DIR, DEFAULT_DISTRO_ARCH, MAX_PORT_NUMBER
 
 from .xlate import XLATOR
 
-__version__ = '1.9.5'
+__version__ = '2.0.0'
 LOG = logging.getLogger(__name__)
 
 _ = XLATOR.gettext
 ngettext = XLATOR.ngettext
 
+DEFAULT_PORT_LDAP = 389
+DEFAULT_PORT_LDAPS = 636
+DEFAULT_TIMEOUT = 20
+MAX_TIMEOUT = 3600
+DEFAULT_ADMIN_FILTER = (
+    '(&(inetuserstatus=active)(mailuserstatus=active)(objectclass=pppixelaccount)(mail=*)'
+    '(memberOf=cn=Administratoren Pixelpark Berlin,ou=Groups,o=Pixelpark,o=isp))'
+)
+
 
 # =============================================================================
 class CrTplConfigError(MultiConfigError):
@@ -46,6 +55,295 @@ class CrTplConfigError(MultiConfigError):
     pass
 
 
+# =============================================================================
+class LdapConnectionInfo(FbBaseObject):
+    """Encapsulating all necessary data to connect to a LDAP server."""
+
+    re_host_key = re.compile(r'^\s*(?:host|server)\s*$', re.IGNORECASE)
+    re_ldaps_key = re.compile(r'^\s*(?:use[_-]?)?(?:ldaps|ssl)\s*$', re.IGNORECASE)
+    re_port_key = re.compile(r'^\s*port\s*$', re.IGNORECASE)
+    re_base_dn_key = re.compile(r'^\s*base[_-]*dn\s*$', re.IGNORECASE)
+    re_bind_dn_key = re.compile(r'^\s*bind[_-]*dn\s*$', re.IGNORECASE)
+    re_bind_pw_key = re.compile(r'^\s*bind[_-]*pw\s*$', re.IGNORECASE)
+    re_admin_filter_key = re.compile(r'^\s*(?:admin[_-]?)?filter\s*$', re.IGNORECASE)
+
+    # -------------------------------------------------------------------------
+    def __init__(
+        self, appname=None, verbose=0, version=__version__, base_dir=None,
+            host=None, use_ldaps=False, port=DEFAULT_PORT_LDAP, base_dn=None,
+            bind_dn=None, bind_pw=None, admin_filter=None, initialized=False):
+
+        self._host = None
+        self._use_ldaps = False
+        self._port = DEFAULT_PORT_LDAP
+        self._base_dn = None
+        self._bind_dn = None
+        self._bind_pw = None
+        self._admin_filter = DEFAULT_ADMIN_FILTER
+
+        super(LdapConnectionInfo, self).__init__(
+            appname=appname, verbose=verbose, version=version, base_dir=base_dir,
+            initialized=False)
+
+        self.host = host
+        self.use_ldaps = use_ldaps
+        self.port = port
+        if base_dn:
+            self.base_dn = base_dn
+        self.bind_dn = bind_dn
+        self.bind_pw = bind_pw
+        if admin_filter:
+            self.admin_filter = admin_filter
+
+        if initialized:
+            self.initialized = True
+
+    # -------------------------------------------------------------------------
+    def as_dict(self, short=True):
+        """
+        Transforms the elements of the object into a dict
+
+        @param short: don't include local properties in resulting dict.
+        @type short: bool
+
+        @return: structure as dict
+        @rtype:  dict
+        """
+
+        res = super(LdapConnectionInfo, self).as_dict(short=short)
+
+        res['host'] = self.host
+        res['use_ldaps'] = self.use_ldaps
+        res['port'] = self.port
+        res['base_dn'] = self.base_dn
+        res['bind_dn'] = self.bind_dn
+        res['bind_pw'] = None
+        res['schema'] = self.schema
+        res['url'] = self.url
+        res['admin_filter'] = self.admin_filter
+
+        if self.bind_pw:
+            if self.verbose > 4:
+                res['bind_pw'] = self.bind_pw
+            else:
+                res['bind_pw'] = '******'
+
+        return res
+
+    # -----------------------------------------------------------
+    @property
+    def host(self):
+        """The host name (or IP address) of the LDAP server."""
+        return self._host
+
+    @host.setter
+    def host(self, value):
+        if value is None or str(value).strip() == '':
+            self._host = None
+            return
+        self._host = str(value).strip().lower()
+
+    # -----------------------------------------------------------
+    @property
+    def use_ldaps(self):
+        """Should there be used LDAPS for communicating with the LDAP server?"""
+        return self._use_ldaps
+
+    @use_ldaps.setter
+    def use_ldaps(self, value):
+        self._use_ldaps = to_bool(value)
+
+    # -----------------------------------------------------------
+    @property
+    def port(self):
+        "The TCP port number of the LDAP server."
+        return self._port
+
+    @port.setter
+    def port(self, value):
+        v = int(value)
+        if v < 1 or v > MAX_PORT_NUMBER:
+            raise CrTplConfigError(_("Invalid port {!r} for LDAP server given.").format(value))
+        self._port = v
+
+    # -----------------------------------------------------------
+    @property
+    def base_dn(self):
+        """The DN used to connect to the LDAP server, anonymous bind is used, if
+            this DN is empty or None."""
+        return self._base_dn
+
+    @base_dn.setter
+    def base_dn(self, value):
+        if value is None or str(value).strip() == '':
+            msg = _("An empty Base DN for LDAP searches is not allowed.")
+            raise CrTplConfigError(msg)
+        self._base_dn = str(value).strip()
+
+    # -----------------------------------------------------------
+    @property
+    def bind_dn(self):
+        """The DN used to connect to the LDAP server, anonymous bind is used, if
+            this DN is empty or None."""
+        return self._bind_dn
+
+    @bind_dn.setter
+    def bind_dn(self, value):
+        if value is None or str(value).strip() == '':
+            self._bind_dn = None
+            return
+        self._bind_dn = str(value).strip()
+
+    # -----------------------------------------------------------
+    @property
+    def bind_pw(self):
+        """The password of the DN used to connect to the LDAP server."""
+        return self._bind_pw
+
+    @bind_pw.setter
+    def bind_pw(self, value):
+        if value is None or str(value).strip() == '':
+            self._bind_pw = None
+            return
+        self._bind_pw = str(value).strip()
+
+    # -----------------------------------------------------------
+    @property
+    def admin_filter(self):
+        """The LDAP filter to get  the list of administrators from LDAP."""
+        return self._admin_filter
+
+    @admin_filter.setter
+    def admin_filter(self, value):
+        if value is None or str(value).strip() == '':
+            self._admin_filter = None
+            return
+        self._admin_filter = str(value).strip()
+
+    # -----------------------------------------------------------
+    @property
+    def schema(self):
+        """The schema as part of the URL to connect to the LDAP server."""
+        if self.use_ldaps:
+            return 'ldaps'
+        return 'ldap'
+
+    # -----------------------------------------------------------
+    @property
+    def url(self):
+        """The URL, which ca be used to connect to the LDAP server."""
+        if not self.host:
+            return None
+
+        port = ''
+        if self.use_ldaps:
+            if self.port != DEFAULT_PORT_LDAPS:
+                port = ':{}'.format(self.port)
+        else:
+            if self.port != DEFAULT_PORT_LDAP:
+                port = ':{}'.format(self.port)
+
+        return '{s}://{h}{p}'.format(s=self.schema, h=self.host, p=port)
+
+    # -------------------------------------------------------------------------
+    def __repr__(self):
+        """Typecasting into a string for reproduction."""
+
+        out = "<%s(" % (self.__class__.__name__)
+
+        fields = []
+        fields.append("appname={!r}".format(self.appname))
+        fields.append("host={!r}".format(self.host))
+        fields.append("use_ldaps={!r}".format(self.use_ldaps))
+        fields.append("port={!r}".format(self.port))
+        fields.append("base_dn={!r}".format(self.base_dn))
+        fields.append("bind_dn={!r}".format(self.bind_dn))
+        fields.append("bind_pw={!r}".format(self.bind_pw))
+        fields.append("admin_filter={!r}".format(self.admin_filter))
+        fields.append("initialized={!r}".format(self.initialized))
+
+        out += ", ".join(fields) + ")>"
+        return out
+
+    # -------------------------------------------------------------------------
+    def __copy__(self):
+
+        new = self.__class__(
+            appname=self.appname, verbose=self.verbose, base_dir=self.base_dir, host=self.host,
+            use_ldaps=self.use_ldaps, port=self.port, base_dn=self.base_dn, bind_dn=self.bind_dn,
+            bind_pw=self.bind_pw, admin_filter=self.admin_filter, initialized=self.initialized)
+
+        return new
+
+    # -------------------------------------------------------------------------
+    @classmethod
+    def init_from_config(cls, name, data, appname=None, verbose=0, base_dir=None):
+
+        new = cls(appname=appname, verbose=verbose, base_dir=base_dir)
+
+        s_name = "ldap:" + name
+        msg_invalid = _("Invalid value {val!r} in section {sec!r} for a LDAP {what}.")
+
+        for key in data.keys():
+            value = data[key]
+
+            if cls.re_host_key.match(key):
+                if value.strip():
+                    new.host = value
+                else:
+                    msg = msg_invalid.format(val=value, sec=s_name, what='host')
+                    LOG.error(msg)
+                continue
+
+            if cls.re_ldaps_key.match(key):
+                new.use_ldaps = value
+                continue
+
+            if cls.re_port_key.match(key):
+                port = DEFAULT_PORT_LDAP
+                try:
+                    port = int(value)
+                except (ValueError, TypeError) as e:
+                    msg = msg_invalid.format(val=value, sec=s_name, what='port')
+                    msg += ' ' + str(e)
+                    LOG.error(msg)
+                    continue
+                if port <= 0 or port > MAX_PORT_NUMBER:
+                    msg = msg_invalid.format(val=value, sec=s_name, what='port')
+                    LOG.error(msg)
+                    continue
+                new.port = port
+                continue
+
+            if cls.re_base_dn_key.match(key):
+                if value.strip():
+                    new.base_dn = value
+                else:
+                    msg = msg_invalid.format(val=value, sec=s_name, what='base_dn')
+                    LOG.error(msg)
+                continue
+
+            if cls.re_bind_dn_key.match(key):
+                new.bind_dn = value
+                continue
+
+            if cls.re_bind_pw_key.match(key):
+                new.bind_pw = value
+                continue
+
+            if cls.re_admin_filter_key.match(key):
+                new.admin_filter = value
+                continue
+
+            msg = _("Unknown LDAP configuration key {key} found in section {sec!r}.").format(
+                key=key, sec=s_name)
+            LOG.error(msg)
+
+        new.initialized = True
+
+        return new
+
+
 # =============================================================================
 class CobblerDistroInfo(FbGenericBaseObject):
     """Class for encapsulation all necessary data of a Repo definition in Cobbler."""
@@ -358,6 +656,40 @@ class CobblerDistroInfo(FbGenericBaseObject):
         return new
 
 
+# =============================================================================
+class LdapConnectionDict(dict, FbGenericBaseObject):
+    """A dictionary containing LdapConnectionInfo as values and their names as keys."""
+
+    # -------------------------------------------------------------------------
+    def as_dict(self, short=True):
+        """
+        Transforms the elements of the object into a dict
+
+        @param short: don't include local properties in resulting dict.
+        @type short: bool
+
+        @return: structure as dict
+        @rtype:  dict
+        """
+
+        res = super(LdapConnectionDict, self).as_dict(short=short)
+
+        for key in self.keys():
+            res[key] = self[key].as_dict(short=short)
+
+        return res
+
+    # -------------------------------------------------------------------------
+    def __copy__(self):
+
+        new = self.__class__()
+
+        for key in self.keys():
+            new[key] = copy.copy(self[key])
+
+        return new
+
+
 # =============================================================================
 class CrTplConfiguration(BaseMultiConfig):
     """
@@ -440,6 +772,14 @@ class CrTplConfiguration(BaseMultiConfig):
 
     default_swap_size_mb = 512
 
+    default_ldap_server = 'prd-ds.pixelpark.com'
+    use_ssl_on_default = True
+    default_ldap_port = DEFAULT_PORT_LDAPS
+    default_base_dn = 'o=isp'
+    default_bind_dn = 'uid=readonly,ou=People,o=isp'
+
+    re_ldap_section_w_name = re.compile(r'^\s*ldap\s*:\s*(\S+)')
+
     # -------------------------------------------------------------------------
     def __init__(
         self, appname=None, verbose=0, version=__version__, base_dir=None,
@@ -459,6 +799,8 @@ class CrTplConfiguration(BaseMultiConfig):
             add_stems.append('mail')
         if 'cobbler-repos' not in add_stems:
             add_stems.append('cobbler-distros')
+        if 'ldap' not in add_stems:
+            add_stems.append('ldap')
 
         self.os_id = self.default_os_id
 
@@ -520,6 +862,8 @@ class CrTplConfiguration(BaseMultiConfig):
             config_dir = DEFAULT_CONFIG_DIR
         LOG.debug("Config dir: {!r}.".format(config_dir))
 
+        self.ldap_timeout = DEFAULT_TIMEOUT
+
         super(CrTplConfiguration, self).__init__(
             appname=appname, verbose=verbose, version=version, base_dir=base_dir,
             append_appname_to_stems=append_appname_to_stems, config_dir=config_dir,
@@ -536,6 +880,16 @@ class CrTplConfiguration(BaseMultiConfig):
 
         self.private_ssh_key = str(self.base_dir.joinpath('keys', self.ssh_privkey))
 
+        self.ldap_connection = LdapConnectionDict()
+
+        default_connection = LdapConnectionInfo(
+            appname=self.appname, verbose=self.verbose, base_dir=self.base_dir,
+            host=self.default_ldap_server, use_ldaps=self.use_ssl_on_default,
+            port=self.default_ldap_port, base_dn=self.default_base_dn,
+            bind_dn=self.default_bind_dn, initialized=False)
+
+        self.ldap_connection['default'] = default_connection
+
         if initialized:
             self.initialized = True
 
@@ -740,35 +1094,87 @@ class CrTplConfiguration(BaseMultiConfig):
         re_cobbler_distros = re.compile(r'^\s*cobbler[_-]?distros\s*$', re.IGNORECASE)
         re_cobbler_repos = re.compile(r'^\s*cobbler[_-]?repos\s*$', re.IGNORECASE)
 
+        sn = section_name.lower()
+        section = self.cfg[section_name]
+
         LOG.debug(_("Evaluating section {!r} ...").format(section_name))
+        if self.verbose > 2:
+            LOG.debug(_("Content of section:") + '\n' + pp(section))
 
         super(CrTplConfiguration, self).eval_section(section_name)
 
-        sn = section_name.lower()
-        section = self.cfg[section_name]
-
         if sn == 'vsphere':
             self._eval_config_vsphere(section_name, section)
             return
+
         if sn == 'template':
             self._eval_config_template(section_name, section)
             return
+
         if sn == 'timeouts':
             self._eval_config_timeouts(section_name, section)
             return
+
         if sn == 'cobbler':
             self._eval_config_cobbler(section_name, section)
             return
+
         if re_cobbler_distros.match(section_name):
             self._eval_cobbler_distros(section_name, section)
             return
+
         if re_cobbler_repos.match(section_name):
             self._eval_cobbler_repos(section_name, section)
             return
 
+        if sn == 'ldap':
+            for key in section.keys():
+                sub = section[key]
+                if key.lower().strip() == 'timeout':
+                    self._eval_ldap_timeout(sub)
+                    continue
+                self._eval_ldap_connection(key, sub)
+            return
+
+        match = self.re_ldap_section_w_name.match(sn)
+        if match:
+            connection_name = match.group(1)
+            self._eval_ldap_connection(connection_name, section)
+            return
+
         if self.verbose > 1:
             LOG.debug(_("Unhandled configuration section {!r}.").format(section_name))
 
+    # -------------------------------------------------------------------------
+    def _eval_ldap_timeout(self, value):
+
+        timeout = DEFAULT_TIMEOUT
+        msg_invalid = _("Value {!r} for a timeout is invalid.")
+
+        try:
+            timeout = int(value)
+        except (ValueError, TypeError) as e:
+            msg = msg_invalid.format(value)
+            msg += ': ' + str(e)
+            LOG.error(msg)
+            return
+        if timeout <= 0 or timeout > MAX_TIMEOUT:
+            msg = msg_invalid.format(value)
+            LOG.error(msg)
+            return
+
+        self.ldap_timeout = timeout
+
+    # -------------------------------------------------------------------------
+    def _eval_ldap_connection(self, connection_name, section):
+
+        connection = LdapConnectionInfo.init_from_config(
+            connection_name, section,
+            appname=self.appname, verbose=self.verbose, base_dir=self.base_dir,
+        )
+
+        self.ldap_connection[connection_name] = connection
+
     # -------------------------------------------------------------------------
     def _eval_config_vsphere(self, section_name, section):
 
index 5a6e3f2abcd35c14ebde1d3f9a7f0527f45700da..155f78dd686271f64170bc88048e09550cec3727 100644 (file)
@@ -24,6 +24,10 @@ import paramiko
 
 from pyVmomi import vim
 
+import ldap3
+from ldap3.core.exceptions import LDAPInvalidDnError, LDAPInvalidValueError
+from ldap3.core.exceptions import LDAPException, LDAPBindError
+
 # Own modules
 
 from fb_tools.common import pp, to_str
@@ -43,13 +47,13 @@ from fb_vmware.datastore import VsphereDatastore
 
 from . import print_section_start, print_section_end
 
-from .config import CrTplConfiguration
+from .config import CrTplConfiguration, DEFAULT_PORT_LDAP, DEFAULT_PORT_LDAPS
 
 from .cobbler import Cobbler
 
 from .xlate import XLATOR
 
-__version__ = '2.1.3'
+__version__ = '2.2.0'
 
 LOG = logging.getLogger(__name__)
 TZ = pytz.timezone('Europe/Berlin')
@@ -149,6 +153,8 @@ class CrTplHandler(BaseHandler):
         self.abort = False
         self.postinstall_errors = None
         self.cobbler = None
+        self.ldap = None
+        self.ldap_server = None
 
         self.vsphere = VsphereConnection(
             self.cfg.vsphere_info, cluster=self.cfg.vsphere_cluster,
@@ -222,6 +228,71 @@ class CrTplHandler(BaseHandler):
 
         return res
 
+    # -------------------------------------------------------------------------
+    def connect_ldap(self):
+
+        ldap_config = self.cfg.ldap_connection['default']
+
+        server_opts = {}
+        if ldap_config.use_ldaps:
+            server_opts['use_ssl'] = True
+            if ldap_config.port != DEFAULT_PORT_LDAPS:
+                server_opts['port'] = ldap_config.port
+        else:
+            server_opts['use_ssl'] = False
+            if ldap_config.port != DEFAULT_PORT_LDAP:
+                server_opts['port'] = ldap_config.port
+
+        server_opts['get_info'] = ldap3.DSA
+        server_opts['mode'] = ldap3.IP_V4_PREFERRED
+        server_opts['connect_timeout'] = self.cfg.ldap_timeout
+
+        LOG.info(_("Connecting to LDAP server {!r} ...").format(ldap_config.url))
+
+        if self.verbose > 1:
+            msg = _("Connect options to LDAP server {!r}:").format(ldap_config.url)
+            msg += '\n' + pp(server_opts)
+            LOG.debug(msg)
+
+        self.ldap_server = ldap3.Server(ldap_config.host, **server_opts)
+
+        if self.verbose > 2:
+            LOG.debug("LDAP server {s}: {re}".format(
+                s=ldap_config.host, re=repr(self.ldap_server)))
+
+        self.ldap = ldap3.Connection(
+            self.ldap_server, ldap_config.bind_dn, ldap_config.bind_pw,
+            client_strategy=ldap3.SAFE_SYNC, auto_bind=True)
+
+        if self.verbose > 2:
+            msg = _("Info about LDAP server {}:").format(ldap_config.url)
+            msg += '\n' + repr(self.ldap_server.info)
+            LOG.debug(msg)
+
+    # -------------------------------------------------------------------------
+    def disconnect_ldap(self):
+
+        if 'default' in self.cfg.ldap_connection:
+            ldap_config = self.cfg.ldap_connection['default']
+            ldap_server = ldap_config.url
+        else:
+            ldap_server = 'unknown'
+
+        if self.ldap:
+            LOG.info(_("Unbinding from LDAP server {} ...").format(ldap_server))
+            self.ldap.unbind()
+            self.ldap = None
+
+        if self.ldap_server:
+            LOG.info(_("Disconnecting from LDAP server {} ...").format(ldap_server))
+            self.ldap_server = None
+
+    # -------------------------------------------------------------------------
+    def __del__(self):
+        """Destructor."""
+
+        self.disconnect_ldap()
+
     # -------------------------------------------------------------------------
     def __call__(self):
         """Executing the underlying action."""
@@ -267,6 +338,7 @@ class CrTplHandler(BaseHandler):
         self.cobbler.get_cobbler_version()
         self.check_for_cobbler_distro()
         self.cobbler.ensure_profile_ks()
+        self.create_root_authkeys()
         return 0
         self.cobbler.ensure_profile()
         self.cobbler.ensure_root_authkeys()
@@ -1183,6 +1255,16 @@ class CrTplHandler(BaseHandler):
         LOG.debug(_("Object {!r} is now a VMWare template.").format(self.cfg.template_name))
         print_section_end('rename_and_change_vm')
 
+    # -------------------------------------------------------------------------
+    def create_root_authkeys(self):
+
+        LOG.info(_("Creating authorized keys of root from LDAP ..."))
+
+        try:
+            self.connect_ldap()
+        finally:
+            self.disconnect_ldap()
+
 
 # =============================================================================
 if __name__ == "__main__":
index dda7db9f95f882e940d4d4d24ad060ab518b379a..718f7c9db1b39bc7dccbdccef7e4d714f53225da 100644 (file)
@@ -8,6 +8,7 @@ PyYAML
 toml
 hjson
 jinja2
+ldap3
 fb_logging
 fb_tools
 fb_vmware