ansible/cloud/docker
Johannes 'fish' Ziemke 459a76c0dd Rename present to running, add new present state
The new present state just makes sure that a container exists, not that
it's running, although it get started one creation.
This is very useful for data volumes. This also changes the old
present, now running (default) state to only create the container if
it's not found, otherwise it just get started.

See also discussion on mailinglist:
https://groups.google.com/forum/#!topic/ansible-devel/jB84gdhPzLQ

This closes #6395
2014-03-14 14:28:46 +01:00

730 lines
24 KiB
Python

#!/usr/bin/python
# (c) 2013, Cove Schneider
# (c) 2014, Joshua Conner <joshua.conner@gmail.com>
# (c) 2014, Pavel Antonov <antonov@adwz.ru>
#
# This file is part of Ansible,
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
######################################################################
DOCUMENTATION = '''
---
module: docker
version_added: "1.4"
short_description: manage docker containers
description:
- Manage the life cycle of docker containers.
options:
count:
description:
- Set number of containers to run
required: False
default: 1
aliases: []
image:
description:
- Set container image to use
required: true
default: null
aliases: []
command:
description:
- Set command to run in a container on startup
required: false
default: null
aliases: []
name:
description:
- Set name for container (used to find single container or to provide links)
required: false
default: null
aliases: []
version_added: "1.5"
ports:
description:
- Set private to public port mapping specification using docker CLI-style syntax [([<host_interface>:[host_port]])|(<host_port>):]<container_port>[/udp]
required: false
default: null
aliases: []
version_added: "1.5"
expose:
description:
- 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: []
version_added: "1.5"
publish_all_ports:
description:
- Publish all exposed ports to the host interfaces
required: false
default: false
aliases: []
version_added: "1.5"
volumes:
description:
- Set volume(s) to mount on the container
required: false
default: null
aliases: []
volumes_from:
description:
- Set shared volume(s) from another container
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: []
version_added: "1.5"
memory_limit:
description:
- Set RAM allocated to container
required: false
default: null
aliases: []
default: 256MB
docker_url:
description:
- URL of docker host to issue commands to
required: false
default: unix://var/run/docker.sock
aliases: []
username:
description:
- Set remote API username
required: false
default: null
aliases: []
password:
description:
- Set remote API password
required: false
default: null
aliases: []
hostname:
description:
- Set container hostname
required: false
default: null
aliases: []
env:
description:
- Set environment variables (e.g. env="PASSWORD=sEcRe7,WORKERS=4")
required: false
default: null
aliases: []
dns:
description:
- Set custom DNS servers for the container
required: false
default: null
aliases: []
detach:
description:
- Enable detached mode on start up, leaves container running in background
required: false
default: true
aliases: []
state:
description:
- Set the state of the container
required: false
default: present
choices: [ "present", "running", "stopped", "absent", "killed", "restarted" ]
aliases: []
privileged:
description:
- Set whether the container should run in privileged mode
required: false
default: false
aliases: []
lxc_conf:
description:
- LXC config parameters, e.g. lxc.aa_profile:unconfined
required: false
default:
aliases: []
name:
description:
- Set the name of the container (cannot use with count)
required: false
default: null
aliases: []
version_added: "1.5"
author: Cove Schneider, Joshua Conner, Pavel Antonov
requirements: [ "docker-py >= 0.3.0" ]
'''
EXAMPLES = '''
Start one docker container running tomcat in each host of the web group and bind tomcat's listening port to 8080
on the host:
- hosts: web
sudo: yes
tasks:
- name: run tomcat servers
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:
- hosts: web
sudo: yes
tasks:
- 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['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:
- hosts: web
sudo: yes
vars:
start_containers_count: 5
tasks:
- 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}}]['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:
- hosts: web
sudo: yes
tasks:
- name: stop tomcat servers
docker: image=centos command="service tomcat6 start" state=absent
- 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 docker.client
from requests.exceptions import *
except ImportError, e:
HAS_DOCKER_PY = False
def _human_to_bytes(number):
suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
if isinstance(number, int):
return number
if number[-1] == suffixes[0] and number[-2].isdigit():
return number[:-1]
i = 1
for each in suffixes[1:]:
if number[-len(each):] == suffixes[i]:
return int(number[:-len(each)]) * (1024 ** i)
i = i + 1
print "failed=True msg='Could not convert %s to integer'" % (number)
sys.exit(1)
def _ansible_facts(container_list):
return {"docker_containers": container_list}
def _docker_id_quirk(inspect):
# XXX: some quirk in docker
if 'ID' in inspect:
inspect['Id'] = inspect['ID']
del inspect['ID']
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.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)
if len(parts) == 2:
self.volumes[parts[1]] = {}
self.binds[parts[0]] = parts[1]
# docker mount (e.g. /www, mounts a docker volume /www on the container at the same location)
else:
self.volumes[parts[0]] = {}
self.lxc_conf = None
if self.module.params.get('lxc_conf'):
self.lxc_conf = []
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.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('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'):
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.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]
tag = None
if image.find(':') > 0:
return image.split(':')
else:
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
def has_changed(self):
for k, v in self.counters.iteritems():
if v > 0:
return True
return False
def get_inspect_containers(self, containers):
inspect = []
for i in containers:
details = self.client.inspect_container(i['Id'])
details = _docker_id_quirk(details)
inspect.append(details)
return inspect
def get_deployed_containers(self):
# determine which images/commands are running already
containers = self.client.containers(all=True)
image = self.module.params.get('image')
command = self.module.params.get('command')
if command:
command = command.strip()
name = self.module.params.get('name')
if name and not name.startswith('/'):
name = '/' + name
deployed = []
# if we weren't given a tag with the image, we need to only compare on the image name, as that
# docker will give us back the full image name including a tag in the container list if one exists.
image, tag = self.get_split_image_tag(image)
for i in containers:
running_image, running_tag = self.get_split_image_tag(i['Image'])
running_command = i['Command'].strip()
if (name and name in i['Names']) or \
(not name and running_image == image and (not tag or tag == running_tag) and
(not command or running_command == command)):
details = self.client.inspect_container(i['Id'])
details = _docker_id_quirk(details)
deployed.append(details)
return deployed
def get_running_containers(self):
running = []
for i in self.get_deployed_containers():
if i['State']['Running'] == True and i['State']['Ghost'] == False:
running.append(i)
return running
def create_containers(self, count=1):
params = {'image': self.module.params.get('image'),
'command': self.module.params.get('command'),
'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')),
'environment': self.env,
'dns': self.module.params.get('dns'),
'hostname': self.module.params.get('hostname'),
'detach': self.module.params.get('detach'),
'name': self.module.params.get('name'),
}
def do_create(count, params):
results = []
for _ in range(count):
result = self.client.create_container(**params)
self.increment_counter('created')
results.append(result)
return results
try:
containers = do_create(count, params)
except:
self.client.pull(params['image'])
self.increment_counter('pull')
containers = do_create(count, params)
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'], **params)
self.increment_counter('started')
def stop_containers(self, containers):
for i in containers:
self.client.stop(i['Id'])
self.increment_counter('stopped')
return [self.client.wait(i['Id']) for i in containers]
def remove_containers(self, containers):
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'])
self.increment_counter('killed')
def restart_containers(self, containers):
for i in containers:
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),
expose = dict(required=False, default=None),
ports = 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'),
user = dict(default=None),
password = dict(),
email = dict(),
hostname = dict(default=None),
env = dict(),
dns = dict(),
detach = dict(default=True, type='bool'),
state = dict(default='running', choices=['absent', 'present', 'running', 'stopped', 'killed', 'restarted']),
debug = dict(default=False, type='bool'),
privileged = dict(default=False, type='bool'),
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")
running_containers = manager.get_running_containers()
running_count = len(running_containers)
delta = count - running_count
deployed_containers = manager.get_deployed_containers()
facts = None
failed = False
changed = False
# start/stop containers
if state in [ "running", "present" ]:
# make sure a container with `name` exists, if not create and start it
if name and "/" + name not in map(lambda x: x.get('Name'), deployed_containers):
containers = manager.create_containers(1)
if state == "present": #otherwise it get (re)started later anyways..
manager.start_containers(containers)
running_containers = manager.get_running_containers()
deployed_containers = manager.get_deployed_containers()
if state == "running":
# make sure a container with `name` is running
if name and "/" + name not in map(lambda x: x.get('Name'), running_containers):
manager.start_containers(deployed_containers)
# start more containers if we don't have enough
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)]
containers = manager.stop_containers(containers_to_stop)
manager.remove_containers(containers_to_stop)
facts = manager.get_running_containers()
else:
acts = manager.get_deployed_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)
facts = manager.get_inspect_containers(running_containers)
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:
changed = manager.has_changed()
module.exit_json(failed=True, changed=changed, msg="Docker API error: " + e.explanation)
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 *
main()