1#!/usr/bin/python
2#
3# (c) 2015, Steve Gargan <steve.gargan@gmail.com>
4# (c) 2018 Genome Research Ltd.
5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
6
7from __future__ import absolute_import, division, print_function
8__metaclass__ = type
9
10
11ANSIBLE_METADATA = {'metadata_version': '1.1',
12                    'status': ['preview'],
13                    'supported_by': 'community'}
14
15
16DOCUMENTATION = """
17module: consul_kv
18short_description: Manipulate entries in the key/value store of a consul cluster
19description:
20  - Allows the retrieval, addition, modification and deletion of key/value entries in a
21    consul cluster via the agent. The entire contents of the record, including
22    the indices, flags and session are returned as C(value).
23  - If the C(key) represents a prefix then note that when a value is removed, the existing
24    value if any is returned as part of the results.
25  - See http://www.consul.io/docs/agent/http.html#kv for more details.
26requirements:
27  - python-consul
28  - requests
29version_added: "2.0"
30author:
31  - Steve Gargan (@sgargan)
32  - Colin Nolan (@colin-nolan)
33options:
34    state:
35        description:
36          - The action to take with the supplied key and value. If the state is 'present' and `value` is set, the key
37            contents will be set to the value supplied and `changed` will be set to `true` only if the value was
38            different to the current contents. If the state is 'present' and `value` is not set, the existing value
39            associated to the key will be returned. The state 'absent' will remove the key/value pair,
40            again 'changed' will be set to true only if the key actually existed
41            prior to the removal. An attempt can be made to obtain or free the
42            lock associated with a key/value pair with the states 'acquire' or
43            'release' respectively. a valid session must be supplied to make the
44            attempt changed will be true if the attempt is successful, false
45            otherwise.
46        choices: [ absent, acquire, present, release ]
47        default: present
48    key:
49        description:
50          - The key at which the value should be stored.
51        type: str
52        required: yes
53    value:
54        description:
55          - The value should be associated with the given key, required if C(state)
56            is C(present).
57        type: str
58        required: yes
59    recurse:
60        description:
61          - If the key represents a prefix, each entry with the prefix can be
62            retrieved by setting this to C(yes).
63        type: bool
64        default: 'no'
65    retrieve:
66        description:
67            - If the I(state) is C(present) and I(value) is set, perform a
68              read after setting the value and return this value.
69        default: True
70        type: bool
71    session:
72        description:
73          - The session that should be used to acquire or release a lock
74            associated with a key/value pair.
75        type: str
76    token:
77        description:
78          - The token key identifying an ACL rule set that controls access to
79            the key value pair
80        type: str
81    cas:
82        description:
83          - Used when acquiring a lock with a session. If the C(cas) is C(0), then
84            Consul will only put the key if it does not already exist. If the
85            C(cas) value is non-zero, then the key is only set if the index matches
86            the ModifyIndex of that key.
87        type: str
88    flags:
89        description:
90          - Opaque positive integer value that can be passed when setting a value.
91        type: str
92    host:
93        description:
94          - Host of the consul agent.
95        type: str
96        default: localhost
97    port:
98        description:
99          - The port on which the consul agent is running.
100        type: int
101        default: 8500
102    scheme:
103        description:
104          - The protocol scheme on which the consul agent is running.
105        type: str
106        default: http
107        version_added: "2.1"
108    validate_certs:
109        description:
110          - Whether to verify the tls certificate of the consul agent.
111        type: bool
112        default: 'yes'
113        version_added: "2.1"
114"""
115
116
117EXAMPLES = '''
118# If the key does not exist, the value associated to the "data" property in `retrieved_key` will be `None`
119# If the key value is empty string, `retrieved_key["data"]["Value"]` will be `None`
120- name: retrieve a value from the key/value store
121  consul_kv:
122    key: somekey
123  register: retrieved_key
124
125- name: Add or update the value associated with a key in the key/value store
126  consul_kv:
127    key: somekey
128    value: somevalue
129
130- name: Remove a key from the store
131  consul_kv:
132    key: somekey
133    state: absent
134
135- name: Add a node to an arbitrary group via consul inventory (see consul.ini)
136  consul_kv:
137    key: ansible/groups/dc1/somenode
138    value: top_secret
139
140- name: Register a key/value pair with an associated session
141  consul_kv:
142    key: stg/node/server_birthday
143    value: 20160509
144    session: "{{ sessionid }}"
145    state: acquire
146'''
147
148from ansible.module_utils._text import to_text
149
150try:
151    import consul
152    from requests.exceptions import ConnectionError
153    python_consul_installed = True
154except ImportError:
155    python_consul_installed = False
156
157from ansible.module_utils.basic import AnsibleModule
158
159# Note: although the python-consul documentation implies that using a key with a value of `None` with `put` has a
160# special meaning (https://python-consul.readthedocs.io/en/latest/#consul-kv), if not set in the subsequently API call,
161# the value just defaults to an empty string (https://www.consul.io/api/kv.html#create-update-key)
162NOT_SET = None
163
164
165def _has_value_changed(consul_client, key, target_value):
166    """
167    Uses the given Consul client to determine if the value associated to the given key is different to the given target
168    value.
169    :param consul_client: Consul connected client
170    :param key: key in Consul
171    :param target_value: value to be associated to the key
172    :return: tuple where the first element is the value of the "X-Consul-Index" header and the second is `True` if the
173    value has changed (i.e. the stored value is not the target value)
174    """
175    index, existing = consul_client.kv.get(key)
176    if not existing:
177        return index, True
178    try:
179        changed = to_text(existing['Value'], errors='surrogate_or_strict') != target_value
180        return index, changed
181    except UnicodeError:
182        # Existing value was not decodable but all values we set are valid utf-8
183        return index, True
184
185
186def execute(module):
187    state = module.params.get('state')
188
189    if state == 'acquire' or state == 'release':
190        lock(module, state)
191    elif state == 'present':
192        if module.params.get('value') is NOT_SET:
193            get_value(module)
194        else:
195            set_value(module)
196    elif state == 'absent':
197        remove_value(module)
198    else:
199        module.exit_json(msg="Unsupported state: %s" % (state, ))
200
201
202def lock(module, state):
203
204    consul_api = get_consul_api(module)
205
206    session = module.params.get('session')
207    key = module.params.get('key')
208    value = module.params.get('value')
209
210    if not session:
211        module.fail(
212            msg='%s of lock for %s requested but no session supplied' %
213            (state, key))
214
215    index, changed = _has_value_changed(consul_api, key, value)
216
217    if changed and not module.check_mode:
218        if state == 'acquire':
219            changed = consul_api.kv.put(key, value,
220                                        cas=module.params.get('cas'),
221                                        acquire=session,
222                                        flags=module.params.get('flags'))
223        else:
224            changed = consul_api.kv.put(key, value,
225                                        cas=module.params.get('cas'),
226                                        release=session,
227                                        flags=module.params.get('flags'))
228
229    module.exit_json(changed=changed,
230                     index=index,
231                     key=key)
232
233
234def get_value(module):
235    consul_api = get_consul_api(module)
236    key = module.params.get('key')
237
238    index, existing_value = consul_api.kv.get(key, recurse=module.params.get('recurse'))
239
240    module.exit_json(changed=False, index=index, data=existing_value)
241
242
243def set_value(module):
244    consul_api = get_consul_api(module)
245
246    key = module.params.get('key')
247    value = module.params.get('value')
248
249    if value is NOT_SET:
250        raise AssertionError('Cannot set value of "%s" to `NOT_SET`' % key)
251
252    index, changed = _has_value_changed(consul_api, key, value)
253
254    if changed and not module.check_mode:
255        changed = consul_api.kv.put(key, value,
256                                    cas=module.params.get('cas'),
257                                    flags=module.params.get('flags'))
258
259    stored = None
260    if module.params.get('retrieve'):
261        index, stored = consul_api.kv.get(key)
262
263    module.exit_json(changed=changed,
264                     index=index,
265                     key=key,
266                     data=stored)
267
268
269def remove_value(module):
270    ''' remove the value associated with the given key. if the recurse parameter
271     is set then any key prefixed with the given key will be removed. '''
272    consul_api = get_consul_api(module)
273
274    key = module.params.get('key')
275
276    index, existing = consul_api.kv.get(
277        key, recurse=module.params.get('recurse'))
278
279    changed = existing is not None
280    if changed and not module.check_mode:
281        consul_api.kv.delete(key, module.params.get('recurse'))
282
283    module.exit_json(changed=changed,
284                     index=index,
285                     key=key,
286                     data=existing)
287
288
289def get_consul_api(module, token=None):
290    return consul.Consul(host=module.params.get('host'),
291                         port=module.params.get('port'),
292                         scheme=module.params.get('scheme'),
293                         verify=module.params.get('validate_certs'),
294                         token=module.params.get('token'))
295
296
297def test_dependencies(module):
298    if not python_consul_installed:
299        module.fail_json(msg="python-consul required for this module. "
300                             "see https://python-consul.readthedocs.io/en/latest/#installation")
301
302
303def main():
304
305    module = AnsibleModule(
306        argument_spec=dict(
307            cas=dict(type='str'),
308            flags=dict(type='str'),
309            key=dict(type='str', required=True),
310            host=dict(type='str', default='localhost'),
311            scheme=dict(type='str', default='http'),
312            validate_certs=dict(type='bool', default=True),
313            port=dict(type='int', default=8500),
314            recurse=dict(type='bool'),
315            retrieve=dict(type='bool', default=True),
316            state=dict(type='str', default='present', choices=['absent', 'acquire', 'present', 'release']),
317            token=dict(type='str', no_log=True),
318            value=dict(type='str', default=NOT_SET),
319            session=dict(type='str'),
320        ),
321        supports_check_mode=True
322    )
323
324    test_dependencies(module)
325
326    try:
327        execute(module)
328    except ConnectionError as e:
329        module.fail_json(msg='Could not connect to consul agent at %s:%s, error was %s' % (
330            module.params.get('host'), module.params.get('port'), e))
331    except Exception as e:
332        module.fail_json(msg=str(e))
333
334
335if __name__ == '__main__':
336    main()
337