contrib: Add Docker Container plugin
This plugin enable using Docker containers as resources in a Heat template. Change-Id: Id9fd6c3491b246c88308713a7661be1ad0056eb5 Implements: blueprint docker-plugin
This commit is contained in:
parent
12fa11591a
commit
797ae98024
|
@ -0,0 +1,29 @@
|
|||
Docker plugin for OpenStack Heat
|
||||
================================
|
||||
|
||||
This plugin enable using Docker containers as resources in a Heat template.
|
||||
|
||||
|
||||
### 1. Install the Docker plugin in Heat
|
||||
|
||||
NOTE: Heat scans several directories to find plugins. The list of directories
|
||||
is specified in the configuration file "heat.conf" with the "plugin_dirs"
|
||||
directive.
|
||||
|
||||
Running the following commands will install the Docker plugin in an existing
|
||||
Heat setup.
|
||||
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
ln -sf $(cd heat/contrib/docker-plugin/plugin; pwd) /usr/lib/heat/docker
|
||||
echo "plugin_dirs=$(cd heat/contrib/docker-plugin/plugin; pwd)" > /etc/heat/heat.conf
|
||||
```
|
||||
|
||||
NOTE: If you already have plugins enabled, you should not run the last command
|
||||
and instead edit the config file "/etc/heat/heat.conf" manually.
|
||||
|
||||
|
||||
### 2. Restart heat
|
||||
|
||||
Only the process "heat-engine" needs to be restarted to load the new installed
|
||||
plugin.
|
|
@ -0,0 +1,266 @@
|
|||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
#
|
||||
# Copyright (c) 2013 Docker, Inc.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from heat.engine import properties
|
||||
from heat.engine import resource
|
||||
from heat.openstack.common.gettextutils import _
|
||||
|
||||
import docker
|
||||
|
||||
|
||||
class DockerContainer(resource.Resource):
|
||||
|
||||
properties_schema = {
|
||||
'docker_endpoint': properties.Schema(
|
||||
properties.Schema.STRING,
|
||||
_('Docker daemon endpoint (by default the local docker daemon '
|
||||
'will be used)'),
|
||||
default=None
|
||||
),
|
||||
'hostname': properties.Schema(
|
||||
properties.Schema.STRING,
|
||||
_('Hostname of the container'),
|
||||
default=''
|
||||
),
|
||||
'user': properties.Schema(
|
||||
properties.Schema.STRING,
|
||||
_('Username or UID'),
|
||||
default=''
|
||||
),
|
||||
'memory': properties.Schema(
|
||||
properties.Schema.INTEGER,
|
||||
_('Memory limit (Bytes)'),
|
||||
default=0
|
||||
),
|
||||
'attach_stdin': properties.Schema(
|
||||
properties.Schema.BOOLEAN,
|
||||
_('Attach to the the process\' standard input'),
|
||||
default=False
|
||||
),
|
||||
'attach_stdout': properties.Schema(
|
||||
properties.Schema.BOOLEAN,
|
||||
_('Attach to the process\' standard output'),
|
||||
default=True
|
||||
),
|
||||
'attach_stderr': properties.Schema(
|
||||
properties.Schema.BOOLEAN,
|
||||
_('Attach to the process\' standard error'),
|
||||
default=True
|
||||
),
|
||||
'port_specs': properties.Schema(
|
||||
properties.Schema.LIST,
|
||||
_('TCP/UDP ports mapping'),
|
||||
default=None
|
||||
),
|
||||
'privileged': properties.Schema(
|
||||
properties.Schema.BOOLEAN,
|
||||
_('Enable extended privileges'),
|
||||
default=False
|
||||
),
|
||||
'tty': properties.Schema(
|
||||
properties.Schema.BOOLEAN,
|
||||
_('Allocate a pseudo-tty'),
|
||||
default=False
|
||||
),
|
||||
'open_stdin': properties.Schema(
|
||||
properties.Schema.BOOLEAN,
|
||||
_('Open stdin'),
|
||||
default=False
|
||||
),
|
||||
'stdin_once': properties.Schema(
|
||||
properties.Schema.BOOLEAN,
|
||||
_('If true, close stdin after the 1 attached client disconnects'),
|
||||
default=False
|
||||
),
|
||||
'env': properties.Schema(
|
||||
properties.Schema.LIST,
|
||||
_('Set environment variables'),
|
||||
default=None
|
||||
),
|
||||
'cmd': properties.Schema(
|
||||
properties.Schema.LIST,
|
||||
_('Command to run after spawning the container'),
|
||||
default=[]
|
||||
),
|
||||
'dns': properties.Schema(
|
||||
properties.Schema.LIST,
|
||||
_('Set custom dns servers'),
|
||||
default=None
|
||||
),
|
||||
'image': properties.Schema(
|
||||
properties.Schema.STRING,
|
||||
_('Image name')
|
||||
),
|
||||
'volumes': properties.Schema(
|
||||
properties.Schema.MAP,
|
||||
_('Create a bind mount'),
|
||||
default={}
|
||||
),
|
||||
'volumes_from': properties.Schema(
|
||||
properties.Schema.STRING,
|
||||
_('Mount all specified volumes'),
|
||||
default=''
|
||||
),
|
||||
}
|
||||
|
||||
attributes_schema = {
|
||||
'info': _('Container info'),
|
||||
'network_info': _('Container network info'),
|
||||
'network_ip': _('Container ip address'),
|
||||
'network_tcp_ports': _('Container TCP ports'),
|
||||
'network_udp_ports': _('Container UDP ports'),
|
||||
'logs': _('Container logs'),
|
||||
'logs_head': _('Container first logs line'),
|
||||
'logs_tail': _('Container last logs line')
|
||||
}
|
||||
|
||||
docker_client = docker.Client()
|
||||
|
||||
def get_client(self):
|
||||
client = self.docker_client
|
||||
endpoint = self.properties.get('docker_endpoint')
|
||||
if endpoint:
|
||||
client = docker.Client(endpoint)
|
||||
return client
|
||||
|
||||
def _parse_networkinfo_ports(self, networkinfo):
|
||||
tcp = []
|
||||
udp = []
|
||||
for port, info in networkinfo['Ports'].iteritems():
|
||||
p = port.split('/')
|
||||
if not info or len(p) != 2 or 'HostPort' not in info[0]:
|
||||
continue
|
||||
port = info[0]['HostPort']
|
||||
if p[1] == 'tcp':
|
||||
tcp.append(port)
|
||||
elif p[1] == 'udp':
|
||||
udp.append(port)
|
||||
return (','.join(tcp), ','.join(udp))
|
||||
|
||||
def _container_networkinfo(self, client, resource_id):
|
||||
info = client.inspect_container(self.resource_id)
|
||||
networkinfo = info['NetworkSettings']
|
||||
ports = self._parse_networkinfo_ports(networkinfo)
|
||||
networkinfo['TcpPorts'] = ports[0]
|
||||
networkinfo['UdpPorts'] = ports[1]
|
||||
return networkinfo
|
||||
|
||||
def _resolve_attribute(self, name):
|
||||
if not self.resource_id:
|
||||
return
|
||||
if name == 'info':
|
||||
client = self.get_client()
|
||||
return client.inspect_container(self.resource_id)
|
||||
if name == 'network_info':
|
||||
client = self.get_client()
|
||||
networkinfo = self._container_networkinfo(client, self.resource_id)
|
||||
return networkinfo
|
||||
if name == 'network_ip':
|
||||
client = self.get_client()
|
||||
networkinfo = self._container_networkinfo(client, self.resource_id)
|
||||
return networkinfo['Gateway']
|
||||
if name == 'network_tcp_ports':
|
||||
client = self.get_client()
|
||||
networkinfo = self._container_networkinfo(client, self.resource_id)
|
||||
return networkinfo['TcpPorts']
|
||||
if name == 'network_udp_ports':
|
||||
client = self.get_client()
|
||||
networkinfo = self._container_networkinfo(client, self.resource_id)
|
||||
return networkinfo['UdpPorts']
|
||||
if name == 'logs':
|
||||
client = self.get_client()
|
||||
logs = client.logs(self.resource_id)
|
||||
return logs
|
||||
if name == 'logs_head':
|
||||
client = self.get_client()
|
||||
logs = client.logs(self.resource_id)
|
||||
return logs.split('\n')[0]
|
||||
if name == 'logs_tail':
|
||||
client = self.get_client()
|
||||
logs = client.logs(self.resource_id)
|
||||
return logs.split('\n').pop()
|
||||
|
||||
def handle_create(self):
|
||||
args = {
|
||||
'image': self.properties['image'],
|
||||
'command': self.properties['cmd'],
|
||||
'hostname': self.properties['hostname'],
|
||||
'user': self.properties['user'],
|
||||
'stdin_open': self.properties['open_stdin'],
|
||||
'tty': self.properties['tty'],
|
||||
'mem_limit': self.properties['memory'],
|
||||
'ports': self.properties['port_specs'],
|
||||
'environment': self.properties['env'],
|
||||
'dns': self.properties['dns'],
|
||||
'volumes': self.properties['volumes'],
|
||||
'volumes_from': self.properties['volumes_from'],
|
||||
'privileged': self.properties['privileged'],
|
||||
}
|
||||
client = self.get_client()
|
||||
result = client.create_container(**args)
|
||||
container_id = result['Id']
|
||||
self.resource_id_set(container_id)
|
||||
client.start(container_id)
|
||||
return container_id
|
||||
|
||||
def _get_container_status(self, container_id):
|
||||
client = self.get_client()
|
||||
info = client.inspect_container(container_id)
|
||||
return info['State']
|
||||
|
||||
def check_create_complete(self, container_id):
|
||||
status = self._get_container_status(container_id)
|
||||
return status['Running']
|
||||
|
||||
def handle_delete(self):
|
||||
if self.resource_id is None:
|
||||
return
|
||||
client = self.get_client()
|
||||
client.kill(self.resource_id)
|
||||
return self.resource_id
|
||||
|
||||
def check_delete_complete(self, container_id):
|
||||
status = self._get_container_status(container_id)
|
||||
return (not status['Running'])
|
||||
|
||||
def handle_suspend(self):
|
||||
if not self.resource_id:
|
||||
return
|
||||
client = self.get_client()
|
||||
client.stop(self.resource_id)
|
||||
return self.resource_id
|
||||
|
||||
def check_suspend_complete(self, container_id):
|
||||
status = self._get_container_status(container_id)
|
||||
return (not status['Running'])
|
||||
|
||||
def handle_resume(self):
|
||||
if not self.resource_id:
|
||||
return
|
||||
client = self.get_client()
|
||||
client.start(self.resource_id)
|
||||
return self.resource_id
|
||||
|
||||
def check_resume_complete(self, container_id):
|
||||
status = self._get_container_status(container_id)
|
||||
return status['Running']
|
||||
|
||||
|
||||
def resource_mapping():
|
||||
return {
|
||||
'OS::Docker::Container': DockerContainer
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
docker-py>=0.2.2
|
|
@ -0,0 +1,83 @@
|
|||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
#
|
||||
# Copyright (c) 2013 Docker, Inc.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import random
|
||||
import string
|
||||
|
||||
|
||||
class APIError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class FakeDockerClient(object):
|
||||
|
||||
def __init__(self, endpoint=None):
|
||||
self._endpoint = endpoint
|
||||
self._containers = {}
|
||||
|
||||
def _generate_string(self, n=32):
|
||||
return ''.join(random.choice(string.lowercase) for i in range(n))
|
||||
|
||||
def _check_exists(self, container_id):
|
||||
if container_id not in self._containers:
|
||||
raise APIError('404 Client Error: Not Found ("No such container: '
|
||||
'{0}")'.format(container_id))
|
||||
|
||||
def _set_running(self, container_id, running):
|
||||
self._check_exists(container_id)
|
||||
self._containers[container_id] = running
|
||||
|
||||
def inspect_container(self, container_id):
|
||||
self._check_exists(container_id)
|
||||
info = {
|
||||
'Id': container_id,
|
||||
'NetworkSettings': {
|
||||
'Bridge': 'docker0',
|
||||
'Gateway': '172.17.42.1',
|
||||
'IPAddress': '172.17.0.3',
|
||||
'IPPrefixLen': 16,
|
||||
'Ports': {
|
||||
'80/tcp': [{'HostIp': '0.0.0.0', 'HostPort': '1080'}]
|
||||
}
|
||||
},
|
||||
'State': {
|
||||
'Running': self._containers[container_id]
|
||||
}
|
||||
}
|
||||
return info
|
||||
|
||||
def logs(self, container_id):
|
||||
logs = ['---logs_begin---']
|
||||
for i in range(random.randint(1, 20)):
|
||||
logs.append(self._generate_string(random.randint(5, 42)))
|
||||
logs.append('---logs_end---')
|
||||
return '\n'.join(logs)
|
||||
|
||||
def create_container(self, *args, **kwargs):
|
||||
container_id = self._generate_string()
|
||||
self._containers[container_id] = None
|
||||
self._set_running(container_id, False)
|
||||
return self.inspect_container(container_id)
|
||||
|
||||
def start(self, container_id):
|
||||
self._set_running(container_id, True)
|
||||
|
||||
def stop(self, container_id):
|
||||
self._set_running(container_id, False)
|
||||
|
||||
def kill(self, container_id):
|
||||
self._set_running(container_id, False)
|
|
@ -0,0 +1,119 @@
|
|||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
#
|
||||
# Copyright (c) 2013 Docker, Inc.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from heat.common import exception
|
||||
from heat.common import template_format
|
||||
from heat.engine import resource
|
||||
from heat.engine import scheduler
|
||||
from heat.tests.common import HeatTestCase
|
||||
from heat.tests import utils
|
||||
|
||||
from .fake_docker_client import FakeDockerClient # noqa
|
||||
from ..plugin import docker_container # noqa
|
||||
|
||||
|
||||
template = '''
|
||||
{
|
||||
"AWSTemplateFormatVersion" : "2010-09-09",
|
||||
"Description": "Test template",
|
||||
"Parameters": {},
|
||||
"Resources": {
|
||||
"Blog": {
|
||||
"Type": "OS::Docker::Container",
|
||||
"Properties": {
|
||||
"image": "samalba/wordpress",
|
||||
"env": [
|
||||
"FOO=bar"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class DockerContainerTest(HeatTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(DockerContainerTest, self).setUp()
|
||||
utils.setup_dummy_db()
|
||||
for res_name, res_class in docker_container.resource_mapping().items():
|
||||
resource._register_class(res_name, res_class)
|
||||
|
||||
def create_container(self, resource_name):
|
||||
t = template_format.parse(template)
|
||||
stack = utils.parse_stack(t)
|
||||
resource = docker_container.DockerContainer(
|
||||
resource_name, t['Resources'][resource_name], stack)
|
||||
self.m.StubOutWithMock(resource, 'get_client')
|
||||
resource.get_client().MultipleTimes().AndReturn(FakeDockerClient())
|
||||
self.assertEqual(None, resource.validate())
|
||||
self.m.ReplayAll()
|
||||
scheduler.TaskRunner(resource.create)()
|
||||
self.assertEqual(resource.state, (resource.CREATE,
|
||||
resource.COMPLETE))
|
||||
return resource
|
||||
|
||||
def get_container_state(self, resource):
|
||||
client = resource.get_client()
|
||||
return client.inspect_container(resource.resource_id)['State']
|
||||
|
||||
def test_resource_create(self):
|
||||
container = self.create_container('Blog')
|
||||
self.assertTrue(container.resource_id)
|
||||
running = self.get_container_state(container)['Running']
|
||||
self.assertEqual(True, running)
|
||||
self.m.VerifyAll()
|
||||
|
||||
def test_resource_attributes(self):
|
||||
container = self.create_container('Blog')
|
||||
# Test network info attributes
|
||||
self.assertEqual('172.17.42.1', container.FnGetAtt('network_ip'))
|
||||
self.assertEqual('1080', container.FnGetAtt('network_tcp_ports'))
|
||||
self.assertEqual('', container.FnGetAtt('network_udp_ports'))
|
||||
# Test logs attributes
|
||||
self.assertEqual('---logs_begin---', container.FnGetAtt('logs_head'))
|
||||
self.assertEqual('---logs_end---', container.FnGetAtt('logs_tail'))
|
||||
# Test a non existing attribute
|
||||
self.assertRaises(exception.InvalidTemplateAttribute,
|
||||
container.FnGetAtt, 'invalid_attribute')
|
||||
self.m.VerifyAll()
|
||||
|
||||
def test_resource_delete(self):
|
||||
container = self.create_container('Blog')
|
||||
scheduler.TaskRunner(container.delete)()
|
||||
self.assertEqual(container.state, (container.DELETE,
|
||||
container.COMPLETE))
|
||||
running = self.get_container_state(container)['Running']
|
||||
self.assertEqual(False, running)
|
||||
self.m.VerifyAll()
|
||||
|
||||
def test_resource_suspend_resume(self):
|
||||
container = self.create_container('Blog')
|
||||
# Test suspend
|
||||
scheduler.TaskRunner(container.suspend)()
|
||||
self.assertEqual(container.state, (container.SUSPEND,
|
||||
container.COMPLETE))
|
||||
running = self.get_container_state(container)['Running']
|
||||
self.assertEqual(False, running)
|
||||
# Test resume
|
||||
scheduler.TaskRunner(container.resume)()
|
||||
self.assertEqual(container.state, (container.RESUME,
|
||||
container.COMPLETE))
|
||||
running = self.get_container_state(container)['Running']
|
||||
self.assertEqual(True, running)
|
||||
self.m.VerifyAll()
|
Loading…
Reference in New Issue