From 221b1c9fd05ac90612db208ac099692e4a6fcc49 Mon Sep 17 00:00:00 2001 From: Chris Houseknecht Date: Fri, 22 Apr 2016 15:19:12 -0400 Subject: [PATCH] Refactored docker inventory. (#15485) --- contrib/inventory/docker.py | 1091 +++++++++++++++++++++++++--------- contrib/inventory/docker.yml | 179 ++---- 2 files changed, 855 insertions(+), 415 deletions(-) diff --git a/contrib/inventory/docker.py b/contrib/inventory/docker.py index 7e8ee30a7c..0b07261b5e 100755 --- a/contrib/inventory/docker.py +++ b/contrib/inventory/docker.py @@ -1,6 +1,8 @@ #!/usr/bin/env python - -# (c) 2013, Paul Durivage +# +# (c) 2016 Paul Durivage +# Chris Houseknecht +# James Tanner # # This file is part of Ansible. # @@ -17,129 +19,344 @@ # You should have received a copy of the GNU General Public License # along with Ansible. If not, see . # -# -# Author: Paul Durivage -# -# Description: -# This module queries local or remote Docker daemons and generates -# inventory information. -# -# This plugin does not support targeting of specific hosts using the --host -# flag. Instead, it queries the Docker API for each container, running -# or not, and returns this data all once. -# -# The plugin returns the following custom attributes on Docker containers: -# docker_args -# docker_config -# docker_created -# docker_driver -# docker_exec_driver -# docker_host_config -# docker_hostname_path -# docker_hosts_path -# docker_id -# docker_image -# docker_name -# docker_network_settings -# docker_path -# docker_resolv_conf_path -# docker_state -# docker_volumes -# docker_volumes_rw -# -# Requirements: -# The docker-py module: https://github.com/dotcloud/docker-py -# -# Notes: -# A config file can be used to configure this inventory module, and there -# are several environment variables that can be set to modify the behavior -# of the plugin at runtime: -# DOCKER_CONFIG_FILE -# DOCKER_HOST -# DOCKER_VERSION -# DOCKER_TIMEOUT -# DOCKER_PRIVATE_SSH_PORT -# DOCKER_DEFAULT_IP -# -# Environment Variables: -# environment variable: DOCKER_CONFIG_FILE -# description: -# - A path to a Docker inventory hosts/defaults file in YAML format -# - A sample file has been provided, colocated with the inventory -# file called 'docker.yml' -# required: false -# default: Uses docker.docker.Client constructor defaults -# environment variable: DOCKER_HOST -# description: -# - The socket on which to connect to a Docker daemon API -# required: false -# default: Uses docker.docker.Client constructor defaults -# environment variable: DOCKER_VERSION -# description: -# - Version of the Docker API to use -# default: Uses docker.docker.Client constructor defaults -# required: false -# environment variable: DOCKER_TIMEOUT -# description: -# - Timeout in seconds for connections to Docker daemon API -# default: Uses docker.docker.Client constructor defaults -# required: false -# environment variable: DOCKER_PRIVATE_SSH_PORT -# description: -# - The private port (container port) on which SSH is listening -# for connections -# default: 22 -# required: false -# environment variable: DOCKER_DEFAULT_IP -# description: -# - This environment variable overrides the container SSH connection -# IP address (aka, 'ansible_ssh_host') -# -# This option allows one to override the ansible_ssh_host whenever -# Docker has exercised its default behavior of binding private ports -# to all interfaces of the Docker host. This behavior, when dealing -# with remote Docker hosts, does not allow Ansible to determine -# a proper host IP address on which to connect via SSH to containers. -# By default, this inventory module assumes all 0.0.0.0-exposed -# ports to be bound to localhost:. To override this -# behavior, for example, to bind a container's SSH port to the public -# interface of its host, one must manually set this IP. -# -# It is preferable to begin to launch Docker containers with -# ports exposed on publicly accessible IP addresses, particularly -# if the containers are to be targeted by Ansible for remote -# configuration, not accessible via localhost SSH connections. -# -# Docker containers can be explicitly exposed on IP addresses by -# a) starting the daemon with the --ip argument -# b) running containers with the -P/--publish ip::containerPort -# argument -# default: 127.0.0.1 if port exposed on 0.0.0.0 by Docker -# required: false -# -# Examples: -# Use the config file: -# DOCKER_CONFIG_FILE=./docker.yml docker.py --list -# -# Connect to docker instance on localhost port 4243 -# DOCKER_HOST=tcp://localhost:4243 docker.py --list -# -# Any container's ssh port exposed on 0.0.0.0 will mapped to -# another IP address (where Ansible will attempt to connect via SSH) -# DOCKER_DEFAULT_IP=1.2.3.4 docker.py --list + +DOCUMENTATION = ''' + +Docker Inventory Script +======================= +Generates dynamic inventory by making API requests to one or more Docker daemons. Communicates with the API +by way of docker-py (https://docker-py.readthedocs.org/en/stable/). So before running the script, you will need to +install docker-py: + + pip install docker-py + + +Run for Specific Host +--------------------- +When run for a specific container using the --host option this script returns the following hostvars: + +{ + "ansible_ssh_host": "", + "ansible_ssh_port": 0, + "docker_apparmorprofile": "", + "docker_args": [], + "docker_config": { + "AttachStderr": false, + "AttachStdin": false, + "AttachStdout": false, + "Cmd": [ + "/hello" + ], + "Domainname": "", + "Entrypoint": null, + "Env": null, + "Hostname": "9f2f80b0a702", + "Image": "hello-world", + "Labels": {}, + "OnBuild": null, + "OpenStdin": false, + "StdinOnce": false, + "Tty": false, + "User": "", + "Volumes": null, + "WorkingDir": "" + }, + "docker_created": "2016-04-18T02:05:59.659599249Z", + "docker_driver": "aufs", + "docker_execdriver": "native-0.2", + "docker_execids": null, + "docker_graphdriver": { + "Data": null, + "Name": "aufs" + }, + "docker_hostconfig": { + "Binds": null, + "BlkioWeight": 0, + "CapAdd": null, + "CapDrop": null, + "CgroupParent": "", + "ConsoleSize": [ + 0, + 0 + ], + "ContainerIDFile": "", + "CpuPeriod": 0, + "CpuQuota": 0, + "CpuShares": 0, + "CpusetCpus": "", + "CpusetMems": "", + "Devices": null, + "Dns": null, + "DnsOptions": null, + "DnsSearch": null, + "ExtraHosts": null, + "GroupAdd": null, + "IpcMode": "", + "KernelMemory": 0, + "Links": null, + "LogConfig": { + "Config": {}, + "Type": "json-file" + }, + "LxcConf": null, + "Memory": 0, + "MemoryReservation": 0, + "MemorySwap": 0, + "MemorySwappiness": null, + "NetworkMode": "default", + "OomKillDisable": false, + "PidMode": "host", + "PortBindings": null, + "Privileged": false, + "PublishAllPorts": false, + "ReadonlyRootfs": false, + "RestartPolicy": { + "MaximumRetryCount": 0, + "Name": "" + }, + "SecurityOpt": [ + "label:disable" + ], + "UTSMode": "", + "Ulimits": null, + "VolumeDriver": "", + "VolumesFrom": null + }, + "docker_hostnamepath": "/mnt/sda1/var/lib/docker/containers/9f2f80b0a702361d1ac432e6af816c19bda46da15c21264fb418c873de635a14/hostname", + "docker_hostspath": "/mnt/sda1/var/lib/docker/containers/9f2f80b0a702361d1ac432e6af816c19bda46da15c21264fb418c873de635a14/hosts", + "docker_id": "9f2f80b0a702361d1ac432e6af816c19bda46da15c21264fb418c873de635a14", + "docker_image": "0a6ba66e537a53a5ea94f7c6a99c534c6adb12e3ed09326d4bf3b38f7c3ba4e7", + "docker_logpath": "/mnt/sda1/var/lib/docker/containers/9f2f80b0a702361d1ac432e6af816c19bda46da15c21264fb418c873de635a14/9f2f80b0a702361d1ac432e6af816c19bda46da15c21264fb418c873de635a14-json.log", + "docker_mountlabel": "", + "docker_mounts": [], + "docker_name": "/hello-world", + "docker_networksettings": { + "Bridge": "", + "EndpointID": "", + "Gateway": "", + "GlobalIPv6Address": "", + "GlobalIPv6PrefixLen": 0, + "HairpinMode": false, + "IPAddress": "", + "IPPrefixLen": 0, + "IPv6Gateway": "", + "LinkLocalIPv6Address": "", + "LinkLocalIPv6PrefixLen": 0, + "MacAddress": "", + "Networks": { + "bridge": { + "EndpointID": "", + "Gateway": "", + "GlobalIPv6Address": "", + "GlobalIPv6PrefixLen": 0, + "IPAddress": "", + "IPPrefixLen": 0, + "IPv6Gateway": "", + "MacAddress": "" + } + }, + "Ports": null, + "SandboxID": "", + "SandboxKey": "", + "SecondaryIPAddresses": null, + "SecondaryIPv6Addresses": null + }, + "docker_path": "/hello", + "docker_processlabel": "", + "docker_resolvconfpath": "/mnt/sda1/var/lib/docker/containers/9f2f80b0a702361d1ac432e6af816c19bda46da15c21264fb418c873de635a14/resolv.conf", + "docker_restartcount": 0, + "docker_short_id": "9f2f80b0a7023", + "docker_state": { + "Dead": false, + "Error": "", + "ExitCode": 0, + "FinishedAt": "2016-04-18T02:06:00.296619369Z", + "OOMKilled": false, + "Paused": false, + "Pid": 0, + "Restarting": false, + "Running": false, + "StartedAt": "2016-04-18T02:06:00.272065041Z", + "Status": "exited" + } +} + +Groups +------ +When run in --list mode (the default), container instances are grouped by: + + - container id + - container name + - container short id + - image_name (image_) + - docker_host + - running + - stopped + + +Configuration: +-------------- +You can control the behavior of the inventory script by passing arguments, defining environment variables, or +creating a docker.yml file (sample provided in ansible/contrib/inventory). The order of precedence is command +line args, then the docker.yml file and finally environment variables. + +Environment variables: +;;;;;;;;;;;;;;;;;;;;;; + +DOCKER_CONFIG_FILE + description: path to docker inventory configuration file. + default: ./docker.yml + +DOCKER_HOST + description: Docker daemon URL or Unix socket path. + default: unix://var/run/docker.sock + +DOCKER_TLS_HOSTNAME: + description: When DOCKER_TLS_VERIFY is true, provide the expected name of the host. + default: localhost + +DOCKER_API_VERSION: + description: Version of the Docker API the client will use. + default: DEFAULT_DOCKER_API_VERSION as defined in docker-py + +DOCKER_CERT_PATH: + description: Path to the directory containing the client certificate and key files. + default: None + +DOCKER_SSL_VERSION: + description: Version of TLS supported by Docker daemon. + default: None + +DOCKER_TLS: + description: Use TLS when sending requests to Docker daemon. Set to 1, 0, true, false, True, False, yes, no. + default: False + +DOCKER_TLS_VERIFY: + description: Verify hostname found in TLS certs. Set to 1, 0, true, false, True, False, yes, no. + default: False + +DOCKER_TIMEOUT: + description: Docker request timeout in seconds. + default: Value of DOCKER_TIMEOUT as defined in docker-py + +DOCKER_PRIVATE_SSH_PORT: + description: The private port (container port) on which SSH is listening for connections + default: 22 + +DOCKER_DEFAULT_IP: + description: This environment variable overrides the container SSH connection + IP address (aka, 'ansible_ssh_host'). + + This option allows one to override the ansible_ssh_host whenever Docker has exercised its default behavior of + binding private ports to all interfaces of the Docker host. This behavior, when dealing with remote Docker hosts, + does not allow Ansible to determine a proper host IP address on which to connect via SSH to containers. By + default, this inventory module assumes all 0.0.0.0-exposed ports to be bound to localhost:. To override + this behavior, for example, to bind a container's SSH port to the public interface of its host, one must + manually set this IP. + + It is preferable to begin to launch Docker containers with ports exposed on publicly accessible IP addresses, + particularly if the containers are to be targeted by Ansible for remote configuration, not accessible via + localhost SSH connections. Docker containers can be explicitly exposed on IP addresses by + a) starting the daemon with the --ip argument + b) running containers with the -P/--publish ip::containerPort + argument + default: 127.0.0.1 if port exposed on 0.0.0.0 + + +docker.yml +;;;;;;;;;;;;;;;;;;;; + +A sample docker.yml file is included in the ansible/contrib/inventory. Using this file is not required. If +the file is not found, environment variables will be used. + +The default name of the file is derived from the name of the inventory script. By default the script will look for +basename of the script (i.e. docker) with an extension of '.yml'. You can override the default name by passing a +command line argument or setting DOCKER_CONFIG_FILE in the environment. + +Here's what you can define in docker_inventory.yml: + + * defaults: Defines a default connnection. Defaults will be taken from this and applied to any values not provided + for a host defined in the hosts list. + + * hosts: If you wish to get inventory from more than one Docker daemon hosts, define a hosts list. + +For a host defined in defaults or hosts, you can provided the following attributes. The only required attribute is host. + + host: + description: The URL or Unix socket path for the host. + required: yes + tls: + description: Connect using https:// + default: false + required: false + tls_verify: + description: Connect using https:// and verify the host name matches the host name found in the certificate. + default: false + required: false + cert_path: + description: Path to the host's certificate .pem file. + default: null + required: false + cacert_path: + description: Path to the host's Certificate Authority .pem file. + default: null + required: false + key_path: + description: Path to the host's encryption key .pem file + default: null + required: false + version: + description: The API version. + required: false + default: will be supplied by the docker-py module. + timeout: + description: The amount of time in seconds to wait on an API response. + required: false + default: will be supplied by the docker-py module. + default_ip: + description: The IP address to assign to ansilbe_host when the container's SSH port is mappped to 0.0.0.0 + required: false + default: 1267.0.0.1 + private_ssh_port: + description: The port containers use for SSH + required: false + default: 22 + +Examples +-------- + # Run the script with Env vars (for when you have Docker toolbox installed) + ./docker_inventory.py --pretty + + # Connect to docker instance on localhost port 4243 + DOCKER_HOST=tcp://localhost:4243 ./docker.py --pretty + + # Any container's ssh port exposed on 0.0.0.0 will mapped to + #another IP address (where Ansible will attempt to connect via SSH) + DOCKER_DEFAULT_IP=1.2.3.4 ./docker.py --pretty + + # Run as input to a playbook: + ansible-playbook -i ~/projects/ansible/contrib/inventory/docker_inventory.py docker_inventory_test.yml + + # Simple playbook to invoke with the above example: + + - name: Test docker_inventory + hosts: all + connection: local + gather_facts: no + tasks: + - debug: msg="Container - {{ inventory_hostname }}" + + +''' import os import sys import json import argparse - -from UserDict import UserDict -from collections import defaultdict - +import re import yaml -from requests import HTTPError, ConnectionError - +from collections import defaultdict # Manipulation of the path is needed because the docker-py # module is imported by the name docker, and because this file # is also named docker @@ -149,211 +366,507 @@ for path in [os.getcwd(), '', os.path.dirname(os.path.abspath(__file__))]: except: pass +HAS_DOCKER_PY = True +HAS_DOCKER_ERROR = False + try: - import docker -except ImportError: - print('docker-py is required for this module') + from docker import Client + from docker.errors import APIError, TLSParameterError + from docker.tls import TLSConfig + from docker.constants import DEFAULT_TIMEOUT_SECONDS, DEFAULT_DOCKER_API_VERSION +except ImportError, exc: + HAS_DOCKER_ERROR = str(exc) + HAS_DOCKER_PY = False + +DEFAULT_DOCKER_HOST = 'unix://var/run/docker.sock' +DEFAULT_TLS = False +DEFAULT_TLS_VERIFY = False +DEFAULT_IP = '127.0.0.1' +DEFAULT_SSH_PORT = '22' + +BOOLEANS_TRUE = ['yes', 'on', '1', 'true', 1, True] +BOOLEANS_FALSE = ['no', 'off', '0', 'false', 0, False] + + +DOCKER_ENV_ARGS = dict( + config_file='DOCKER_CONFIG_FILE', + docker_host='DOCKER_HOST', + api_version='DOCKER_API_VERSION', + cert_path='DOCKER_CERT_PATH', + ssl_version='DOCKER_SSL_VERSION', + tls='DOCKER_TLS', + tls_verify='DOCKER_TLS_VERIFY', + timeout='DOCKER_TIMEOUT', + private_ssh_port='DOCKER_DEFAULT_SSH_PORT', + default_ip='DOCKER_DEFAULT_IP', +) + + +def fail(msg): + sys.stderr.write("%s\n" % msg) sys.exit(1) -class HostDict(UserDict): - def __setitem__(self, key, value): - if value is not None: - self.data[key] = value - - def update(self, dict=None, **kwargs): - if dict is None: - pass - elif isinstance(dict, UserDict): - for k, v in dict.data.items(): - self[k] = v - else: - for k, v in dict.items(): - self[k] = v - if len(kwargs): - for k, v in kwargs.items(): - self[k] = v - - -def write_stderr(string): - sys.stderr.write('%s\n' % string) - - -def setup(): - config = dict() - config_file = os.environ.get('DOCKER_CONFIG_FILE') - if config_file: - try: - config_file = os.path.abspath(config_file) - except Exception as e: - write_stderr(e) - sys.exit(1) - - with open(config_file) as f: - try: - config = yaml.safe_load(f.read()) - except Exception as e: - write_stderr(e) - sys.exit(1) - - # Environment Variables - env_base_url = os.environ.get('DOCKER_HOST') - env_version = os.environ.get('DOCKER_VERSION') - env_timeout = os.environ.get('DOCKER_TIMEOUT') - env_ssh_port = os.environ.get('DOCKER_PRIVATE_SSH_PORT', '22') - env_default_ip = os.environ.get('DOCKER_DEFAULT_IP', '127.0.0.1') - # Config file defaults - defaults = config.get('defaults', dict()) - def_host = defaults.get('host') - def_version = defaults.get('version') - def_timeout = defaults.get('timeout') - def_default_ip = defaults.get('default_ip') - def_ssh_port = defaults.get('private_ssh_port') - - hosts = list() - - if config: - hosts_list = config.get('hosts', list()) - # Look to the config file's defined hosts - if hosts_list: - for host in hosts_list: - baseurl = host.get('host') or def_host or env_base_url - version = host.get('version') or def_version or env_version - timeout = host.get('timeout') or def_timeout or env_timeout - default_ip = host.get('default_ip') or def_default_ip or env_default_ip - ssh_port = host.get('private_ssh_port') or def_ssh_port or env_ssh_port - - hostdict = HostDict( - base_url=baseurl, - version=version, - timeout=timeout, - default_ip=default_ip, - private_ssh_port=ssh_port, - ) - hosts.append(hostdict) - # Look to the defaults - else: - hostdict = HostDict( - base_url=def_host, - version=def_version, - timeout=def_timeout, - default_ip=def_default_ip, - private_ssh_port=def_ssh_port, - ) - hosts.append(hostdict) - # Look to the environment +def log(msg, pretty_print=False): + if pretty_print: + print(json.dumps(msg, sort_keys=True, indent=2)) else: - hostdict = HostDict( - base_url=env_base_url, - version=env_version, - timeout=env_timeout, - default_ip=env_default_ip, - private_ssh_port=env_ssh_port, - ) - hosts.append(hostdict) - - return hosts + print(msg + u'\n') -def list_groups(): - hosts = setup() - groups = defaultdict(list) - hostvars = defaultdict(dict) +class AnsibleDockerClient(Client): + def __init__(self, auth_params, debug): - for host in hosts: - ssh_port = host.pop('private_ssh_port', None) - default_ip = host.pop('default_ip', None) - hostname = host.get('base_url') + self.auth_params = auth_params + self.debug = debug + self._connect_params = self._get_connect_params() + + try: + super(AnsibleDockerClient, self).__init__(**self._connect_params) + except APIError, exc: + self.fail("Docker API error: %s" % exc) + except Exception, exc: + self.fail("Error connecting: %s" % exc) + + def fail(self, msg): + fail(msg) + + def log(self, msg, pretty_print=False): + if self.debug: + log(msg, pretty_print) + + def _get_tls_config(self, **kwargs): + self.log("get_tls_config:") + for key in kwargs: + self.log(" %s: %s" % (key, kwargs[key])) + try: + tls_config = TLSConfig(**kwargs) + return tls_config + except TLSParameterError, exc: + self.fail("TLS config error: %s" % exc) + + def _get_connect_params(self): + auth = self.auth_params + + self.log("auth params:") + for key in auth: + self.log(" %s: %s" % (key, auth[key])) + + if auth['tls'] or auth['tls_verify']: + auth['docker_host'] = auth['docker_host'].replace('tcp://', 'https://') + + if auth['tls'] and auth['cert_path'] and auth['key_path']: + # TLS with certs and no host verification + tls_config = self._get_tls_config(client_cert=(auth['cert_path'], auth['key_path']), + verify=False, + ssl_version=auth['ssl_version']) + return dict(base_url=auth['docker_host'], + tls=tls_config, + version=auth['api_version'], + timeout=auth['timeout']) + + if auth['tls']: + # TLS with no certs and not host verification + tls_config = self._get_tls_config(verify=False, + ssl_version=auth['ssl_version']) + return dict(base_url=auth['docker_host'], + tls=tls_config, + version=auth['api_version'], + timeout=auth['timeout']) + + if auth['tls_verify'] and auth['cert_path'] and auth['key_path']: + # TLS with certs and host verification + if auth['cacert_path']: + tls_config = self._get_tls_config(client_cert=(auth['cert_path'], auth['key_path']), + ca_cert=auth['cacert_path'], + verify=True, + assert_hostname=auth['tls_hostname'], + ssl_version=auth['ssl_version']) + else: + tls_config = self._get_tls_config(client_cert=(auth['cert_path'], auth['key_path']), + verify=True, + assert_hostname=auth['tls_hostname'], + ssl_version=auth['ssl_version']) + + return dict(base_url=auth['docker_host'], + tls=tls_config, + version=auth['api_version'], + timeout=auth['timeout']) + + if auth['tls_verify'] and auth['cacert_path']: + # TLS with cacert only + tls_config = self._get_tls_config(ca_cert=auth['cacert_path'], + assert_hostname=auth['tls_hostname'], + verify=True, + ssl_version=auth['ssl_version']) + return dict(base_url=auth['docker_host'], + tls=tls_config, + version=auth['api_version'], + timeout=auth['timeout']) + + if auth['tls_verify']: + # TLS with verify and no certs + tls_config = self._get_tls_config(verify=True, + assert_hostname=auth['tls_hostname'], + ssl_version=auth['ssl_version']) + return dict(base_url=auth['docker_host'], + tls=tls_config, + version=auth['api_version'], + timeout=auth['timeout']) + # No TLS + return dict(base_url=auth['docker_host'], + version=auth['api_version'], + timeout=auth['timeout']) + + def _handle_ssl_error(self, error): + match = re.match(r"hostname.*doesn\'t match (\'.*\')", str(error)) + if match: + msg = "You asked for verification that Docker host name matches %s. The actual hostname is %s. " \ + "Most likely you need to set DOCKER_TLS_HOSTNAME or pass tls_hostname with a value of %s. " \ + "You may also use TLS without verification by setting the tls parameter to true." \ + % (self.auth_params['tls_hostname'], match.group(1)) + self.fail(msg) + self.fail("SSL Exception: %s" % (error)) + + +class EnvArgs(object): + def __init__(self): + self.config_file = None + self.docker_host = None + self.api_version = None + self.cert_path = None + self.ssl_version = None + self.tls = None + self.tls_verify = None + self.tls_hostname = None + self.timeout = None + self.default_ssh_port = None + self.default_ip = None + + +class DockerInventory(object): + + def __init__(self): + self._args = self._parse_cli_args() + self._env_args = self._parse_env_args() + self.groups = defaultdict(list) + self.hostvars = defaultdict(dict) + + def run(self): + config_from_file = self._parse_config_file() + if not config_from_file: + config_from_file = dict() + docker_hosts = self.get_hosts(config_from_file) + + for host in docker_hosts: + client = AnsibleDockerClient(host, self._args.debug) + self.get_inventory(client, host) + + if not self._args.host: + self.groups['docker_hosts'] = [host.get('docker_host') for host in docker_hosts] + self.groups['_meta'] = dict( + hostvars=self.hostvars + ) + print(self._json_format_dict(self.groups, pretty_print=self._args.pretty)) + else: + print(self._json_format_dict(self.hostvars.get(self._args.host, dict()), pretty_print=self._args.pretty)) + + sys.exit(0) + + def get_inventory(self, client, host): + + ssh_port = host.get('default_ssh_port') + default_ip = host.get('default_ip') + hostname = host.get('docker_host') try: - client = docker.Client(**host) containers = client.containers(all=True) - except (HTTPError, ConnectionError) as e: - write_stderr(e) - sys.exit(1) + except Exception as exc: + self.fail("Error fetching containers for host %s - %s" % (hostname, str(exc))) for container in containers: id = container.get('Id') short_id = id[:13] + try: name = container.get('Names', list()).pop(0).lstrip('/') except IndexError: name = short_id - if not id: - continue + if not self._args.host or (self._args.host and self._args.host in [name, id, short_id]): + try: + inspect = client.inspect_container(id) + except Exception as exc: + self.fail("Error inspecting container %s - %s" % (name, str(exc))) - inspect = client.inspect_container(id) - running = inspect.get('State', dict()).get('Running') + running = inspect.get('State', dict()).get('Running') - groups[id].append(name) - groups[name].append(name) - if not short_id in groups.keys(): - groups[short_id].append(name) - groups[hostname].append(name) + # Add container to groups + image_name = inspect.get('Config', dict()).get('Image') + if image_name: + self.groups["image_%s" % (image_name)].append(name) - if running is True: - groups['running'].append(name) - else: - groups['stopped'].append(name) + self.groups[id].append(name) + self.groups[name].append(name) + if short_id not in self.groups.keys(): + self.groups[short_id].append(name) + self.groups[hostname].append(name) - try: - port = client.port(container, ssh_port)[0] - except (IndexError, AttributeError, TypeError): - port = dict() + if running is True: + self.groups['running'].append(name) + else: + self.groups['stopped'].append(name) - try: - ip = default_ip if port['HostIp'] == '0.0.0.0' else port['HostIp'] - except KeyError: - ip = '' + # Figure ous ssh IP and Port + try: + # Lookup the public facing port Nat'ed to ssh port. + port = client.port(container, ssh_port)[0] + except (IndexError, AttributeError, TypeError): + port = dict() - container_info = dict( - ansible_ssh_host=ip, - ansible_ssh_port=port.get('HostPort', int()), - docker_args=inspect.get('Args'), - docker_config=inspect.get('Config'), - docker_created=inspect.get('Created'), - docker_driver=inspect.get('Driver'), - docker_exec_driver=inspect.get('ExecDriver'), - docker_host_config=inspect.get('HostConfig'), - docker_hostname_path=inspect.get('HostnamePath'), - docker_hosts_path=inspect.get('HostsPath'), - docker_id=inspect.get('ID'), - docker_image=inspect.get('Image'), - docker_name=name, - docker_network_settings=inspect.get('NetworkSettings'), - docker_path=inspect.get('Path'), - docker_resolv_conf_path=inspect.get('ResolvConfPath'), - docker_state=inspect.get('State'), - docker_volumes=inspect.get('Volumes'), - docker_volumes_rw=inspect.get('VolumesRW'), + try: + ip = default_ip if port['HostIp'] == '0.0.0.0' else port['HostIp'] + except KeyError: + ip = '' + + facts = dict( + ansible_ssh_host=ip, + ansible_ssh_port=port.get('HostPort', int()), + docker_name=name, + docker_short_id=short_id + ) + + for key in inspect: + fact_key = self._slugify(key) + facts[fact_key] = inspect.get(key) + + self.hostvars[name].update(facts) + + def _slugify(self, value): + return 'docker_%s' % (re.sub('[^\w-]', '_', value).lower().lstrip('_')) + + def get_hosts(self, config): + ''' + Determine the list of docker hosts we need to talk to. + + :param config: dictionary read from config file. can be empty. + :return: list of connection dictionaries + ''' + hosts = list() + + hosts_list = config.get('hosts') + defaults = config.get('defaults', dict()) + self.log('defaults:') + self.log(defaults, pretty_print=True) + def_host = defaults.get('host') + def_tls = defaults.get('tls') + def_tls_verify = defaults.get('tls_verify') + def_tls_hostname = defaults.get('tls_hostname') + def_ssl_version = defaults.get('ssl_version') + def_cert_path = defaults.get('cert_path') + def_cacert_path = defaults.get('cacert_path') + def_key_path = defaults.get('key_path') + def_version = defaults.get('version') + def_timeout = defaults.get('timeout') + def_ip = defaults.get('default_ip') + def_ssh_port = defaults.get('private_ssh_port') + + if hosts_list: + # use hosts from config file + for host in hosts_list: + docker_host = host.get('host') or def_host or self._args.docker_host or \ + self._env_args.docker_host or DEFAULT_DOCKER_HOST + api_version = host.get('version') or def_version or self._args.api_version or \ + self._env_args.api_version or DEFAULT_DOCKER_API_VERSION + tls_hostname = host.get('tls_hostname') or def_tls_hostname or self._args.tls_hostname or \ + self._env_args.tls_hostname + tls_verify = host.get('tls_verify') or def_tls_verify or self._args.tls_verify or \ + self._env_args.tls_verify or DEFAULT_TLS_VERIFY + tls = host.get('tls') or def_tls or self._args.tls or self._env_args.tls or DEFAULT_TLS + ssl_version = host.get('ssl_version') or def_ssl_version or self._args.ssl_version or \ + self._env_args.ssl_version + + cert_path = host.get('cert_path') or def_cert_path or self._args.cert_path or \ + self._env_args.cert_path + if cert_path and cert_path == self._env_args.cert_path: + cert_path = os.path.join(cert_path, 'cert.pem') + + cacert_path = host.get('cacert_path') or def_cacert_path or self._args.cacert_path or \ + self._env_args.cert_path + if cacert_path and cacert_path == self._env_args.cert_path: + cacert_path = os.path.join(cacert_path, 'ca.pem') + + key_path = host.get('key_path') or def_key_path or self._args.key_path or \ + self._env_args.cert_path + if key_path and key_path == self._env_args.cert_path: + key_path = os.path.join(key_path, 'key.pem') + + timeout = host.get('timeout') or def_timeout or self._args.timeout or self._env_args.timeout or \ + DEFAULT_TIMEOUT_SECONDS + default_ip = host.get('default_ip') or def_ip or self._args.default_ip_address or \ + DEFAULT_IP + default_ssh_port = host.get('private_ssh_port') or def_ssh_port or self._args.private_ssh_port or \ + DEFAULT_SSH_PORT + host_dict = dict( + docker_host=docker_host, + api_version=api_version, + tls=tls, + tls_verify=tls_verify, + tls_hostname=tls_hostname, + cert_path=cert_path, + cacert_path=cacert_path, + key_path=key_path, + ssl_version=ssl_version, + timeout=timeout, + default_ip=default_ip, + default_ssh_port=default_ssh_port, + ) + hosts.append(host_dict) + else: + # use default definition + docker_host = def_host or self._args.docker_host or self._env_args.docker_host or DEFAULT_DOCKER_HOST + api_version = def_version or self._args.api_version or self._env_args.api_version or \ + DEFAULT_DOCKER_API_VERSION + tls_hostname = def_tls_hostname or self._args.tls_hostname or self._env_args.tls_hostname + tls_verify = def_tls_verify or self._args.tls_verify or self._env_args.tls_verify or DEFAULT_TLS_VERIFY + tls = def_tls or self._args.tls or self._env_args.tls or DEFAULT_TLS + ssl_version = def_ssl_version or self._args.ssl_version or self._env_args.ssl_version + + cert_path = def_cert_path or self._args.cert_path or self._env_args.cert_path + if cert_path and cert_path == self._env_args.cert_path: + cert_path = os.path.join(cert_path, 'cert.pem') + + cacert_path = def_cacert_path or self._args.cacert_path or self._env_args.cert_path + if cacertpath and cacert_path == self._env_args.cert_path: + cacert_path = os.path.join(cacert_path, 'ca.pem') + + key_path = def_key_path or self._args.key_path or self._env_args.cert_path + if key_path and key_path == self._env_args.cert_path: + key_path = os.path.join(key_path, 'key.pem') + + timeout = def_timeout or self._args.timeout or self._env_args.timeout or DEFAULT_TIMEOUT_SECONDS + default_ip = def_ip or self._args.default_ip_address or DEFAULT_IP + default_ssh_port = def_ssh_port or self._args.private_ssh_port or DEFAULT_SSH_PORT + host_dict = dict( + docker_host=docker_host, + api_version=api_version, + tls=tls, + tls_verify=tls_verify, + tls_hostname=tls_hostname, + cert_path=cert_path, + cacert_path=cacert_path, + key_path=key_path, + ssl_version=ssl_version, + timeout=timeout, + default_ip=default_ip, + default_ssh_port=default_ssh_port, ) + hosts.append(host_dict) + self.log("hosts: ") + self.log(hosts, pretty_print=True) + return hosts - hostvars[name].update(container_info) + def _parse_config_file(self): + config = dict() + config_path = None - groups['docker_hosts'] = [host.get('base_url') for host in hosts] - groups['_meta'] = dict() - groups['_meta']['hostvars'] = hostvars - print(json.dumps(groups, sort_keys=True, indent=4)) - sys.exit(0) + if self._args.config_file: + config_path = self._args.config_file + elif self._env_args.config_file: + config_path = self._env_args.config_file + if config_path: + try: + config_file = os.path.abspath(config_path) + except: + config_file = None -def parse_args(): - parser = argparse.ArgumentParser() - group = parser.add_mutually_exclusive_group(required=True) - group.add_argument('--list', action='store_true') - group.add_argument('--host', action='store_true') - return parser.parse_args() + if config_file and os.path.exists(config_file): + with open(config_file) as f: + try: + config = yaml.safe_load(f.read()) + except Exception as exc: + self.fail("Error: parsing %s - %s" % (config_path, str(exc))) + return config + + def log(self, msg, pretty_print=False): + if self._args.debug: + log(msg, pretty_print) + + def fail(self, msg): + fail(msg) + + def _parse_env_args(self): + args = EnvArgs() + for key, value in DOCKER_ENV_ARGS.items(): + if os.environ.get(value): + val = os.environ.get(value) + if val in BOOLEANS_TRUE: + val = True + if val in BOOLEANS_FALSE: + val = False + setattr(args, key, val) + return args + + def _parse_cli_args(self): + # Parse command line arguments + + basename = os.path.splitext(os.path.basename(__file__))[0] + default_config = basename + '.yml' + + parser = argparse.ArgumentParser( + description='Return Ansible inventory for one or more Docker hosts.') + parser.add_argument('--list', action='store_true', default=True, + help='List all containers (default: True)') + parser.add_argument('--debug', action='store_true', default=False, + help='Send debug messages to STDOUT') + parser.add_argument('--host', action='store', + help='Only get information for a specific container.') + parser.add_argument('--pretty', action='store_true', default=False, + help='Pretty print JSON output(default: False)') + parser.add_argument('--config-file', action='store', default=default_config, + help="Name of the config file to use. Default is %s" % (default_config)) + parser.add_argument('--docker-host', action='store', default=None, + help="The base url or Unix sock path to connect to the docker daemon. Defaults to %s" + % (DEFAULT_DOCKER_HOST)) + parser.add_argument('--tls-hostname', action='store', default='localhost', + help="Host name to expect in TLS certs. Defaults to 'localhost'") + parser.add_argument('--api-version', action='store', default=None, + help="Docker daemon API version. Defaults to %s" % (DEFAULT_DOCKER_API_VERSION)) + parser.add_argument('--timeout', action='store', default=None, + help="Docker connection timeout in seconds. Defaults to %s" + % (DEFAULT_TIMEOUT_SECONDS)) + parser.add_argument('--cacert-path', action='store', default=None, + help="Path to the TLS certificate authority pem file.") + parser.add_argument('--cert-path', action='store', default=None, + help="Path to the TLS certificate pem file.") + parser.add_argument('--key-path', action='store', default=None, + help="Path to the TLS encryption key pem file.") + parser.add_argument('--ssl-version', action='store', default=None, + help="TLS version number") + parser.add_argument('--tls', action='store_true', default=None, + help="Use TLS. Defaults to %s" % (DEFAULT_TLS)) + parser.add_argument('--tls-verify', action='store_true', default=None, + help="Verify TLS certificates. Defaults to %s" % (DEFAULT_TLS_VERIFY)) + parser.add_argument('--private-ssh-port', action='store', default=None, + help="Default private container SSH Port. Defaults to %s" % (DEFAULT_SSH_PORT)) + parser.add_argument('--default-ip-address', action='store', default=None, + help="Default container SSH IP address. Defaults to %s" % (DEFAULT_IP)) + return parser.parse_args() + + def _json_format_dict(self, data, pretty_print=False): + # format inventory data for output + if pretty_print: + return json.dumps(data, sort_keys=True, indent=4) + else: + return json.dumps(data) def main(): - args = parse_args() - if args.list: - list_groups() - elif args.host: - write_stderr('This option is not supported.') - sys.exit(1) - sys.exit(0) + if not HAS_DOCKER_PY: + fail("Failed to import docker-py. Try `pip install docker-py` - %s" % (HAS_DOCKER_ERROR)) + + DockerInventory().run() main() diff --git a/contrib/inventory/docker.yml b/contrib/inventory/docker.yml index 53e3c3f364..16a2b0716e 100644 --- a/contrib/inventory/docker.yml +++ b/contrib/inventory/docker.yml @@ -1,134 +1,61 @@ -# This is the configuration file for the Ansible plugin for Docker inventory. +# This is the configuration file for the Docker inventory script: docker_inventory.py. # -# Author: Paul Durivage +# defaults: Defines a default connnection. Defaults will be taken from this and applied to any values not provided +# for a host defined in the hosts list. # -# Description: -# This module queries local or remote Docker daemons and generates -# inventory information. +# hosts: If you wish to get inventory from more than one Docker daemon hosts, define a hosts list. +# +# For a host defined in defaults or hosts, you can provided the following attributes. The only required attribute is host. # -# This plugin does not support targeting of specific hosts using the --host -# flag. Instead, it it queries the Docker API for each container, running -# or not, and returns this data all once. -# -# The plugin returns the following custom attributes on Docker containers: -# docker_args -# docker_config -# docker_created -# docker_driver -# docker_exec_driver -# docker_host_config -# docker_hostname_path -# docker_hosts_path -# docker_id -# docker_image -# docker_name -# docker_network_settings -# docker_path -# docker_resolv_conf_path -# docker_state -# docker_volumes -# docker_volumes_rw -# -# Requirements: -# The docker-py module: https://github.com/dotcloud/docker-py -# -# Notes: -# A config file can be used to configure this inventory module, and there -# are several environment variables that can be set to modify the behavior -# of the plugin at runtime: -# DOCKER_CONFIG_FILE -# DOCKER_HOST -# DOCKER_VERSION -# DOCKER_TIMEOUT -# DOCKER_PRIVATE_SSH_PORT -# DOCKER_DEFAULT_IP -# -# Environment Variables: -# environment variable: DOCKER_CONFIG_FILE -# description: -# - A path to a Docker inventory hosts/defaults file in YAML format -# - A sample file has been provided, colocated with the inventory -# file called 'docker.yml' -# required: false -# default: Uses docker.docker.Client constructor defaults -# environment variable: DOCKER_HOST -# description: -# - The socket on which to connect to a Docker daemon API -# required: false -# default: Uses docker.docker.Client constructor defaults -# environment variable: DOCKER_VERSION -# description: -# - Version of the Docker API to use -# default: Uses docker.docker.Client constructor defaults -# required: false -# environment variable: DOCKER_TIMEOUT -# description: -# - Timeout in seconds for connections to Docker daemon API -# default: Uses docker.docker.Client constructor defaults -# required: false -# environment variable: DOCKER_PRIVATE_SSH_PORT -# description: -# - The private port (container port) on which SSH is listening -# for connections -# default: 22 -# required: false -# environment variable: DOCKER_DEFAULT_IP -# description: -# - This environment variable overrides the container SSH connection -# IP address (aka, 'ansible_ssh_host') -# -# This option allows one to override the ansible_ssh_host whenever -# Docker has exercised its default behavior of binding private ports -# to all interfaces of the Docker host. This behavior, when dealing -# with remote Docker hosts, does not allow Ansible to determine -# a proper host IP address on which to connect via SSH to containers. -# By default, this inventory module assumes all 0.0.0.0-exposed -# ports to be bound to localhost:. To override this -# behavior, for example, to bind a container's SSH port to the public -# interface of its host, one must manually set this IP. -# -# It is preferable to begin to launch Docker containers with -# ports exposed on publicly accessible IP addresses, particularly -# if the containers are to be targeted by Ansible for remote -# configuration, not accessible via localhost SSH connections. -# -# Docker containers can be explicitly exposed on IP addresses by -# a) starting the daemon with the --ip argument -# b) running containers with the -P/--publish ip::containerPort -# argument -# default: 127.0.0.1 if port exposed on 0.0.0.0 by Docker -# required: false -# -# Examples: -# Use the config file: -# DOCKER_CONFIG_FILE=./docker.yml docker.py --list -# -# Connect to docker instance on localhost port 4243 -# DOCKER_HOST=tcp://localhost:4243 docker.py --list -# -# Any container's ssh port exposed on 0.0.0.0 will mapped to -# another IP address (where Ansible will attempt to connect via SSH) -# DOCKER_DEFAULT_IP=1.2.3.4 docker.py --list -# -# -# -# The Docker inventory plugin provides several environment variables that -# may be overridden here. This configuration file always takes precedence -# over environment variables. -# -# Variable precedence is: hosts > defaults > environment +# host: +# description: The URL or Unix socket path for the host. +# required: yes +# tls: +# description: Connect using https:// +# default: false +# required: false +# tls_verify: +# description: Connect using https:// and verify the host name matches the host name found in the certificate. +# default: false +# required: false +# cert_path: +# description: Path to the client's certificate .pem file. +# default: null +# required: false +# cacert_path: +# description: Path to the client's Certificate Authority .pem file. +# default: null +# required: false +# key_path: +# description: Path to the client's encryption key .pem file +# default: null +# required: false +# version: +# description: The API version the client will use. +# required: false +# default: will be supplied by the docker-py module. +# timeout: +# description: The amount of time in seconds to wait on an API response. +# required: false +# default: will be supplied by the docker-py module. +# default_ip: +# description: The IP address to assign to ansilbe_host when the container's SSH port is mappped to 0.0.0.0 +# required: false +# default: 1267.0.0.1 +# private_ssh_port: +# description: The port containers use for SSH +# required: false +# default: 22 +# ---- -defaults: - host: unix:///var/run/docker.sock - version: 1.9 - timeout: 60 - private_ssh_port: 22 - default_ip: 127.0.0.1 -hosts: +#defaults: +# host: unix:///var/run/docker.sock +# private_ssh_port: 22 +# default_ip: 127.0.0.1 + +#hosts: # - host: tcp://10.45.5.16:4243 -# version: 1.9 -# timeout: 60 # private_ssh_port: 2022 # default_ip: 172.16.3.45 # - host: tcp://localhost:4243 +# private_ssh_port: 2029