]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/ansible: TLS Mutual Authentication 27512/head
authorJuan Miguel Olmo Martínez <jolmomar@redhat.com>
Thu, 11 Apr 2019 08:51:50 +0000 (10:51 +0200)
committerJuan Miguel Olmo Martínez <jolmomar@redhat.com>
Wed, 29 May 2019 14:54:56 +0000 (16:54 +0200)
- Changes needed to allow Ansible Orchestrator to use the new authentication strategy used in Ansible Runner Service
- Changes to propagate Ansible playbook errors to the completion result

Addressed changes suggested by the team

- Certificate and key are stored now in the mon KV store
- Option server_url is now server_location
- Using manager Options to have a better mgmt of MODULE_OPTIONS
- Added verbosity to status command to show problems connecting with external orchestrator
- lint problems fixed

Addressed changes suggested by @sebastian-philipp

- Improved messages and documentation

Fix error in documentation

- Fix error in ansible documentation
- Added examples in orchestrator-cli documentation

Signed-off-by: Juan Miguel Olmo Martínez <jolmomar@redhat.com>
doc/mgr/ansible.rst
doc/mgr/orchestrator_cli.rst
src/pybind/mgr/ansible/ansible_runner_svc.py
src/pybind/mgr/ansible/module.py
src/pybind/mgr/ansible/output_wizards.py
src/pybind/mgr/ansible/tests/test_client_playbooks.py

index e81e67baf306520b1b80e9ebfb37f3234fd0dc7f..c992e65a1a89da9324942bddcf10c9f57cb87057 100644 (file)
@@ -5,14 +5,14 @@
 Ansible Orchestrator
 ====================
 
-This module is a :ref:`Ceph orchestrator <orchestrator-modules>` module that uses `Ansible Runner Service <https://github.com/pcuzner/ansible-runner-service>`_ (a RESTful API server) to execute Ansible playbooks in order to satisfy the different operations supported.
+This module is a :ref:`Ceph orchestrator <orchestrator-modules>` module that uses `Ansible Runner Service <https://github.com/ansible/ansible-runner-service>`_ (a RESTful API server) to execute Ansible playbooks in order to satisfy the different operations supported.
 
 These operations basically (and for the moment) are:
 
 - Get an inventory of the Ceph cluster nodes and all the storage devices present in each node
+- Hosts management
+- Create/remove OSD's
 - ...
-- ...
-
 
 Usage
 =====
@@ -41,25 +41,58 @@ Enable the Ansible orchestrator module and use it with the :ref:`CLI <orchestrat
 Configuration
 =============
 
+The external Ansible Runner Service uses TLS mutual authentication to allow clients to use the API.
+A client certificate and a key files should be provided by the Administrator of the Ansible Runner Service for each manager node.
+This files should be copied in each of the manager nodes with read access for the ceph user.
+The destination folder for this files and the name of the files must be the same always in all the manager nodes,
+althought the certificate/key content of this files logically will be different in each node.
+
 Configuration must be set once the module is enabled by first time.
 
 This can be done in one monitor node via the configuration key facility on a
-cluster-wide level (so they apply to all manager instances) as follows::
+cluster-wide level (so they apply to all manager instances) as follows:
+
+In first place, configure the Ansible Runner Service client certificate and key:
+
+::
+
+    If the provided client certificate is usable for all servers, apply it using:
+    # ceph ansible set-ssl-certificate -i <location_of_the_crt_file>
+    # ceph ansible set-ssl-certificate-key -i <location_of_the_key_file>
+
+
+::
+
+    If the client certificate provided is for an especific manager server use:
+    # ceph ansible set-ssl-certificate <server> -i <location_of_the_crt_file>
+    # ceph ansible set-ssl-certificate-key <server> -i <location_of_the_key_file>
+
+
+
+After setting the client certificate and key files, finish the configuration as follows:
+
+::
+
+    # ceph config set mgr mgr/ansible/server_location <ip_address/server_name>:<port>
+    # ceph config set mgr mgr/ansible/verify_server <False|True>
+    # ceph config set mgr mgr/ansible/ca_bundle <path_to_ca_bundle_file>
 
 
-    # ceph config set mgr mgr/ansible/server_addr <ip_address/server_name>
-    # ceph config set mgr mgr/ansible/server_port <port>
-    # ceph config set mgr mgr/ansible/username <username>
-    # ceph config set mgr mgr/ansible/password <password>
-    # ceph config set mgr mgr/ansible/verify_server <verify_server_value>
 
 Where:
 
     * <ip_address/server_name>: Is the ip address/hostname of the server where the Ansible Runner Service is available.
     * <port>: The port number where the Ansible Runner Service is listening
-    * <username>: The username of one authorized user in the Ansible Runner Service
-    * <password>: The password of the authorized user.
-    * <verify_server_value>: Either a boolean, in which case it controls whether the server's TLS certificate is verified, or a string, in which case it must be a path to a CA bundle to use in the verification. Defaults to ``True``.
+    * <verify_server_value>: boolean, it controls whether the Ansible Runner Service server's TLS certificate is verified. Defaults to ``True``.
+    * <path_to_ca_bundle_file>: Path to a CA bundle to use in the verification.
+
+In order to check that everything is OK, use the "status" orchestrator command.
+
+    # ceph orchestrator status
+    Backend: ansible
+    Available: True
+
+Any kind of problem connecting with the external Ansible Runner Service will be reported using this command.
 
 
 Debugging
@@ -95,27 +128,5 @@ And use the "active" manager node: ( "ceph -s" command in one monitor give you t
 Operations
 ==========
 
-**Inventory:**
-
-Get the list of storage devices installed in all the cluster nodes. The output format is::
-
-  [host:
-     device_name (type_of_device , size_in_bytes)]
-
-Example::
-
-  [root@mon0 ~]# ceph orchestrator device ls
-  192.168.121.160:
-    vda (hdd, 44023414784b)
-    sda (hdd, 53687091200b)
-    sdb (hdd, 53687091200b)
-    sdc (hdd, 53687091200b)
-  192.168.121.36:
-    vda (hdd, 44023414784b)
-  192.168.121.201:
-    vda (hdd, 44023414784b)
-  192.168.121.70:
-    vda (hdd, 44023414784b)
-    sda (hdd, 53687091200b)
-    sdb (hdd, 53687091200b)
-    sdc (hdd, 53687091200b)
+To see the complete list of operations, use:
+:ref:`CLI <orchestrator-cli-module>`
index d93da8672d525495b2cb17c8d7cf3c504b9af30f..54c96a46e6ef289f7144a2f78e25af7ac0d2f491 100644 (file)
@@ -113,6 +113,22 @@ filtered to a particular node:
 
     ceph orchestrator device ls [--host=...] [--refresh]
 
+Example::
+
+    # ceph orchestrator device ls
+    Host 192.168.121.206:
+    Device Path           Type       Size    Rotates  Available Model
+    /dev/sdb               hdd      50.0G       True       True ATA/QEMU HARDDISK
+    /dev/sda               hdd      50.0G       True      False ATA/QEMU HARDDISK
+
+    Host 192.168.121.181:
+    Device Path           Type       Size    Rotates  Available Model
+    /dev/sdb               hdd      50.0G       True       True ATA/QEMU HARDDISK
+    /dev/sda               hdd      50.0G       True      False ATA/QEMU HARDDISK
+
+.. note::
+    Output form Ansible orchestrator
+
 Create OSDs
 ^^^^^^^^^^^
 
@@ -126,6 +142,13 @@ The output of ``osd create`` is not specified and may vary between orchestrator
 
 Where ``drive.group.json`` is a JSON file containing the fields defined in :class:`orchestrator.DriveGroupSpec`
 
+Example::
+
+    # ceph orchestrator osd create 192.168.121.206:/dev/sdc
+    {"status": "OK", "msg": "", "data": {"event": "playbook_on_stats", "uuid": "7082f3ba-f5b7-4b7c-9477-e74ca918afcb", "stdout": "\r\nPLAY RECAP *********************************************************************\r\n192.168.121.206            : ok=96   changed=3    unreachable=0    failed=0   \r\n", "counter": 932, "pid": 10294, "created": "2019-05-28T22:22:58.527821", "end_line": 1170, "runner_ident": "083cad3c-8197-11e9-b07a-2016b900e38f", "start_line": 1166, "event_data": {"ignored": 0, "skipped": {"192.168.121.206": 186}, "ok": {"192.168.121.206": 96}, "artifact_data": {}, "rescued": 0, "changed": {"192.168.121.206": 3}, "pid": 10294, "dark": {}, "playbook_uuid": "409364a6-9d49-4e44-8b7b-c28e5b3adf89", "playbook": "add-osd.yml", "failures": {}, "processed": {"192.168.121.206": 1}}, "parent_uuid": "409364a6-9d49-4e44-8b7b-c28e5b3adf89"}}
+
+.. note::
+    Output form Ansible orchestrator
 
 Decommission an OSD
 ^^^^^^^^^^^^^^^^^^^
@@ -136,6 +159,13 @@ Decommission an OSD
 Removes one or more OSDs from the cluster and the host, if the OSDs are marked as
 ``destroyed``.
 
+Example::
+
+    # ceph orchestrator osd rm 4
+    {"status": "OK", "msg": "", "data": {"event": "playbook_on_stats", "uuid": "1a16e631-906d-48e0-9e24-fa7eb593cc0a", "stdout": "\r\nPLAY RECAP *********************************************************************\r\n192.168.121.158            : ok=2    changed=0    unreachable=0    failed=0   \r\n192.168.121.181            : ok=2    changed=0    unreachable=0    failed=0   \r\n192.168.121.206            : ok=2    changed=0    unreachable=0    failed=0   \r\nlocalhost                  : ok=31   changed=8    unreachable=0    failed=0   \r\n", "counter": 240, "pid": 10948, "created": "2019-05-28T22:26:09.264012", "end_line": 308, "runner_ident": "8c093db0-8197-11e9-b07a-2016b900e38f", "start_line": 301, "event_data": {"ignored": 0, "skipped": {"localhost": 37}, "ok": {"192.168.121.181": 2, "192.168.121.158": 2, "192.168.121.206": 2, "localhost": 31}, "artifact_data": {}, "rescued": 0, "changed": {"localhost": 8}, "pid": 10948, "dark": {}, "playbook_uuid": "a12ec40e-bce9-4bc9-b09e-2d8f76a5be02", "playbook": "shrink-osd.yml", "failures": {}, "processed": {"192.168.121.181": 1, "192.168.121.158": 1, "192.168.121.206": 1, "localhost": 1}}, "parent_uuid": "a12ec40e-bce9-4bc9-b09e-2d8f76a5be02"}}
+
+.. note::
+    Output form Ansible orchestrator
 
 ..
     Blink Device Lights
@@ -245,12 +275,12 @@ This is an overview of the current implementation status of the orchestrators.
 =================================== ========= ====== ========= =====
  Command                             Ansible   Rook   DeepSea   SSH
 =================================== ========= ====== ========= =====
- host add                            â\9aª         ⚪       ⚪         ✔️
- host ls                             â\9aª         ⚪       ⚪         ✔️
- host rm                             â\9aª         ⚪       ⚪         ✔️
+ host add                            â\9c\94ï¸\8f         ⚪       ⚪         ✔️
+ host ls                             â\9c\94ï¸\8f         ⚪       ⚪         ✔️
+ host rm                             â\9c\94ï¸\8f         ⚪       ⚪         ✔️
  mgr update                          ⚪         ⚪       ⚪         ✔️
  mon update                          ⚪         ✔️       ⚪         ✔️
- osd create                          ✔️         ✔️       ⚪         ✔️
+ osd create                          ✔️          ✔️       ⚪         ✔️
  osd device {ident,fault}-{on,off}   ⚪         ⚪       ⚪         ⚪
  osd rm                              ✔️         ⚪       ⚪         ⚪
  device {ident,fault}-(on,off}       ⚪         ⚪       ⚪         ⚪
index 636e20b0fdcde5b42c9e8e3a76d9ee13deab59a3..45ca1081265d1ba6353786f5e4688fe81e66077d 100644 (file)
@@ -1,19 +1,21 @@
 """
 Client module to interact with the Ansible Runner Service
 """
-import requests
+
 import json
 import re
 from functools import wraps
 
+import requests
+
 # Ansible Runner service API endpoints
 API_URL = "api"
-LOGIN_URL = "api/v1/login"
 PLAYBOOK_EXEC_URL = "api/v1/playbooks"
 PLAYBOOK_EVENTS = "api/v1/jobs/%s/events"
 EVENT_DATA_URL = "api/v1/jobs/%s/events/%s"
 
 class AnsibleRunnerServiceError(Exception):
+    """Generic Ansible Runner Service Exception"""
     pass
 
 def handle_requests_exceptions(func):
@@ -21,10 +23,11 @@ def handle_requests_exceptions(func):
     """
     @wraps(func)
     def inner(*args, **kwargs):
+        """Generic error mgmt decorator"""
         try:
             return func(*args, **kwargs)
-        except requests.exceptions.RequestException as ex:
-               raise AnsibleRunnerServiceError(str(ex))
+        except (requests.exceptions.RequestException, IOError) as ex:
+            raise AnsibleRunnerServiceError(str(ex))
     return inner
 
 class ExecutionStatusCode(object):
@@ -73,8 +76,8 @@ class PlayBookExecution(object):
 
         try:
             response = self.rest_client.http_post(endpoint,
-                                                    self.params,
-                                                    self.querystr_dict)
+                                                  self.params,
+                                                  self.querystr_dict)
         except AnsibleRunnerServiceError:
             self.log.exception("Error launching playbook <%s>", self.playbook)
             raise
@@ -128,10 +131,11 @@ class PlayBookExecution(object):
         self.log.info("Requested playbook execution status is: %s", status_value)
         return status_value
 
-    def get_result(self, event_filter=""):
+    def get_result(self, event_filter):
         """Get the data of the events filtered by a task pattern and
         a event filter
 
+        @event_filter: list of 0..N event names items
         @returns: the events that matches with the patterns provided
         """
         response = None
@@ -148,16 +152,20 @@ class PlayBookExecution(object):
         else:
             events = json.loads(response.text)["data"]["events"]
 
+            # Filter by task
             if self.result_task_pattern:
-                result_events = {event:data for event,data in events.items()
-                                if "task" in data and
-                                re.match(self.result_task_pattern, data["task"])}
+                result_events = {event:data for event, data in events.items()
+                                 if "task" in data and
+                                 re.match(self.result_task_pattern, data["task"])}
             else:
                 result_events = events
 
+            # Filter by event
             if event_filter:
-                result_events = {event:data for event,data in result_events.items()
-                                if re.match(event_filter, data['event'])}
+                type_of_events = "|".join(event_filter)
+
+                result_events = {event:data for event, data in result_events.items()
+                                 if re.match(type_of_events, data['event'])}
 
         self.log.info("Requested playbook result is: %s", json.dumps(result_events))
         return result_events
@@ -167,68 +175,41 @@ class Client(object):
     and execute easily playbooks
     """
 
-    def __init__(self, server_url, user, password, verify_server, logger):
+    def __init__(self, server_url, verify_server, ca_bundle, client_cert,
+                 client_key, logger):
         """Provide an https client to make easy interact with the Ansible
         Runner Service"
 
-        :param servers_url: The base URL >server>:<port> of the Ansible Runner Service
-        :param user: Username of the authorized user
-        :param password: Password of the authorized user
-        :param verify_server: Either a boolean, in which case it controls whether we verify
-            the server's TLS certificate, or a string, in which case it must be a path
-            to a CA bundle to use. Defaults to ``True``.
+        :param server_url: The base URL >server>:<port> of the Ansible Runner
+                           Service
+        :param verify_server: A boolean to specify if server authentity should
+                              be checked or not. (True by default)
+        :param ca_bundle: If provided, an alternative Cert. Auth. bundle file
+                          will be used as source for checking the authentity of
+                          the Ansible Runner Service
+        :param client_cert: Path to Ansible Runner Service client certificate
+                            file
+        :param client_key: Path to Ansible Runner Service client certificate key
+                           file
         :param logger: Log file
         """
         self.server_url = server_url
-        self.user = user
-        self.password = password
         self.log = logger
-        self.auth = (self.user, self.password)
-        if not verify_server:
-            self.verify_server = True
-        elif verify_server.lower().strip() == 'false':
-            self.verify_server = False
-        else:
-            self.verify_server = verify_server
+        self.client_cert = (client_cert, client_key)
 
-        # Once authenticated this token will be used in all the requests
-        self.token = ""
+        # used to provide the "verify" parameter in requests
+        # a boolean that sometimes contains a string :-(
+        self.verify_server = verify_server
+        if ca_bundle: # This intentionallly overwrites
+            self.verify_server = ca_bundle
 
         self.server_url = "https://{0}".format(self.server_url)
 
-        # Log in the server and get a token
-        self.login()
-
-    @handle_requests_exceptions
-    def login(self):
-        """ Login with user credentials to obtain a valid token
-        """
-
-        the_url = "%s/%s" % (self.server_url, LOGIN_URL)
-        response = requests.get(the_url,
-                                auth = self.auth,
-                                verify = self.verify_server)
-
-        if response.status_code != requests.codes.ok:
-            self.log.error("login error <<%s>> (%s):%s",
-                            the_url, response.status_code, response.text)
-        else:
-            self.log.info("login succesful <<%s>> (%s):%s",
-                            the_url, response.status_code, response.text)
-
-        if response:
-            self.token = json.loads(response.text)["data"]["token"]
-            self.log.info("Connection with Ansible Runner Service is operative")
-
     @handle_requests_exceptions
     def is_operative(self):
         """Indicates if the connection with the Ansible runner Server is ok
         """
 
-        # No Token... this means we haven't used yet the service.
-        if not self.token:
-            return False
-
         # Check the service
         response = self.http_get(API_URL)
 
@@ -247,17 +228,19 @@ class Client(object):
         """
 
         the_url = "%s/%s" % (self.server_url, endpoint)
+
         response = requests.get(the_url,
-                            verify = self.verify_server,
-                            headers = {"Authorization": self.token})
+                                verify=self.verify_server,
+                                cert=self.client_cert,
+                                headers={})
 
         if response.status_code != requests.codes.ok:
             self.log.error("http GET %s <--> (%s - %s)\n%s",
-                            the_url, response.status_code, response.reason,
-                            response.text)
+                           the_url, response.status_code, response.reason,
+                           response.text)
         else:
             self.log.info("http GET %s <--> (%s - %s)",
-                            the_url, response.status_code, response.text)
+                          the_url, response.status_code, response.text)
 
         return response
 
@@ -274,19 +257,19 @@ class Client(object):
 
         the_url = "%s/%s" % (self.server_url, endpoint)
         response = requests.post(the_url,
-                            verify = self.verify_server,
-                            headers = {"Authorization": self.token,
-                                        "Content-type": "application/json"},
-                            json = payload,
-                            params = params_dict)
+                                 verify=self.verify_server,
+                                 cert=self.client_cert,
+                                 headers={"Content-type": "application/json"},
+                                 json=payload,
+                                 params=params_dict)
 
         if response.status_code != requests.codes.ok:
             self.log.error("http POST %s [%s] <--> (%s - %s:%s)\n",
-                            the_url, payload, response.status_code,
-                            response.reason, response.text)
+                           the_url, payload, response.status_code,
+                           response.reason, response.text)
         else:
             self.log.info("http POST %s <--> (%s - %s)",
-                            the_url, response.status_code, response.text)
+                          the_url, response.status_code, response.text)
 
         return response
 
@@ -301,16 +284,17 @@ class Client(object):
 
         the_url = "%s/%s" % (self.server_url, endpoint)
         response = requests.delete(the_url,
-                            verify = self.verify_server,
-                            headers = {"Authorization": self.token})
+                                   verify=self.verify_server,
+                                   cert=self.client_cert,
+                                   headers={})
 
         if response.status_code != requests.codes.ok:
             self.log.error("http DELETE %s <--> (%s - %s)\n%s",
-                            the_url, response.status_code, response.reason,
-                            response.text)
+                           the_url, response.status_code, response.reason,
+                           response.text)
         else:
             self.log.info("http DELETE %s <--> (%s - %s)",
-                            the_url, response.status_code, response.text)
+                          the_url, response.status_code, response.text)
 
         return response
 
index 81e1c40a1041841aecc739467d1ddfdd08ebfb5b..6b87b921f34cb220b79c32827178b83e55ca9bcb 100644 (file)
@@ -7,9 +7,13 @@ The external Orchestrator is the Ansible runner service (RESTful https service)
 # pylint: disable=abstract-method, no-member, bad-continuation
 
 import json
+import os
+import errno
+import tempfile
 import requests
+from OpenSSL import crypto, SSL
 
-from mgr_module import MgrModule
+from mgr_module import MgrModule, Option, CLIWriteCommand
 import orchestrator
 
 from .ansible_runner_svc import Client, PlayBookExecution, ExecutionStatusCode,\
@@ -77,8 +81,8 @@ class AnsibleReadOperation(orchestrator.ReadCompletion):
         # Logger
         self.log = logger
 
-        # OutputWizard object used to process the result
-        self.output_wizard = None
+    def __str__(self):
+        return "Playbook {playbook_name}".format(playbook_name=self.playbook)
 
     @property
     def is_complete(self):
@@ -184,7 +188,7 @@ class PlaybookOperation(AnsibleReadOperation):
         self.playbook = playbook
 
         # An aditional filter of result events based in the event
-        self.event_filter = ""
+        self.event_filter_list = [""]
 
         # Playbook execution object
         self.pb_execution = PlayBookExecution(client,
@@ -237,8 +241,15 @@ class PlaybookOperation(AnsibleReadOperation):
 
         processed_result = []
 
-        if self._is_complete:
-            raw_result = self.pb_execution.get_result(self.event_filter)
+        if self._is_errored:
+            processed_result = self.pb_execution.get_result(["runner_on_failed",
+                                                       "runner_on_unreachable",
+                                                       "runner_on_no_hosts",
+                                                       "runner_on_async_failed",
+                                                       "runner_item_on_failed"])
+
+        elif self._is_complete:
+            raw_result = self.pb_execution.get_result(self.event_filter_list)
 
             if self.output_wizard:
                 processed_result = self.output_wizard.process(self.pb_execution.play_uuid,
@@ -392,10 +403,12 @@ class Module(MgrModule, orchestrator.Orchestrator):
     """
 
     MODULE_OPTIONS = [
-        {'name': 'server_url'},
-        {'name': 'username'},
-        {'name': 'password'},
-        {'name': 'verify_server'} # Check server identity (Boolean/path to CA bundle)
+        # url:port of the Ansible Runner Service
+        Option(name="server_location", type="str", default=""),
+        # Check server identity (True by default)
+        Option(name="verify_server", type="bool", default=True),
+        # Path to an alternative CA bundle
+        Option(name="ca_bundle", type="str", default="")
     ]
 
     def __init__(self, *args, **kwargs):
@@ -407,11 +420,38 @@ class Module(MgrModule, orchestrator.Orchestrator):
 
         self.ar_client = None
 
+        # TLS certificate and key file names used to connect with the external
+        # Ansible Runner Service
+        self.client_cert_fname = ""
+        self.client_key_fname = ""
+
+        # used to provide more verbose explanation of errors in status method
+        self.status_message = ""
+
     def available(self):
         """ Check if Ansible Runner service is working
         """
-        # TODO
-        return (True, "Everything ready")
+        available = False
+        msg = ""
+        try:
+
+            if self.ar_client:
+                available = self.ar_client.is_operative()
+                if not available:
+                    msg = "No response from Ansible Runner Service"
+            else:
+                msg = "Not possible to initialize connection with Ansible "\
+                      "Runner service."
+
+        except AnsibleRunnerServiceError as ex:
+            available = False
+            msg = str(ex)
+
+        # Add more details to the detected problem
+        if self.status_message:
+            msg = "{}:\n{}".format(msg, self.status_message)
+
+        return (available, msg)
 
     def wait(self, completions):
         """Given a list of Completion instances, progress any which are
@@ -438,16 +478,19 @@ class Module(MgrModule, orchestrator.Orchestrator):
         """
         self.log.info("Starting Ansible Orchestrator module ...")
 
-        # Verify config options (Just that settings are available)
-        self.verify_config()
-
-        # Ansible runner service client
         try:
-            self.ar_client = Client(server_url=self.get_module_option('server_url', ''),
-                                    user=self.get_module_option('username', ''),
-                                    password=self.get_module_option('password', ''),
-                                    verify_server=self.get_module_option('verify_server', True),
-                                    logger=self.log)
+            # Verify config options and client certificates
+            self.verify_config()
+
+            # Ansible runner service client
+            self.ar_client = Client(
+                server_url=self.get_module_option('server_location', ''),
+                verify_server=self.get_module_option('verify_server', True),
+                ca_bundle=self.get_module_option('ca_bundle', ''),
+                client_cert=self.client_cert_fname,
+                client_key=self.client_key_fname,
+                logger=self.log)
+
         except AnsibleRunnerServiceError:
             self.log.exception("Ansible Runner Service not available. "
                           "Check external server status/TLS identity or "
@@ -482,7 +525,7 @@ class Module(MgrModule, orchestrator.Orchestrator):
         # Assign the process_output function
         playbook_operation.output_wizard = ProcessInventory(self.ar_client,
                                                             self.log)
-        playbook_operation.event_filter = "runner_on_ok"
+        playbook_operation.event_filter_list = ["runner_on_ok"]
 
         # Execute the playbook to obtain data
         self._launch_operation(playbook_operation)
@@ -514,7 +557,7 @@ class Module(MgrModule, orchestrator.Orchestrator):
         # Filter to get the result
         playbook_operation.output_wizard = ProcessPlaybookResult(self.ar_client,
                                                                   self.log)
-        playbook_operation.event_filter = "playbook_on_stats"
+        playbook_operation.event_filter_list = ["playbook_on_stats"]
 
         # Execute the playbook
         self._launch_operation(playbook_operation)
@@ -540,7 +583,8 @@ class Module(MgrModule, orchestrator.Orchestrator):
         # Filter to get the result
         playbook_operation.output_wizard = ProcessPlaybookResult(self.ar_client,
                                                                  self.log)
-        playbook_operation.event_filter = "playbook_on_stats"
+        playbook_operation.event_filter_list = ["playbook_on_stats"]
+
 
         # Execute the playbook
         self._launch_operation(playbook_operation)
@@ -579,13 +623,14 @@ class Module(MgrModule, orchestrator.Orchestrator):
             add_url = URL_ADD_RM_HOSTS.format(host_name=host,
                                               inventory_group=ORCHESTRATOR_GROUP)
 
-            operations = [HttpOperation(add_url, "post")]
+            operations = [HttpOperation(add_url, "post", "", None)]
 
         except AnsibleRunnerServiceError as ex:
             # Problems with the external orchestrator.
             # Prepare the operation to return the error in a Completion object.
-            self.log.exception("Error checking <orchestrator> group: %s", ex)
-            operations = [HttpOperation(url_group, "post")]
+            self.log.exception("Error checking <orchestrator> group: %s",
+                               str(ex))
+            operations = [HttpOperation(url_group, "post", "", None)]
 
         return ARSChangeOperation(self.ar_client, self.log, operations)
 
@@ -642,49 +687,106 @@ class Module(MgrModule, orchestrator.Orchestrator):
         self.all_completions.append(ansible_operation)
 
     def verify_config(self):
-        """ Verify configuration options for the Ansible orchestrator module
-        """
-        client_msg = ""
+        """Verify mandatory settings for the module and provide help to
+           configure properly the orchestrator
+        """
+
+        the_crt = None
+        the_key = None
+
+        # Retrieve TLS content to use and check them
+        # First try to get certiticate and key content for this manager instance
+        # ex: mgr/ansible/mgr0/[crt/key]
+        self.log.info("Tying to use configured specific certificate and key"
+                      "files for this server")
+        the_crt = self.get_store("{}/{}".format(self.get_mgr_id(), "crt"))
+        the_key = self.get_store("{}/{}".format(self.get_mgr_id(), "key"))
+        if the_crt is None or the_key is None:
+            # If not possible... try to get generic certificates and key content
+            # ex: mgr/ansible/[crt/key]
+            self.log.warning("Specific tls files for this manager not "\
+                             "configured, trying to use generic files")
+            the_crt = self.get_store("crt")
+            the_key = self.get_store("key")
+
+        if the_crt is None or the_key is None:
+            self.status_message = "No client certificate configured. Please "\
+                                  "set Ansible Runner Service client "\
+                                  "certificate and key:\n"\
+                                  "ceph ansible set-ssl-certificate-"\
+                                  "{key,certificate} -i <file>"
+            self.log.error(self.status_message)
+            return
+
+        # generate certificate temp files
+        self.client_cert_fname = generate_temp_file("crt", the_crt)
+        self.client_key_fname = generate_temp_file("key", the_key)
+
+        self.status_message = verify_tls_files(self.client_cert_fname,
+                                               self.client_key_fname)
+
+        if self.status_message:
+            self.log.error(self.status_message)
+            return
 
-        if not self.get_module_option('server_url', ''):
-            msg = "No Ansible Runner Service base URL <server_name>:<port>." \
-            "Try 'ceph config set mgr mgr/{0}/server_url " \
+        # Check module options
+        if not self.get_module_option("server_location", ""):
+            self.status_message = "No Ansible Runner Service base URL "\
+            "<server_name>:<port>."\
+            "Try 'ceph config set mgr mgr/{0}/server_location "\
             "<server name/ip>:<port>'".format(self.module_name)
-            self.log.error(msg)
-            client_msg += msg
-
-        if not self.get_module_option('username', ''):
-            msg = "No Ansible Runner Service user. " \
-            "Try 'ceph config set mgr mgr/{0}/username " \
-            "<string value>'".format(self.module_name)
-            self.log.error(msg)
-            client_msg += msg
-
-        if not self.get_module_option('password', ''):
-            msg = "No Ansible Runner Service User password. " \
-            "Try 'ceph config set mgr mgr/{0}/password " \
-            "<string value>'".format(self.module_name)
-            self.log.error(msg)
-            client_msg += msg
-
-        if not self.get_module_option('verify_server', ''):
-            msg = "TLS server identity verification is enabled by default." \
-            "Use 'ceph config set mgr mgr/{0}/verify_server False' " \
-            "to disable it. Use 'ceph config set mgr mgr/{0}/verify_server " \
-            "<path>' to point the CA bundle path used for " \
+            self.log.error(self.status_message)
+            return
+
+
+        if self.get_module_option("verify_server", True):
+            self.status_message = "TLS server identity verification is enabled"\
+            " by default.Use 'ceph config set mgr mgr/{0}/verify_server False'"\
+            "to disable it.Use 'ceph config set mgr mgr/{0}/ca_bundle <path>'"\
+            "to point an alternative CA bundle path used for TLS server "\
             "verification".format(self.module_name)
-            self.log.error(msg)
-            client_msg += msg
+            self.log.error(self.status_message)
+            return
 
-        if client_msg:
-            # Raise error
-            # TODO: Use OrchestratorValidationError
-            raise Exception(client_msg)
+        # Everything ok
+        self.status_message = ""
 
 
+    #---------------------------------------------------------------------------
+    # Ansible Orchestrator self-owned commands
+    #---------------------------------------------------------------------------
+    @CLIWriteCommand("ansible set-ssl-certificate",
+                     "name=mgr_id,type=CephString,req=false")
+    def set_tls_certificate(self, mgr_id=None, inbuf=None):
+        """Load tls certificate in mon k-v store
+        """
+        if inbuf is None:
+            return -errno.EINVAL, \
+                   'Please specify the certificate file with "-i" option', ''
+        if mgr_id is not None:
+            self.set_store("{}/crt".format(mgr_id), inbuf)
+        else:
+            self.set_store("crt", inbuf)
+        return 0, "SSL certificate updated", ""
+
+    @CLIWriteCommand("ansible set-ssl-certificate-key",
+                     "name=mgr_id,type=CephString,req=false")
+    def set_tls_certificate_key(self, mgr_id=None, inbuf=None):
+        """Load tls certificate key in mon k-v store
+        """
+        if inbuf is None:
+            return -errno.EINVAL, \
+                   'Please specify the certificate key file with "-i" option', \
+                   ''
+        if mgr_id is not None:
+            self.set_store("{}/key".format(mgr_id), inbuf)
+        else:
+            self.set_store("key", inbuf)
+        return 0, "SSL certificate key updated", ""
 
 # Auxiliary functions
 #==============================================================================
+
 def dg_2_ansible(drive_group):
     """ Transform a drive group especification into:
 
@@ -727,3 +829,76 @@ def dg_2_ansible(drive_group):
     #osd_spec["osd_objectstore"] = drive_group.objectstore
 
     return host, osd_spec
+
+
+def generate_temp_file(key, content):
+    """ Generates a temporal file with the content passed as parameter
+
+    :param key    : used to build the temp file name
+    :param content: the content that will be dumped to file
+    :returns      : the name of the generated file
+    """
+
+    fname = ""
+
+    if content is not None:
+        fname = "{}/{}.tmp".format(tempfile.gettempdir(), key)
+        try:
+            if os.path.exists(fname):
+                os.remove(fname)
+            with open(fname, "w") as text_file:
+                text_file.write(content)
+        except IOError as ex:
+            raise AnsibleRunnerServiceError("Cannot store TLS certificate/key"
+                                            " content: {}".format(str(ex)))
+
+    return fname
+
+def verify_tls_files(crt_file, key_file):
+    """Basic checks for TLS certificate and key files
+
+    :crt_file : Name of the certificate file
+    :key_file : name of the certificate public key file
+
+    :returns  :  String with error description
+    """
+
+    # Check we have files
+    if not crt_file or not key_file:
+        return "no certificate/key configured"
+
+    if not os.path.isfile(crt_file):
+        return "certificate {} does not exist".format(crt_file)
+
+    if not os.path.isfile(key_file):
+        return "Public key {} does not exist".format(key_file)
+
+    # Do some validations to the private key and certificate:
+    # - Check the type and format
+    # - Check the certificate expiration date
+    # - Check the consistency of the private key
+    # - Check that the private key and certificate match up
+    try:
+        with open(crt_file) as fcrt:
+            x509 = crypto.load_certificate(crypto.FILETYPE_PEM, fcrt.read())
+            if x509.has_expired():
+                return "Certificate {} has been expired".format(crt_file)
+    except (ValueError, crypto.Error) as ex:
+        return "Invalid certificate {}: {}".format(crt_file, str(ex))
+    try:
+        with open(key_file) as fkey:
+            pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, fkey.read())
+            pkey.check()
+    except (ValueError, crypto.Error) as ex:
+        return "Invalid private key {}: {}".format(key_file, str(ex))
+    try:
+        context = SSL.Context(SSL.TLSv1_METHOD)
+        context.use_certificate_file(crt_file, crypto.FILETYPE_PEM)
+        context.use_privatekey_file(key_file, crypto.FILETYPE_PEM)
+        context.check_privatekey()
+    except crypto.Error as ex:
+        return "Private key {} and certificate {} do not match up: {}".format(
+                key_file, crt_file, str(ex))
+
+    # Everything OK
+    return ""
index d924bf04cc545459201b2fb0bd70871fdd227dd3..7ba8b7f7500902314ec71e3d7da2b5511f01c375 100644 (file)
@@ -5,8 +5,6 @@ Output wizards are used to process results in different ways in
 completion objects
 """
 
-# pylint: disable=bad-continuation
-
 import json
 
 
@@ -66,7 +64,7 @@ class ProcessInventory(OutputWizard):
         for event_key, dummy_data in inventory_events.items():
 
             event_response = self.ar_client.http_get(EVENT_DATA_URL %
-                            (operation_id, event_key))
+                                                     (operation_id, event_key))
 
             # self.pb_execution.play_uuid
 
@@ -105,7 +103,7 @@ class ProcessPlaybookResult(OutputWizard):
         # Loop over the result events and request the data
         for event_key, dummy_data in inventory_events.items():
             event_response = self.ar_client.http_get(EVENT_DATA_URL %
-                            (operation_id, event_key))
+                                                     (operation_id, event_key))
 
             result += event_response.text
 
index 98dfd3dd58fe563eab21ddc341d72b17c7df696b..23cbbe4555bca4bdfb410c81dfd01a9d325823b2 100644 (file)
@@ -8,13 +8,11 @@ import requests_mock
 from requests.exceptions import ConnectionError
 
 from ..ansible_runner_svc import Client, PlayBookExecution, ExecutionStatusCode, \
-                                LOGIN_URL, API_URL, PLAYBOOK_EXEC_URL, \
+                                API_URL, PLAYBOOK_EXEC_URL, \
                                 PLAYBOOK_EVENTS, AnsibleRunnerServiceError
 
 
 SERVER_URL = "ars:5001"
-USER = "admin"
-PASSWORD = "admin"
 CERTIFICATE = ""
 
 # Playbook attributes
@@ -32,30 +30,11 @@ formatter = logging.Formatter("%(levelname)s - %(message)s")
 handler.setFormatter(formatter)
 logger.addHandler(handler)
 
-
-def mock_login(mock_server):
-
-    the_login_url = "https://%s/%s" % (SERVER_URL,LOGIN_URL)
-
-    mock_server.register_uri("GET",
-                            the_login_url,
-                            json={"status": "OK",
-                            "msg": "Token returned",
-                            "data": {"token": "dummy_token"}},
-                            status_code=200)
-
-    the_api_url = "https://%s/%s" % (SERVER_URL,API_URL)
-    mock_server.register_uri("GET",
-                    the_api_url,
-                    text="<!DOCTYPE html>api</html>",
-                    status_code=200)
-
 def mock_get_pb(mock_server, playbook_name, return_code):
 
-    mock_login(mock_server)
-
-    ars_client = Client(SERVER_URL, USER, PASSWORD,
-                        CERTIFICATE, logger)
+    ars_client = Client(SERVER_URL, verify_server=False, ca_bundle="",
+                        client_cert = "DUMMY_PATH", client_key = "DUMMY_PATH",
+                        logger = logger)
 
     the_pb_url = "https://%s/%s/%s" % (SERVER_URL, PLAYBOOK_EXEC_URL, playbook_name)
 
@@ -82,34 +61,26 @@ class ARSclientTest(unittest.TestCase):
     def test_server_not_reachable(self):
 
         with self.assertRaises(AnsibleRunnerServiceError):
-            ars_client = Client(SERVER_URL, USER, PASSWORD,
-                                CERTIFICATE, logger)
-
-    def test_server_wrong_USER(self):
+            ars_client = Client(SERVER_URL, verify_server=False, ca_bundle="",
+                            client_cert = "DUMMY_PATH", client_key = "DUMMY_PATH",
+                            logger = logger)
 
-        with requests_mock.Mocker() as mock_server:
-            the_login_url = "https://%s/%s" % (SERVER_URL,LOGIN_URL)
-            mock_server.get(the_login_url,
-                            json={"status": "NOAUTH",
-                            "msg": "Access denied invalid login: unknown USER",
-                            "data": {}},
-                            status_code=401)
-
-
-            ars_client = Client(SERVER_URL, USER, PASSWORD,
-                                CERTIFICATE, logger)
+            status = ars_client.is_operative()
 
-            self.assertFalse(ars_client.is_operative(),
-                            "Operative attribute expected to be False")
 
     def test_server_connection_ok(self):
 
         with requests_mock.Mocker() as mock_server:
 
-            mock_login(mock_server)
+            ars_client = Client(SERVER_URL, verify_server=False, ca_bundle="",
+                                client_cert = "DUMMY_PATH", client_key = "DUMMY_PATH",
+                                logger = logger)
 
-            ars_client = Client(SERVER_URL, USER, PASSWORD,
-                                CERTIFICATE, logger)
+            the_api_url = "https://%s/%s" % (SERVER_URL,API_URL)
+            mock_server.register_uri("GET",
+                    the_api_url,
+                    text="<!DOCTYPE html>api</html>",
+                    status_code=200)
 
             self.assertTrue(ars_client.is_operative(),
                             "Operative attribute expected to be True")
@@ -118,10 +89,9 @@ class ARSclientTest(unittest.TestCase):
 
         with requests_mock.Mocker() as mock_server:
 
-            mock_login(mock_server)
-
-            ars_client = Client(SERVER_URL, USER, PASSWORD,
-                                CERTIFICATE, logger)
+            ars_client = Client(SERVER_URL, verify_server=False, ca_bundle="",
+                                client_cert = "DUMMY_PATH", client_key = "DUMMY_PATH",
+                                logger = logger)
 
             url = "https://%s/test" % (SERVER_URL)
             mock_server.register_uri("DELETE",
@@ -149,8 +119,6 @@ class PlayBookExecutionTests(unittest.TestCase):
             self.assertEqual(test_pb.play_uuid, PB_UUID,
                              "Found Unexpected playbook uuid")
 
-
-
     def test_playbook_execution_error(self):
         """Check playbook id is not set when the playbook is not present
         """