1# -*- coding: utf-8 -*-
2# Copyright (c) 2018, Stefan Heitmueller <stefan.heitmueller@gmx.com>
3# Copyright (c) 2018 Ansible Project
4# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
5
6from __future__ import (absolute_import, division, print_function)
7
8__metaclass__ = type
9
10DOCUMENTATION = '''
11    name: docker_swarm
12    plugin_type: inventory
13    version_added: '2.8'
14    author:
15      - Stefan Heitmüller (@morph027) <stefan.heitmueller@gmx.com>
16    short_description: Ansible dynamic inventory plugin for Docker swarm nodes.
17    requirements:
18        - python >= 2.7
19        - L(Docker SDK for Python,https://docker-py.readthedocs.io/en/stable/) >= 1.10.0
20    extends_documentation_fragment:
21        - constructed
22    description:
23        - Reads inventories from the Docker swarm API.
24        - Uses a YAML configuration file docker_swarm.[yml|yaml].
25        - "The plugin returns following groups of swarm nodes:  I(all) - all hosts; I(workers) - all worker nodes;
26          I(managers) - all manager nodes; I(leader) - the swarm leader node;
27          I(nonleaders) - all nodes except the swarm leader."
28    options:
29        plugin:
30            description: The name of this plugin, it should always be set to C(docker_swarm) for this plugin to
31                         recognize it as it's own.
32            type: str
33            required: true
34            choices: docker_swarm
35        docker_host:
36            description:
37                - Socket of a Docker swarm manager node (C(tcp), C(unix)).
38                - "Use C(unix://var/run/docker.sock) to connect via local socket."
39            type: str
40            required: true
41            aliases: [ docker_url ]
42        verbose_output:
43            description: Toggle to (not) include all available nodes metadata (e.g. C(Platform), C(Architecture), C(OS),
44                         C(EngineVersion))
45            type: bool
46            default: yes
47        tls:
48            description: Connect using TLS without verifying the authenticity of the Docker host server.
49            type: bool
50            default: no
51        validate_certs:
52            description: Toggle if connecting using TLS with or without verifying the authenticity of the Docker
53                         host server.
54            type: bool
55            default: no
56            aliases: [ tls_verify ]
57        client_key:
58            description: Path to the client's TLS key file.
59            type: path
60            aliases: [ tls_client_key, key_path ]
61        ca_cert:
62            description: Use a CA certificate when performing server verification by providing the path to a CA
63                         certificate file.
64            type: path
65            aliases: [ tls_ca_cert, cacert_path ]
66        client_cert:
67            description: Path to the client's TLS certificate file.
68            type: path
69            aliases: [ tls_client_cert, cert_path ]
70        tls_hostname:
71            description: When verifying the authenticity of the Docker host server, provide the expected name of
72                         the server.
73            type: str
74        ssl_version:
75            description: Provide a valid SSL version number. Default value determined by ssl.py module.
76            type: str
77        api_version:
78            description:
79                - The version of the Docker API running on the Docker Host.
80                - Defaults to the latest version of the API supported by docker-py.
81            type: str
82            aliases: [ docker_api_version ]
83        timeout:
84            description:
85                - The maximum amount of time in seconds to wait on a response from the API.
86                - If the value is not specified in the task, the value of environment variable C(DOCKER_TIMEOUT)
87                  will be used instead. If the environment variable is not set, the default value will be used.
88            type: int
89            default: 60
90            aliases: [ time_out ]
91        include_host_uri:
92            description: Toggle to return the additional attribute C(ansible_host_uri) which contains the URI of the
93                         swarm leader in format of C(tcp://172.16.0.1:2376). This value may be used without additional
94                         modification as value of option I(docker_host) in Docker Swarm modules when connecting via API.
95                         The port always defaults to C(2376).
96            type: bool
97            default: no
98        include_host_uri_port:
99            description: Override the detected port number included in I(ansible_host_uri)
100            type: int
101'''
102
103EXAMPLES = '''
104# Minimal example using local docker
105plugin: docker_swarm
106docker_host: unix://var/run/docker.sock
107
108# Minimal example using remote docker
109plugin: docker_swarm
110docker_host: tcp://my-docker-host:2375
111
112# Example using remote docker with unverified TLS
113plugin: docker_swarm
114docker_host: tcp://my-docker-host:2376
115tls: yes
116
117# Example using remote docker with verified TLS and client certificate verification
118plugin: docker_swarm
119docker_host: tcp://my-docker-host:2376
120validate_certs: yes
121ca_cert: /somewhere/ca.pem
122client_key: /somewhere/key.pem
123client_cert: /somewhere/cert.pem
124
125# Example using constructed features to create groups and set ansible_host
126plugin: docker_swarm
127docker_host: tcp://my-docker-host:2375
128strict: False
129keyed_groups:
130  # add e.g. x86_64 hosts to an arch_x86_64 group
131  - prefix: arch
132    key: 'Description.Platform.Architecture'
133  # add e.g. linux hosts to an os_linux group
134  - prefix: os
135    key: 'Description.Platform.OS'
136  # create a group per node label
137  # e.g. a node labeled w/ "production" ends up in group "label_production"
138  # hint: labels containing special characters will be converted to safe names
139  - key: 'Spec.Labels'
140    prefix: label
141'''
142
143from ansible.errors import AnsibleError
144from ansible.module_utils._text import to_native
145from ansible.module_utils.six.moves.urllib.parse import urlparse
146from ansible.plugins.inventory import BaseInventoryPlugin, Constructable
147from ansible.parsing.utils.addresses import parse_address
148
149try:
150    import docker
151    from docker.errors import TLSParameterError
152    from docker.tls import TLSConfig
153    HAS_DOCKER = True
154except ImportError:
155    HAS_DOCKER = False
156
157
158def update_tls_hostname(result):
159    if result['tls_hostname'] is None:
160        # get default machine name from the url
161        parsed_url = urlparse(result['docker_host'])
162        if ':' in parsed_url.netloc:
163            result['tls_hostname'] = parsed_url.netloc[:parsed_url.netloc.rindex(':')]
164        else:
165            result['tls_hostname'] = parsed_url
166
167
168def _get_tls_config(fail_function, **kwargs):
169    try:
170        tls_config = TLSConfig(**kwargs)
171        return tls_config
172    except TLSParameterError as exc:
173        fail_function("TLS config error: %s" % exc)
174
175
176def get_connect_params(auth, fail_function):
177    if auth['tls'] or auth['tls_verify']:
178        auth['docker_host'] = auth['docker_host'].replace('tcp://', 'https://')
179
180    if auth['tls_verify'] and auth['cert_path'] and auth['key_path']:
181        # TLS with certs and host verification
182        if auth['cacert_path']:
183            tls_config = _get_tls_config(client_cert=(auth['cert_path'], auth['key_path']),
184                                         ca_cert=auth['cacert_path'],
185                                         verify=True,
186                                         assert_hostname=auth['tls_hostname'],
187                                         ssl_version=auth['ssl_version'],
188                                         fail_function=fail_function)
189        else:
190            tls_config = _get_tls_config(client_cert=(auth['cert_path'], auth['key_path']),
191                                         verify=True,
192                                         assert_hostname=auth['tls_hostname'],
193                                         ssl_version=auth['ssl_version'],
194                                         fail_function=fail_function)
195
196        return dict(base_url=auth['docker_host'],
197                    tls=tls_config,
198                    version=auth['api_version'],
199                    timeout=auth['timeout'])
200
201    if auth['tls_verify'] and auth['cacert_path']:
202        # TLS with cacert only
203        tls_config = _get_tls_config(ca_cert=auth['cacert_path'],
204                                     assert_hostname=auth['tls_hostname'],
205                                     verify=True,
206                                     ssl_version=auth['ssl_version'],
207                                     fail_function=fail_function)
208        return dict(base_url=auth['docker_host'],
209                    tls=tls_config,
210                    version=auth['api_version'],
211                    timeout=auth['timeout'])
212
213    if auth['tls_verify']:
214        # TLS with verify and no certs
215        tls_config = _get_tls_config(verify=True,
216                                     assert_hostname=auth['tls_hostname'],
217                                     ssl_version=auth['ssl_version'],
218                                     fail_function=fail_function)
219        return dict(base_url=auth['docker_host'],
220                    tls=tls_config,
221                    version=auth['api_version'],
222                    timeout=auth['timeout'])
223
224    if auth['tls'] and auth['cert_path'] and auth['key_path']:
225        # TLS with certs and no host verification
226        tls_config = _get_tls_config(client_cert=(auth['cert_path'], auth['key_path']),
227                                     verify=False,
228                                     ssl_version=auth['ssl_version'],
229                                     fail_function=fail_function)
230        return dict(base_url=auth['docker_host'],
231                    tls=tls_config,
232                    version=auth['api_version'],
233                    timeout=auth['timeout'])
234
235    if auth['tls']:
236        # TLS with no certs and not host verification
237        tls_config = _get_tls_config(verify=False,
238                                     ssl_version=auth['ssl_version'],
239                                     fail_function=fail_function)
240        return dict(base_url=auth['docker_host'],
241                    tls=tls_config,
242                    version=auth['api_version'],
243                    timeout=auth['timeout'])
244
245    # No TLS
246    return dict(base_url=auth['docker_host'],
247                version=auth['api_version'],
248                timeout=auth['timeout'])
249
250
251class InventoryModule(BaseInventoryPlugin, Constructable):
252    ''' Host inventory parser for ansible using Docker swarm as source. '''
253
254    NAME = 'docker_swarm'
255
256    def _fail(self, msg):
257        raise AnsibleError(msg)
258
259    def _populate(self):
260        raw_params = dict(
261            docker_host=self.get_option('docker_host'),
262            tls=self.get_option('tls'),
263            tls_verify=self.get_option('validate_certs'),
264            key_path=self.get_option('client_key'),
265            cacert_path=self.get_option('ca_cert'),
266            cert_path=self.get_option('client_cert'),
267            tls_hostname=self.get_option('tls_hostname'),
268            api_version=self.get_option('api_version'),
269            timeout=self.get_option('timeout'),
270            ssl_version=self.get_option('ssl_version'),
271            debug=None,
272        )
273        update_tls_hostname(raw_params)
274        connect_params = get_connect_params(raw_params, fail_function=self._fail)
275        self.client = docker.DockerClient(**connect_params)
276        self.inventory.add_group('all')
277        self.inventory.add_group('manager')
278        self.inventory.add_group('worker')
279        self.inventory.add_group('leader')
280        self.inventory.add_group('nonleaders')
281
282        if self.get_option('include_host_uri'):
283            if self.get_option('include_host_uri_port'):
284                host_uri_port = str(self.get_option('include_host_uri_port'))
285            elif self.get_option('tls') or self.get_option('validate_certs'):
286                host_uri_port = '2376'
287            else:
288                host_uri_port = '2375'
289
290        try:
291            self.nodes = self.client.nodes.list()
292            for self.node in self.nodes:
293                self.node_attrs = self.client.nodes.get(self.node.id).attrs
294                self.inventory.add_host(self.node_attrs['ID'])
295                self.inventory.add_host(self.node_attrs['ID'], group=self.node_attrs['Spec']['Role'])
296                self.inventory.set_variable(self.node_attrs['ID'], 'ansible_host',
297                                            self.node_attrs['Status']['Addr'])
298                if self.get_option('include_host_uri'):
299                    self.inventory.set_variable(self.node_attrs['ID'], 'ansible_host_uri',
300                                                'tcp://' + self.node_attrs['Status']['Addr'] + ':' + host_uri_port)
301                if self.get_option('verbose_output'):
302                    self.inventory.set_variable(self.node_attrs['ID'], 'docker_swarm_node_attributes', self.node_attrs)
303                if 'ManagerStatus' in self.node_attrs:
304                    if self.node_attrs['ManagerStatus'].get('Leader'):
305                        # This is workaround of bug in Docker when in some cases the Leader IP is 0.0.0.0
306                        # Check moby/moby#35437 for details
307                        swarm_leader_ip = parse_address(self.node_attrs['ManagerStatus']['Addr'])[0] or \
308                            self.node_attrs['Status']['Addr']
309                        if self.get_option('include_host_uri'):
310                            self.inventory.set_variable(self.node_attrs['ID'], 'ansible_host_uri',
311                                                        'tcp://' + swarm_leader_ip + ':' + host_uri_port)
312                        self.inventory.set_variable(self.node_attrs['ID'], 'ansible_host', swarm_leader_ip)
313                        self.inventory.add_host(self.node_attrs['ID'], group='leader')
314                    else:
315                        self.inventory.add_host(self.node_attrs['ID'], group='nonleaders')
316                else:
317                    self.inventory.add_host(self.node_attrs['ID'], group='nonleaders')
318                # Use constructed if applicable
319                strict = self.get_option('strict')
320                # Composed variables
321                self._set_composite_vars(self.get_option('compose'),
322                                         self.node_attrs,
323                                         self.node_attrs['ID'],
324                                         strict=strict)
325                # Complex groups based on jinja2 conditionals, hosts that meet the conditional are added to group
326                self._add_host_to_composed_groups(self.get_option('groups'),
327                                                  self.node_attrs,
328                                                  self.node_attrs['ID'],
329                                                  strict=strict)
330                # Create groups based on variable values and add the corresponding hosts to it
331                self._add_host_to_keyed_groups(self.get_option('keyed_groups'),
332                                               self.node_attrs,
333                                               self.node_attrs['ID'],
334                                               strict=strict)
335        except Exception as e:
336            raise AnsibleError('Unable to fetch hosts from Docker swarm API, this was the original exception: %s' %
337                               to_native(e))
338
339    def verify_file(self, path):
340        """Return the possibly of a file being consumable by this plugin."""
341        return (
342            super(InventoryModule, self).verify_file(path) and
343            path.endswith((self.NAME + '.yaml', self.NAME + '.yml')))
344
345    def parse(self, inventory, loader, path, cache=True):
346        if not HAS_DOCKER:
347            raise AnsibleError('The Docker swarm dynamic inventory plugin requires the Docker SDK for Python: '
348                               'https://github.com/docker/docker-py.')
349        super(InventoryModule, self).parse(inventory, loader, path, cache)
350        self._read_config_data(path)
351        self._populate()
352