From 66eb91c2f8298e40509a498775026564bc16d56d Mon Sep 17 00:00:00 2001 From: Joshua Conner Date: Tue, 25 Feb 2014 15:24:46 -0800 Subject: [PATCH] docker: update to docker-py 0.3.0, plus full port binding support, container names, links --- library/cloud/docker | 324 +++++++++++++++++++++++++++++++++---------- 1 file changed, 252 insertions(+), 72 deletions(-) diff --git a/library/cloud/docker b/library/cloud/docker index 64a12909cd0..f2754a9291b 100644 --- a/library/cloud/docker +++ b/library/cloud/docker @@ -1,6 +1,7 @@ #!/usr/bin/python # (c) 2013, Cove Schneider +# (c) 2014, Joshua Conner # (c) 2014, Pavel Antonov # # This file is part of Ansible, @@ -54,16 +55,22 @@ options: aliases: [] ports: description: - - Set private to public port mapping specification (e.g. ports=22,80 or ports=:8080 maps 8080 directly to host) + - Set private to public port mapping specification using docker CLI-style syntax [([:[host_port]])|():][/udp] required: false default: null aliases: [] - links: + expose: description: - - Add links to another container (e.g. links=redis,postgresql:db) + - Set container ports to expose for port mappings or links. (If the port is already exposed using EXPOSE in a Dockerfile, you don't need to expose it again.) required: false default: null aliases: [] + publish_all_ports: + description: + - Publish all exposed ports to the host interfaces + required: false + default: false + aliases: [] volumes: description: - Set volume(s) to mount on the container @@ -76,6 +83,12 @@ options: required: false default: null aliases: [] + links: + description: + - Link container(s) to other container(s) (e.g. links=redis,postgresql:db) + required: false + default: null + aliases: [] memory_limit: description: - Set RAM allocated to container @@ -109,7 +122,7 @@ options: aliases: [] env: description: - - Set environment variables (e.g. env="PASSWORD=sEcRe7,WORKERS=4") + - Set environment variables (e.g. env="PASSWORD=sEcRe7,WORKERS=4" - a list or a comma-separated string is valid) required: false default: null aliases: [] @@ -144,8 +157,14 @@ options: required: false default: aliases: [] -author: Cove Schneider, Pavel Antonov -requirements: [ "docker-py" ] + name: + description: + - Set the name of the container (cannot use with count) + required: false + default: null + aliases: [] +author: Cove Schneider, Joshua Conner, Pavel Antonov +requirements: [ "docker-py >= 0.3.0" ] ''' EXAMPLES = ''' @@ -156,7 +175,7 @@ on the host: sudo: yes tasks: - name: run tomcat servers - docker: image=centos command="service tomcat6 start" ports=:8080 + docker: image=centos command="service tomcat6 start" ports=8080 The tomcat server's port is NAT'ed to a dynamic port on the host, but you can determine which port the server was mapped to using docker_containers: @@ -167,7 +186,7 @@ mapped to using docker_containers: - name: run tomcat servers docker: image=centos command="service tomcat6 start" ports=8080 count=5 - name: Display IP address and port mappings for containers - debug: msg={{inventory_hostname}}:{{item.NetworkSettings.Ports['8080/tcp'][0].HostPort}} + debug: msg={{inventory_hostname}}:{{item['HostConfig']['PortBindings']['8080/tcp'][0]['HostPort']}} with_items: docker_containers Just as in the previous example, but iterates over the list of docker containers with a sequence: @@ -180,7 +199,7 @@ Just as in the previous example, but iterates over the list of docker containers - name: run tomcat servers docker: image=centos command="service tomcat6 start" ports=8080 count={{start_containers_count}} - name: Display IP address and port mappings for containers - debug: msg={{inventory_hostname}}:{{docker_containers[{{item}}].NetworkSettings.Ports['8080/tcp'][0].HostPort}}" + debug: msg="{{inventory_hostname}}:{{docker_containers[{{item}}]['HostConfig']['PortBindings']['8080/tcp'][0]['HostPort']}}" with_sequence: start=0 end={{start_containers_count - 1}} Stop, remove all of the running tomcat containers and list the exit code from the stopped containers: @@ -193,16 +212,79 @@ Stop, remove all of the running tomcat containers and list the exit code from th - name: Display return codes from stopped containers debug: msg="Returned {{inventory_hostname}}:{{item}}" with_items: docker_containers + +Create a named container: + +- hosts: web + sudo: yes + tasks: + - name: run tomcat server + docker: image=centos name=tomcat command="service tomcat6 start" ports=8080 + +Create multiple named containers: + +- hosts: web + sudo: yes + tasks: + - name: run tomcat servers + docker: image=centos name={{item}} command="service tomcat6 start" ports=8080 + with_items: + - crookshank + - snowbell + - heathcliff + - felix + - sylvester + +Create containers named in a sequence: + +- hosts: web + sudo: yes + tasks: + - name: run tomcat servers + docker: image=centos name={{item}} command="service tomcat6 start" ports=8080 + with_sequence: start=1 end=5 format=tomcat_%d.example.com + +Create two linked containers: + +- hosts: web + sudo: yes + tasks: + - name: ensure redis container is running + docker: image=crosbymichael/redis name=redis + + - name: ensure redis_ambassador container is running + docker: image=svendowideit/ambassador ports=6379:6379 links=redis:redis name=redis_ambassador_ansible + +Create containers with options specified as key-value pairs and lists: + +- hosts: web + sudo: yes + tasks: + - docker: + image: namespace/image_name + links: + - postgresql:db + - redis:redis + + +Create containers with options specified as strings and lists as comma-separated strings: + +- hosts: web + sudo: yes + tasks: + docker: image=namespace/image_name links=postgresql:db,redis:redis ''' +HAS_DOCKER_PY = True + +import sys +from urlparse import urlparse try: - import sys import docker.client from requests.exceptions import * - from urlparse import urlparse except ImportError, e: - print "failed=True msg='failed to import python module: %s'" % e - sys.exit(1) + HAS_DOCKER_PY = False + def _human_to_bytes(number): suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] @@ -232,20 +314,18 @@ def _docker_id_quirk(inspect): return inspect class DockerManager: - + counters = {'created':0, 'started':0, 'stopped':0, 'killed':0, 'removed':0, 'restarted':0, 'pull':0} def __init__(self, module): self.module = module - + self.binds = None self.volumes = None if self.module.params.get('volumes'): self.binds = {} self.volumes = {} - vols = self.module.params.get('volumes') - if not isinstance(vols, list): - vols = vols.split(" ") + vols = self.parse_list_from_param('volumes') for vol in vols: parts = vol.split(":") # host mount (e.g. /mnt:/tmp, bind mounts host's /tmp to /mnt in the container) @@ -259,50 +339,106 @@ class DockerManager: self.lxc_conf = None if self.module.params.get('lxc_conf'): self.lxc_conf = [] - options = self.module.params.get('lxc_conf').split(" ") + options = self.parse_list_from_param('lxc_conf') for option in options: parts = option.split(':') self.lxc_conf.append({"Key": parts[0], "Value": parts[1]}) - self.ports = None - if self.module.params.get('ports'): - ports = self.module.params.get('ports') - self.ports = ports if isinstance(ports, list) else ports.split(",") + self.exposed_ports = None + if self.module.params.get('expose'): + expose = self.parse_list_from_param('expose') + self.exposed_ports = self.get_exposed_ports(expose) self.port_bindings = None - if self.module.params.get('port_bindings'): - self.port_bindings = {} - bindings = self.module.params.get('port_bindings') - bindings = bindings if isinstance(bindings, list) else bindings.split(",") - for binding in bindings: - parts = binding.split(":") - if len(parts) == 3: - self.port_bindings[parts[0]] = (parts[1], parts[2]) - elif len(parts) == 2: - self.port_bindings[parts[0]] = parts[1] - else: - self.port_bindings[parts[0]] = None + if self.module.params.get('ports'): + ports = self.parse_list_from_param('ports') + self.port_bindings = self.get_port_bindings(ports) self.links = None if self.module.params.get('links'): - self.links = {} - links = self.module.params.get('links') - if not isinstance(links, list): - links = links.split(",") - for link in links: - parts = link.split(":") - self.links[parts[0]] = parts[1] if len(parts) == 2 else parts[0] + links = self.parse_list_from_param('links') + self.links = dict(map(lambda x: x.split(':'), links)) self.env = None if self.module.params.get('env'): - env = self.module.params.get('env') - self.env = dict(map(lambda x: x.split("="), env if isinstance(env, list) else env.split(","))) + env = self.parse_list_from_param('env') + self.env = dict(map(lambda x: x.split("="), env)) # connect to docker server docker_url = urlparse(module.params.get('docker_url')) self.client = docker.Client(base_url=docker_url.geturl()) - - + + + def parse_list_from_param(self, param_name, delimiter=','): + """ + Get a list from a module parameter, whether it's specified as a delimiter-separated string or is already in list form. + """ + param_list = self.module.params.get(param_name) + if not isinstance(param_list, list): + param_list = param_list.split(delimiter) + return param_list + + + def get_exposed_ports(self, expose_list): + """ + Parse the ports and protocols (TCP/UDP) to expose in the docker-py `create_container` call from the docker CLI-style syntax. + """ + if expose_list: + exposed = [] + for port in expose_list: + if port.endswith('/tcp') or port.endswith('/udp'): + port_with_proto = tuple(port.split('/')) + else: + # assume tcp protocol if not specified + port_with_proto = (port, 'tcp') + exposed.append(port_with_proto) + return exposed + else: + return None + + + def get_port_bindings(self, ports): + """ + Parse the `ports` string into a port bindings dict for the `start_container` call. + """ + binds = {} + for port in ports: + parts = port.split(':') + container_port = parts[-1] + if '/' not in container_port: + container_port = int(parts[-1]) + + p_len = len(parts) + if p_len == 1: + # Bind `container_port` of the container to a dynamically + # allocated TCP port on all available interfaces of the host + # machine. + bind = ('0.0.0.0',) + elif p_len == 2: + # Bind `container_port` of the container to port `parts[0]` on + # all available interfaces of the host machine. + bind = ('0.0.0.0', int(parts[0])) + elif p_len == 3: + # Bind `container_port` of the container to port `parts[1]` on + # IP `parts[0]` of the host machine. If `parts[1]` empty bind + # to a dynamically allocacted port of IP `parts[0]`. + bind = (parts[0], int(parts[1])) if parts[1] else (parts[0],) + + if container_port in binds: + old_bind = binds[container_port] + if isinstance(old_bind, list): + # append to list if it already exists + old_bind.append(bind) + else: + # otherwise create list that contains the old and new binds + binds[container_port] = [binds[container_port], bind] + else: + binds[container_port] = bind + + return binds + + + def get_split_image_tag(self, image): if '/' in image: image = image.split('/')[1] @@ -310,15 +446,15 @@ class DockerManager: if image.find(':') > 0: return image.split(':') else: - return image, tag - + return image, tag + def get_summary_counters_msg(self): msg = "" for k, v in self.counters.iteritems(): msg = msg + "%s %d " % (k, v) return msg - + def increment_counter(self, name): self.counters[name] = self.counters[name] + 1 @@ -328,7 +464,7 @@ class DockerManager: return True return False - + def get_inspect_containers(self, containers): inspect = [] for i in containers: @@ -378,8 +514,7 @@ class DockerManager: def create_containers(self, count=1): params = {'image': self.module.params.get('image'), 'command': self.module.params.get('command'), - 'name': self.module.params.get('name'), - 'ports': self.ports, + 'ports': self.exposed_ports, 'volumes': self.volumes, 'volumes_from': self.module.params.get('volumes_from'), 'mem_limit': _human_to_bytes(self.module.params.get('memory_limit')), @@ -387,7 +522,7 @@ class DockerManager: 'dns': self.module.params.get('dns'), 'hostname': self.module.params.get('hostname'), 'detach': self.module.params.get('detach'), - 'privileged': self.module.params.get('privileged'), + 'name': self.module.params.get('name'), } def do_create(count, params): @@ -409,10 +544,17 @@ class DockerManager: return containers def start_containers(self, containers): + params = { + 'lxc_conf': self.lxc_conf, + 'binds': self.binds, + 'port_bindings': self.port_bindings, + 'publish_all_ports': self.module.params.get('publish_all_ports'), + 'privileged': self.module.params.get('privileged'), + 'links': self.links, + } for i in containers: - self.client.start(i['Id'], lxc_conf=self.lxc_conf, binds=self.binds, - port_bindings = self.port_bindings, links=self.links) - self.increment_counter('started') + self.client.start(i['Id'], **params) + self.increment_counter('started') def stop_containers(self, containers): for i in containers: @@ -425,7 +567,7 @@ class DockerManager: for i in containers: self.client.remove_container(i['Id']) self.increment_counter('removed') - + def kill_containers(self, containers): for i in containers: self.client.kill(i['Id']) @@ -436,18 +578,44 @@ class DockerManager: self.client.restart(i['Id']) self.increment_counter('restarted') + +def check_dependencies(module): + """ + Ensure `docker-py` >= 0.3.0 is installed, and call module.fail_json with a + helpful error message if it isn't. + """ + if not HAS_DOCKER_PY: + module.fail_json(msg="`docker-py` doesn't seem to be installed, but is required for the Ansible Docker module.") + else: + HAS_NEW_ENOUGH_DOCKER_PY = False + if hasattr(docker, '__version__'): + # a '__version__' attribute was added to the module but not until + # after 0.3.0 was added pushed to pip. If it's there, use it. + if docker.__version__ >= '0.3.0': + HAS_NEW_ENOUGH_DOCKER_PY = True + else: + # HACK: if '__version__' isn't there, we check for the existence of + # `_get_raw_response_socket` in the docker.Client class, which was + # added in 0.3.0 + if hasattr(docker.Client, '_get_raw_response_socket'): + HAS_NEW_ENOUGH_DOCKER_PY = True + + if not HAS_NEW_ENOUGH_DOCKER_PY: + module.fail_json(msg="The Ansible Docker module requires `docker-py` >= 0.3.0.") + + def main(): module = AnsibleModule( argument_spec = dict( count = dict(default=1), image = dict(required=True), command = dict(required=False, default=None), - name = dict(required=False, default=None), + expose = dict(required=False, default=None), ports = dict(required=False, default=None), - port_bindings = dict(required=False, default=None), - links = dict(required=False, default=None), + publish_all_ports = dict(default=False, type='bool'), volumes = dict(default=None), volumes_from = dict(default=None), + links = dict(default=None), memory_limit = dict(default=0), memory_swap = dict(default=0), docker_url = dict(default='unix://var/run/docker.sock'), @@ -461,18 +629,24 @@ def main(): state = dict(default='present', choices=['absent', 'present', 'stopped', 'killed', 'restarted']), debug = dict(default=False, type='bool'), privileged = dict(default=False, type='bool'), - lxc_conf = dict(default=None) + lxc_conf = dict(default=None), + name = dict(default=None) ) ) + check_dependencies(module) + try: manager = DockerManager(module) state = module.params.get('state') count = int(module.params.get('count')) + name = module.params.get('name') + + if count < 0: + module.fail_json(msg="Count must be greater than zero") + if count > 1 and name: + module.fail_json(msg="Count and name must not be used together") - if count < 1: - module.fail_json(msg="Count must be positive number") - running_containers = manager.get_running_containers() running_count = len(running_containers) delta = count - running_count @@ -483,11 +657,17 @@ def main(): # start/stop containers if state == "present": + + # make sure a container with `name` is running + if name and "/" + name not in map(lambda x: x.get('Name'), running_containers): + containers = manager.create_containers(1) + manager.start_containers(containers) + # start more containers if we don't have enough - if delta > 0: + elif delta > 0: containers = manager.create_containers(delta) manager.start_containers(containers) - + # stop containers if we have too many elif delta < 0: containers_to_stop = running_containers[0:abs(delta)] @@ -495,20 +675,20 @@ def main(): manager.remove_containers(containers_to_stop) facts = manager.get_running_containers() - + # stop and remove containers elif state == "absent": facts = manager.stop_containers(deployed_containers) manager.remove_containers(deployed_containers) - + # stop containers elif state == "stopped": facts = manager.stop_containers(running_containers) - + # kill containers elif state == "killed": manager.kill_containers(running_containers) - + # restart containers elif state == "restarted": manager.restart_containers(running_containers) @@ -517,7 +697,7 @@ def main(): msg = "%s container(s) running image %s with command %s" % \ (manager.get_summary_counters_msg(), module.params.get('image'), module.params.get('command')) changed = manager.has_changed() - + module.exit_json(failed=failed, changed=changed, msg=msg, ansible_facts=_ansible_facts(facts)) except docker.client.APIError, e: @@ -527,7 +707,7 @@ def main(): except RequestException, e: changed = manager.has_changed() module.exit_json(failed=True, changed=changed, msg=repr(e)) - + # import module snippets from ansible.module_utils.basic import *