Manage Kibana objects

This patch adds a salt state module to manage Kibana objects. It also
adds the client to install these objects.
diff --git a/README.rst b/README.rst
index 89c289f..2a54f23 100644
--- a/README.rst
+++ b/README.rst
@@ -39,6 +39,38 @@
           port: 9200
 
 
+Client setup
+------------
+
+Client with host and port (Kibana use Elasticsearch to store its data):
+
+.. code-block:: yaml
+
+    kibana:
+      client:
+        enabled: true
+        server:
+          host: elasticsearch.host
+          port: 9200
+
+Client where you download a Kibana object that is stored in the directory
+*files/*:
+
+.. code-block:: yaml
+
+    kibana:
+      client:
+        enabled: true
+        server:
+          host: elasticsearch.host
+          port: 9200
+        object:
+          logs:
+            enabled: true
+            name: Logs
+            template: kibana/files/objects/dashboard_logs.json
+            type: 'dashboard'
+
 Read more
 =========
 
diff --git a/_states/kibana_object.py b/_states/kibana_object.py
new file mode 100644
index 0000000..dd40da7
--- /dev/null
+++ b/_states/kibana_object.py
@@ -0,0 +1,150 @@
+# -*- coding: utf-8 -*-
+'''
+Manage Kibana objects.
+
+.. code-block:: yaml
+
+    kibana:
+      kibana_url: 'https://es.host.com:9200'
+      kibana_index: '.kibana'
+
+.. code-block:: yaml
+
+    Ensure minimum dashboard is managed:
+      kibana_objects.present:
+        - name: 'Logs'
+        - kibana_content: <JSON object>
+        - kibana_type: 'dashboard'
+
+'''
+
+# Import Python libs
+import requests
+
+# Import Salt libs
+from salt.utils.dictdiffer import DictDiffer
+
+
+def __virtual__():
+    '''Always load the module.'''
+    return True
+
+
+def present(name, kibana_content=None, kibana_type=None):
+    '''
+    Ensure the Kibana object exists in the database.
+
+    name
+        Name of the object
+
+    kibana_content
+        Content in JSON
+
+    kibana_type
+        String
+    '''
+    ret = {'name': name, 'result': True, 'comment': '', 'changes': {}}
+
+    if not kibana_content:
+        ret['result'] = False
+        ret['comment'] = 'Content is not set'
+        return ret
+
+    profile = __salt__['config.option']('kibana')
+
+    url, index = _set_parameters(name, kibana_type, profile)
+    if not url:
+        ret['result'] = False
+        ret['comment'] = index
+        return ret
+
+    try:
+        headers = {'Content-type': 'application/json'}
+        response = requests.get(url, headers=headers)
+        if response.ok:
+            delta = DictDiffer(response.json(), kibana_content)
+            ret['changes'] = {
+                'old': "{}".format(delta.removed()),
+                'new': "{}".format(delta.added()),
+                'updated': "{}".format(delta.changed())
+            }
+            if not ret['changes']['old'] and not ret['changes']['new'] and not ret['changes']['updated']:
+                ret['comment'] = "Object {} is already present".format(name)
+                return ret
+        response = requests.put(url, headers=headers, json=kibana_content)
+    except requests.exceptions.RequestException as exc:
+        ret['result'] = False
+        ret['comment'] = ("Failed to create Kibana object {0}\n"
+                          "Got exception: {1}").format(name, exc)
+    else:
+        if response.ok:
+            if ret['changes']['old'] or ret['changes']['new'] or ret['changes']['updated']:
+                ret['comment'] = 'Kibana object {0} has been updated'.format(name)
+            else:
+                ret['comment'] = 'Kibana object {0} has been created'.format(name)
+                ret['changes']['new'] = 'Kibana objects created'
+        else:
+            ret['result'] = False
+            ret['comment'] = ("Failed to post Kibana object {0}\n"
+                              "Response: {1}").format(name, response)
+
+    return ret
+
+
+def absent(name, kibana_type=None):
+    '''
+    Ensure the Kibana object is not present in the database.
+
+    name
+        Name of the object
+
+    kibana_type
+        String
+    '''
+    ret = {'name': name, 'result': True, 'comment': '', 'changes': {}}
+
+    profile = __salt__['config.option']('kibana')
+
+    url, index = _set_parameters(name, kibana_type, profile)
+    if not url:
+        ret['result'] = False
+        ret['comment'] = index
+        return ret
+
+    try:
+        response = requests.delete(url)
+    except requests.exceptions.RequestException as exc:
+        ret['result'] = False
+        ret['comment'] = ("Failed to delete Kibana object {0}\n"
+                          "Got exception: {1}").format(name, exc)
+    else:
+        if response.ok:
+            ret['comment'] = "Kibana object {0} has been deleted".format(name)
+        elif response.status_code == 404:
+            ret['comment'] = "Kibana object {0} was not present".format(name)
+        else:
+            ret['result'] = False
+            ret['comment'] = ("Failed to delete Kibana object {0}\n"
+                              "Response: {1}").format(name, response)
+
+    return ret
+
+
+def _set_parameters(name, kibana_type, profile):
+    '''
+    Retrieve parameters from profile.
+    '''
+
+    if not kibana_type:
+        return False, 'Type is not set'
+
+    url = profile.get('kibana_url')
+    if not url:
+        return False, 'Cannot get URL needed by Kibana client'
+
+    index = profile.get('kibana_index')
+    if not index:
+        return False, 'Cannot get the index needed by Kibana client'
+
+    url = "http://{0}/{1}/{2}/{3}".format(url, index, kibana_type, name)
+    return url, index
diff --git a/kibana/client.sls b/kibana/client.sls
new file mode 100644
index 0000000..95f0063
--- /dev/null
+++ b/kibana/client.sls
@@ -0,0 +1,24 @@
+{%- from "kibana/map.jinja" import client with context %}
+{%- if client.get('enabled', False) %}
+
+/etc/salt/minion.d/_kibana.conf:
+  file.managed:
+  - source: salt://kibana/files/_kibana.conf
+  - template: jinja
+  - user: root
+  - group: root
+
+{%- for object_name, object in client.get('object', {}).iteritems() %}
+kibana_object_{{ object_name }}:
+  {%- if object.get('enabled', False) %}
+  {% import_json object.template as content %}
+  kibana_object.present:
+  - kibana_content: {{ content|json }}
+  {%- else %}
+  kibana_object.absent:
+  {%- endif %}
+  - name: {{ object_name }}
+  - kibana_type: {{ object.type }}
+{%- endfor %}
+
+{%- endif %}
diff --git a/kibana/files/_kibana.conf b/kibana/files/_kibana.conf
new file mode 100644
index 0000000..410e2b2
--- /dev/null
+++ b/kibana/files/_kibana.conf
@@ -0,0 +1,5 @@
+{%- from "kibana/map.jinja" import client with context %}
+
+kibana:
+  kibana_url: {{ client.server.host }}:{{ client.server.port }}
+  kibana_index: {{ client.server.index }}
diff --git a/kibana/map.jinja b/kibana/map.jinja
index cedacb3..0a6540a 100644
--- a/kibana/map.jinja
+++ b/kibana/map.jinja
@@ -6,3 +6,12 @@
         'configpath': '/opt/kibana/config/kibana.yml',
     },
 }, merge=salt['pillar.get']('kibana:server')) %}
+
+{%- load_yaml as client_defaults %}
+default:
+  server:
+    host: 127.0.0.1
+    port: 9200
+    index: '.kibana'
+{%- endload %}
+{%- set client = salt['grains.filter_by'](client_defaults, merge=salt['pillar.get']('kibana:client')) %}
diff --git a/metadata/service/client.yml b/metadata/service/client.yml
new file mode 100644
index 0000000..1291f37
--- /dev/null
+++ b/metadata/service/client.yml
@@ -0,0 +1,6 @@
+applications:
+- kibana.client
+parameters:
+  kibana:
+    client:
+      enabled: true