1#!/usr/local/bin/python3.8
2# -*- coding: utf-8 -*-
3#
4# (c) 2018, Jean-Philippe Evrard <jean-philippe@evrard.me>
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
11DOCUMENTATION = '''
12---
13module: etcd3
14short_description: "Set or delete key value pairs from an etcd3 cluster"
15requirements:
16  - etcd3
17description:
18   - Sets or deletes values in etcd3 cluster using its v3 api.
19   - Needs python etcd3 lib to work
20options:
21    key:
22        type: str
23        description:
24            - the key where the information is stored in the cluster
25        required: true
26    value:
27        type: str
28        description:
29            - the information stored
30        required: true
31    host:
32        type: str
33        description:
34            - the IP address of the cluster
35        default: 'localhost'
36    port:
37        type: int
38        description:
39            - the port number used to connect to the cluster
40        default: 2379
41    state:
42        type: str
43        description:
44            - the state of the value for the key.
45            - can be present or absent
46        required: true
47        choices: [ present, absent ]
48    user:
49        type: str
50        description:
51            - The etcd user to authenticate with.
52    password:
53        type: str
54        description:
55            - The password to use for authentication.
56            - Required if I(user) is defined.
57    ca_cert:
58        type: path
59        description:
60            - The Certificate Authority to use to verify the etcd host.
61            - Required if I(client_cert) and I(client_key) are defined.
62    client_cert:
63        type: path
64        description:
65            - PEM formatted certificate chain file to be used for SSL client authentication.
66            - Required if I(client_key) is defined.
67    client_key:
68        type: path
69        description:
70            - PEM formatted file that contains your private key to be used for SSL client authentication.
71            - Required if I(client_cert) is defined.
72    timeout:
73        type: int
74        description:
75            - The socket level timeout in seconds.
76author:
77    - Jean-Philippe Evrard (@evrardjp)
78    - Victor Fauth (@vfauth)
79'''
80
81EXAMPLES = """
82- name: Store a value "bar" under the key "foo" for a cluster located "http://localhost:2379"
83  community.general.etcd3:
84    key: "foo"
85    value: "baz3"
86    host: "localhost"
87    port: 2379
88    state: "present"
89
90- name: Authenticate using user/password combination with a timeout of 10 seconds
91  community.general.etcd3:
92    key: "foo"
93    value: "baz3"
94    state: "present"
95    user: "someone"
96    password: "password123"
97    timeout: 10
98
99- name: Authenticate using TLS certificates
100  community.general.etcd3:
101    key: "foo"
102    value: "baz3"
103    state: "present"
104    ca_cert: "/etc/ssl/certs/CA_CERT.pem"
105    client_cert: "/etc/ssl/certs/cert.crt"
106    client_key: "/etc/ssl/private/key.pem"
107"""
108
109RETURN = '''
110key:
111    description: The key that was queried
112    returned: always
113    type: str
114old_value:
115    description: The previous value in the cluster
116    returned: always
117    type: str
118'''
119
120import traceback
121
122from ansible.module_utils.basic import AnsibleModule, missing_required_lib
123from ansible.module_utils.common.text.converters import to_native
124
125
126try:
127    import etcd3
128    HAS_ETCD = True
129except ImportError:
130    ETCD_IMP_ERR = traceback.format_exc()
131    HAS_ETCD = False
132
133
134def run_module():
135    # define the available arguments/parameters that a user can pass to
136    # the module
137    module_args = dict(
138        key=dict(type='str', required=True, no_log=False),
139        value=dict(type='str', required=True),
140        host=dict(type='str', default='localhost'),
141        port=dict(type='int', default=2379),
142        state=dict(type='str', required=True, choices=['present', 'absent']),
143        user=dict(type='str'),
144        password=dict(type='str', no_log=True),
145        ca_cert=dict(type='path'),
146        client_cert=dict(type='path'),
147        client_key=dict(type='path'),
148        timeout=dict(type='int'),
149    )
150
151    # seed the result dict in the object
152    # we primarily care about changed and state
153    # change is if this module effectively modified the target
154    # state will include any data that you want your module to pass back
155    # for consumption, for example, in a subsequent task
156    result = dict(
157        changed=False,
158    )
159
160    # the AnsibleModule object will be our abstraction working with Ansible
161    # this includes instantiation, a couple of common attr would be the
162    # args/params passed to the execution, as well as if the module
163    # supports check mode
164    module = AnsibleModule(
165        argument_spec=module_args,
166        supports_check_mode=True,
167        required_together=[['client_cert', 'client_key'], ['user', 'password']],
168    )
169
170    # It is possible to set `ca_cert` to verify the server identity without
171    # setting `client_cert` or `client_key` to authenticate the client
172    # so required_together is enough
173    # Due to `required_together=[['client_cert', 'client_key']]`, checking the presence
174    # of either `client_cert` or `client_key` is enough
175    if module.params['ca_cert'] is None and module.params['client_cert'] is not None:
176        module.fail_json(msg="The 'ca_cert' parameter must be defined when 'client_cert' and 'client_key' are present.")
177
178    result['key'] = module.params.get('key')
179    module.params['cert_cert'] = module.params.pop('client_cert')
180    module.params['cert_key'] = module.params.pop('client_key')
181
182    if not HAS_ETCD:
183        module.fail_json(msg=missing_required_lib('etcd3'), exception=ETCD_IMP_ERR)
184
185    allowed_keys = ['host', 'port', 'ca_cert', 'cert_cert', 'cert_key',
186                    'timeout', 'user', 'password']
187    # TODO(evrardjp): Move this back to a dict comprehension when python 2.7 is
188    # the minimum supported version
189    # client_params = {key: value for key, value in module.params.items() if key in allowed_keys}
190    client_params = dict()
191    for key, value in module.params.items():
192        if key in allowed_keys:
193            client_params[key] = value
194    try:
195        etcd = etcd3.client(**client_params)
196    except Exception as exp:
197        module.fail_json(msg='Cannot connect to etcd cluster: %s' % (to_native(exp)),
198                         exception=traceback.format_exc())
199    try:
200        cluster_value = etcd.get(module.params['key'])
201    except Exception as exp:
202        module.fail_json(msg='Cannot reach data: %s' % (to_native(exp)),
203                         exception=traceback.format_exc())
204
205    # Make the cluster_value[0] a string for string comparisons
206    result['old_value'] = to_native(cluster_value[0])
207
208    if module.params['state'] == 'absent':
209        if cluster_value[0] is not None:
210            if module.check_mode:
211                result['changed'] = True
212            else:
213                try:
214                    etcd.delete(module.params['key'])
215                except Exception as exp:
216                    module.fail_json(msg='Cannot delete %s: %s' % (module.params['key'], to_native(exp)),
217                                     exception=traceback.format_exc())
218                else:
219                    result['changed'] = True
220    elif module.params['state'] == 'present':
221        if result['old_value'] != module.params['value']:
222            if module.check_mode:
223                result['changed'] = True
224            else:
225                try:
226                    etcd.put(module.params['key'], module.params['value'])
227                except Exception as exp:
228                    module.fail_json(msg='Cannot add or edit key %s: %s' % (module.params['key'], to_native(exp)),
229                                     exception=traceback.format_exc())
230                else:
231                    result['changed'] = True
232    else:
233        module.fail_json(msg="State not recognized")
234
235    # manipulate or modify the state as needed (this is going to be the
236    # part where your module will do what it needs to do)
237
238    # during the execution of the module, if there is an exception or a
239    # conditional state that effectively causes a failure, run
240    # AnsibleModule.fail_json() to pass in the message and the result
241
242    # in the event of a successful module execution, you will want to
243    # simple AnsibleModule.exit_json(), passing the key/value results
244    module.exit_json(**result)
245
246
247def main():
248    run_module()
249
250
251if __name__ == '__main__':
252    main()
253