Support file injection on container

Add support for file injection on creating the container.
API users need to pass the file contents by using the 'mounts'
attribute. The content will persist into a temp file in the
compute host and bind-mount into the container.

To achieve this, we introduce the volume driver called 'Local'.
This driver will implement the volume driver interface and
handle file injection related operations.

In data model, we adds a new field 'contents' into the volume_mapping
table. We also change 'volume_id' to be nullable because file
injection doesn't need a cinder volume.

A future work is to add the ability to limit the size and number
of injected files. The limits should be configurable either via
admin APIs or config options.

Implements: blueprint inject-files-to-container
Change-Id: I4ab6f50684f77bd7762e872d884ce11a7b0807ba
This commit is contained in:
Hongbin Lu 2018-07-08 21:35:08 +00:00
parent a13a693313
commit 21ae28cc1a
18 changed files with 284 additions and 46 deletions

View File

@ -741,10 +741,16 @@ mounts:
description: |
A list of dictionary data to specify how volumes are mounted into the
container. The container will mount the volumes at create time.
Each item can have an ``type`` attribute that specifies the volume
type. The supported volume types are ``volume`` or ``bind``. If this
attribute is not specified, the default is ``volume``.
To provision a container with pre-existing Cinder volumes bind-mounted,
specify the UUID or name of the volume in the ``source`` attribute.
Alternatively, Cinder volumes can be dynamically created. In this case,
the size of the volume needs to be specified in the ``size`` attribute.
Another option is to mount a user-provided file into the container.
In this case, the ``type`` attribute should be 'bind' and
the content of the file is contained in the ``source`` attribute.
The volumes will be mounted into the file system of the container and
the path to mount the volume needs to be specified in the ``destination``
attribute.

View File

@ -75,6 +75,7 @@ zun.network.driver =
zun.volume.driver =
cinder = zun.volume.driver:Cinder
local = zun.volume.driver:Local
[extras]
osprofiler =

View File

@ -506,26 +506,38 @@ class ContainersController(base.Controller):
return phynet_name
def _build_requested_volumes(self, context, mounts):
# NOTE(hongbin): We assume cinder is the only volume provider here.
# The logic needs to be re-visited if a second volume provider
# (i.e. Manila) is introduced.
cinder_api = cinder.CinderAPI(context)
requested_volumes = []
for mount in mounts:
if mount.get('source'):
volume = cinder_api.search_volume(mount['source'])
auto_remove = False
else:
volume = cinder_api.create_volume(mount['size'])
auto_remove = True
cinder_api.ensure_volume_usable(volume)
volmapp = objects.VolumeMapping(
context,
volume_id=volume.id, volume_provider='cinder',
container_path=mount['destination'],
user_id=context.user_id,
project_id=context.project_id,
auto_remove=auto_remove)
volume_dict = {
'volume_id': None,
'container_path': None,
'auto_remove': False,
'contents': None,
'user_id': context.user_id,
'project_id': context.project_id,
}
volume_type = mount.get('type', 'volume')
if volume_type == 'volume':
if mount.get('source'):
volume = cinder_api.search_volume(mount['source'])
cinder_api.ensure_volume_usable(volume)
volume_dict['volume_id'] = volume.id
volume_dict['container_path'] = mount['destination']
volume_dict['volume_provider'] = 'cinder'
elif mount.get('size'):
volume = cinder_api.create_volume(mount['size'])
cinder_api.ensure_volume_usable(volume)
volume_dict['volume_id'] = volume.id
volume_dict['container_path'] = mount['destination']
volume_dict['volume_provider'] = 'cinder'
volume_dict['auto_remove'] = True
elif volume_type == 'bind':
volume_dict['contents'] = mount.pop('source', '')
volume_dict['container_path'] = mount['destination']
volume_dict['volume_provider'] = 'local'
volmapp = objects.VolumeMapping(context, **volume_dict)
requested_volumes.append(volmapp)
return requested_volumes

View File

@ -201,6 +201,9 @@ mounts = {
'items': {
'type': 'object',
'properties': {
'type': {
'type': ['string'],
},
'source': {
'type': ['string'],
},

View File

@ -55,10 +55,11 @@ REST_API_VERSION_HISTORY = """REST API Version History:
* 1.20 - Convert type of 'command' from string to list
* 1.21 - Add support privileged
* 1.22 - Add healthcheck to container create
* 1.23 - Add attribute 'type' to parameter 'mounts'
"""
BASE_VER = '1.1'
CURRENT_MAX_VER = '1.22'
CURRENT_MAX_VER = '1.23'
class Version(object):

View File

@ -183,3 +183,8 @@ user documentation.
Add healthcheck to container create
1.23
----
Add support for file injection when creating a container.
The content of the file is sent to Zun server via parameter 'mounts'.

View File

@ -19,7 +19,19 @@ volume_group = cfg.OptGroup(name='volume',
volume_opts = [
cfg.StrOpt('driver',
default='cinder',
deprecated_for_removal=True,
help='Defines which driver to use for container volume.'),
cfg.ListOpt('driver_list',
default=['cinder', 'local'],
help="""Defines the list of volume driver to use.
Possible values:
* ``cinder``
* ``local``
Services which consume this:
* ``zun-compute``
Interdependencies to other options:
* None
"""),
cfg.StrOpt('volume_dir',
default='$state_path/mnt',
help='At which the docker volume will create.'),

View File

@ -112,11 +112,14 @@ class DockerDriver(driver.ContainerDriver):
super(DockerDriver, self).__init__()
self._host = host.Host()
self._get_host_storage_info()
self.volume_driver = vol_driver.driver()
self.image_drivers = {}
for driver_name in CONF.image_driver_list:
driver = img_driver.load_image_driver(driver_name)
self.image_drivers[driver_name] = driver
self.volume_drivers = {}
for driver_name in CONF.volume.driver_list:
driver = vol_driver.driver(driver_name)
self.volume_drivers[driver_name] = driver
def _get_host_storage_info(self):
storage_info = self._host.get_storage_info()
@ -380,8 +383,8 @@ class DockerDriver(driver.ContainerDriver):
def _get_binds(self, context, requested_volumes):
binds = {}
for volume in requested_volumes:
source, destination = self.volume_driver.bind_mount(context,
volume)
volume_driver = self._get_volume_driver(volume)
source, destination = volume_driver.bind_mount(context, volume)
binds[source] = {'bind': destination}
return binds
@ -990,17 +993,30 @@ class DockerDriver(driver.ContainerDriver):
docker.start(sandbox['Id'])
return sandbox['Id']
def _get_volume_driver(self, volume_mapping):
driver_name = volume_mapping.volume_provider
driver = self.volume_drivers.get(driver_name)
if not driver:
msg = _("The volume provider '%s' is not supported") % driver_name
raise exception.ZunException(msg)
return driver
def attach_volume(self, context, volume_mapping):
self.volume_driver.attach(context, volume_mapping)
volume_driver = self._get_volume_driver(volume_mapping)
volume_driver.attach(context, volume_mapping)
def detach_volume(self, context, volume_mapping):
self.volume_driver.detach(context, volume_mapping)
volume_driver = self._get_volume_driver(volume_mapping)
volume_driver.detach(context, volume_mapping)
def delete_volume(self, context, volume_mapping):
self.volume_driver.delete(context, volume_mapping)
volume_driver = self._get_volume_driver(volume_mapping)
volume_driver.delete(context, volume_mapping)
def get_volume_status(self, context, volume_mapping):
return self.volume_driver.get_volume_status(context, volume_mapping)
volume_driver = self._get_volume_driver(volume_mapping)
return volume_driver.get_volume_status(context, volume_mapping)
def check_multiattach(self, context, volume_mapping):
return self.volume_driver.check_multiattach(context, volume_mapping)

View File

@ -0,0 +1,40 @@
# 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.
"""add_contents_to_volume_mapping_table
Revision ID: a9c9fb54274a
Revises: bc56b9932dd9
Create Date: 2018-08-10 02:49:27.524151
"""
# revision identifiers, used by Alembic.
revision = 'a9c9fb54274a'
down_revision = 'bc56b9932dd9'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def MediumText():
return sa.Text().with_variant(sa.dialects.mysql.MEDIUMTEXT(), 'mysql')
def upgrade():
op.add_column('volume_mapping',
sa.Column('contents', MediumText(), nullable=True))
op.alter_column('volume_mapping', 'volume_id',
existing_type=sa.String(36),
nullable=True)

View File

@ -186,11 +186,12 @@ class VolumeMapping(Base):
id = Column(Integer, primary_key=True, nullable=False)
project_id = Column(String(255), nullable=True)
user_id = Column(String(255), nullable=True)
volume_id = Column(String(36), nullable=False)
volume_id = Column(String(36), nullable=True)
volume_provider = Column(String(36), nullable=False)
container_path = Column(String(255), nullable=True)
container_uuid = Column(String(36), ForeignKey('container.uuid'))
connection_info = Column(MediumText())
contents = Column(MediumText())
container = orm.relationship(
Container,
backref=orm.backref('volume'),

View File

@ -36,14 +36,15 @@ class VolumeMapping(base.ZunPersistentObject, base.ZunObject):
# Version 1.0: Initial version
# Version 1.1: Add field "auto_remove"
# Version 1.2: Add field "host"
VERSION = '1.2'
# Version 1.3: Add field "contents"
VERSION = '1.3'
fields = {
'id': fields.IntegerField(),
'uuid': fields.UUIDField(nullable=False),
'project_id': fields.StringField(nullable=True),
'user_id': fields.StringField(nullable=True),
'volume_id': fields.UUIDField(nullable=False),
'volume_id': fields.UUIDField(nullable=True),
'volume_provider': fields.StringField(nullable=False),
'container_path': fields.StringField(nullable=True),
'container_uuid': fields.UUIDField(nullable=True),
@ -51,6 +52,7 @@ class VolumeMapping(base.ZunPersistentObject, base.ZunObject):
'connection_info': fields.SensitiveStringField(nullable=True),
'auto_remove': fields.BooleanField(nullable=True),
'host': fields.StringField(nullable=True),
'contents': fields.SensitiveStringField(nullable=True),
}
@staticmethod

View File

@ -26,7 +26,7 @@ from zun.tests.unit.db import base
PATH_PREFIX = '/v1'
CURRENT_VERSION = "container 1.22"
CURRENT_VERSION = "container 1.23"
class FunctionalTest(base.DbTestCase):

View File

@ -28,7 +28,7 @@ class TestRootController(api_base.FunctionalTest):
'default_version':
{'id': 'v1',
'links': [{'href': 'http://localhost/v1/', 'rel': 'self'}],
'max_version': '1.22',
'max_version': '1.23',
'min_version': '1.1',
'status': 'CURRENT'},
'description': 'Zun is an OpenStack project which '
@ -37,7 +37,7 @@ class TestRootController(api_base.FunctionalTest):
'versions': [{'id': 'v1',
'links': [{'href': 'http://localhost/v1/',
'rel': 'self'}],
'max_version': '1.22',
'max_version': '1.23',
'min_version': '1.1',
'status': 'CURRENT'}]}

View File

@ -684,11 +684,60 @@ class TestContainerController(api_base.FunctionalTest):
self.assertEqual(1, len(requested_networks))
self.assertEqual(fake_network['id'], requested_networks[0]['network'])
mock_create_volume.assert_called_once()
mock_ensure_volume_usable.assert_called_once()
requested_volumes = \
mock_container_create.call_args[1]['requested_volumes']
self.assertEqual(1, len(requested_volumes))
self.assertEqual(fake_volume_id, requested_volumes[0].volume_id)
@patch('zun.network.neutron.NeutronAPI.get_available_network')
@patch('zun.compute.api.API.container_show')
@patch('zun.compute.api.API.container_create')
@patch('zun.common.context.RequestContext.can')
@patch('zun.volume.cinder_api.CinderAPI.create_volume')
@patch('zun.volume.cinder_api.CinderAPI.ensure_volume_usable')
@patch('zun.compute.api.API.image_search')
def test_create_container_with_injected_file(
self, mock_search, mock_ensure_volume_usable, mock_create_volume,
mock_authorize, mock_container_create, mock_container_show,
mock_neutron_get_network):
fake_network = {'id': 'foo'}
mock_neutron_get_network.return_value = fake_network
# Create a container with a command
params = ('{"name": "MyDocker", "image": "ubuntu",'
'"command": ["env"], "memory": "512",'
'"mounts": [{"destination": "d", "source": "hello",'
' "type": "bind"}]}')
response = self.post('/v1/containers/',
params=params,
content_type='application/json')
self.assertEqual(202, response.status_int)
# get all containers
container = objects.Container.list(self.context)[0]
container.status = 'Creating'
mock_container_show.return_value = container
response = self.app.get('/v1/containers/')
self.assertEqual(200, response.status_int)
self.assertEqual(2, len(response.json))
c = response.json['containers'][0]
self.assertIsNotNone(c.get('uuid'))
self.assertEqual('MyDocker', c.get('name'))
self.assertEqual(["env"], c.get('command'))
self.assertEqual('Creating', c.get('status'))
self.assertEqual('512', c.get('memory'))
self.assertIn('host', c)
requested_networks = \
mock_container_create.call_args[1]['requested_networks']
self.assertEqual(1, len(requested_networks))
self.assertEqual(fake_network['id'], requested_networks[0]['network'])
mock_create_volume.assert_not_called()
mock_ensure_volume_usable.assert_not_called()
requested_volumes = \
mock_container_create.call_args[1]['requested_volumes']
self.assertEqual(1, len(requested_volumes))
self.assertIsNone(requested_volumes[0].volume_id)
self.assertEqual('local', requested_volumes[0].volume_provider)
@patch('zun.network.neutron.NeutronAPI.get_available_network')
@patch('zun.compute.api.API.container_show')
@patch('zun.compute.api.API.container_create')

View File

@ -152,6 +152,7 @@ def get_test_volume_mapping(**kwargs):
'connection_info': kwargs.get('connection_info', 'fake_info'),
'auto_remove': kwargs.get('auto_remove', False),
'host': kwargs.get('host', 'fake_host'),
'contents': kwargs.get('contents', 'fake-contents'),
}

View File

@ -345,7 +345,7 @@ class TestObject(test_base.TestCase, _TestObject):
# https://docs.openstack.org/zun/latest/
object_data = {
'Container': '1.36-ad2bacdaa51afd0047e96003f93ff181',
'VolumeMapping': '1.2-2230102beda09cf5caabd130c600dc92',
'VolumeMapping': '1.3-14e3f9fc64e7afd751727c6ad3f32a94',
'Image': '1.1-330e6205c80b99b59717e1cfc6a79935',
'MyObj': '1.0-34c4b1aadefd177b13f9a2f894cc23cd',
'NUMANode': '1.0-cba878b70b2f8b52f1e031b41ac13b4e',

View File

@ -13,6 +13,7 @@
import mock
from oslo_serialization import jsonutils
from oslo_utils import uuidutils
from zun.common import exception
import zun.conf
@ -23,10 +24,11 @@ from zun.volume import driver
CONF = zun.conf.CONF
class VolumeDriverTestCase(base.TestCase):
class CinderVolumeDriverTestCase(base.TestCase):
def setUp(self):
super(VolumeDriverTestCase, self).setUp()
super(CinderVolumeDriverTestCase, self).setUp()
self.fake_uuid = uuidutils.generate_uuid()
self.fake_volume_id = 'fake-volume-id'
self.fake_devpath = '/fake-path'
self.fake_mountpoint = '/fake-mountpoint'
@ -35,6 +37,7 @@ class VolumeDriverTestCase(base.TestCase):
'data': {'device_path': self.fake_devpath},
}
self.volume = mock.MagicMock()
self.volume.uuid = self.fake_uuid
self.volume.volume_provider = 'cinder'
self.volume.volume_id = self.fake_volume_id
self.volume.container_path = self.fake_container_path
@ -55,7 +58,7 @@ class VolumeDriverTestCase(base.TestCase):
volume_driver.attach(self.context, self.volume)
mock_cinder_workflow.attach_volume.assert_called_once_with(self.volume)
mock_get_mountpoint.assert_called_once_with(self.fake_volume_id)
mock_get_mountpoint.assert_called_once_with(self.fake_uuid)
mock_do_mount.assert_called_once_with(
self.fake_devpath, self.fake_mountpoint, CONF.volume.fstype)
mock_cinder_workflow.detach_volume.assert_not_called()
@ -112,7 +115,7 @@ class VolumeDriverTestCase(base.TestCase):
volume_driver.attach, self.context, self.volume)
mock_cinder_workflow.attach_volume.assert_called_once_with(self.volume)
mock_get_mountpoint.assert_called_once_with(self.fake_volume_id)
mock_get_mountpoint.assert_called_once_with(self.fake_uuid)
mock_do_mount.assert_called_once_with(
self.fake_devpath, self.fake_mountpoint, CONF.volume.fstype)
mock_cinder_workflow.detach_volume.assert_called_once_with(self.volume)
@ -143,7 +146,7 @@ class VolumeDriverTestCase(base.TestCase):
volume_driver.attach, self.context, self.volume)
mock_cinder_workflow.attach_volume.assert_called_once_with(self.volume)
mock_get_mountpoint.assert_called_once_with(self.fake_volume_id)
mock_get_mountpoint.assert_called_once_with(self.fake_uuid)
mock_do_mount.assert_called_once_with(
self.fake_devpath, self.fake_mountpoint, CONF.volume.fstype)
mock_cinder_workflow.detach_volume.assert_called_once_with(self.volume)
@ -162,7 +165,7 @@ class VolumeDriverTestCase(base.TestCase):
volume_driver.detach(self.context, self.volume)
mock_cinder_workflow.detach_volume.assert_called_once_with(self.volume)
mock_get_mountpoint.assert_called_once_with(self.fake_volume_id)
mock_get_mountpoint.assert_called_once_with(self.fake_uuid)
mock_do_unmount.assert_called_once_with(self.fake_mountpoint)
mock_rmtree.assert_called_once_with(self.fake_mountpoint)
@ -179,7 +182,7 @@ class VolumeDriverTestCase(base.TestCase):
self.assertEqual(self.fake_mountpoint, source)
self.assertEqual(self.fake_container_path, destination)
mock_get_mountpoint.assert_called_once_with(self.fake_volume_id)
mock_get_mountpoint.assert_called_once_with(self.fake_uuid)
@mock.patch('shutil.rmtree')
@mock.patch('zun.common.mount.get_mountpoint')
@ -197,3 +200,55 @@ class VolumeDriverTestCase(base.TestCase):
mock_cinder_workflow.delete_volume.assert_called_once_with(self.volume)
mock_rmtree.assert_called_once_with(self.fake_mountpoint)
class LocalVolumeDriverTestCase(base.TestCase):
def setUp(self):
super(LocalVolumeDriverTestCase, self).setUp()
self.fake_uuid = uuidutils.generate_uuid()
self.fake_mountpoint = '/fake-mountpoint'
self.fake_container_path = '/fake-container-path'
self.fake_contents = 'fake-contents'
self.volume = mock.MagicMock()
self.volume.uuid = self.fake_uuid
self.volume.volume_provider = 'local'
self.volume.container_path = self.fake_container_path
self.volume.contents = self.fake_contents
@mock.patch('oslo_utils.fileutils.ensure_tree')
@mock.patch('zun.common.mount.get_mountpoint')
def test_attach(self, mock_get_mountpoint, mock_ensure_tree):
mock_get_mountpoint.return_value = self.fake_mountpoint
volume_driver = driver.Local()
with mock.patch('zun.volume.driver.open', mock.mock_open()
) as mock_open:
volume_driver.attach(self.context, self.volume)
expected_file_path = self.fake_mountpoint + '/' + self.fake_uuid
mock_open.assert_called_once_with(expected_file_path, 'wb')
mock_open().write.assert_called_once_with(self.fake_contents)
mock_get_mountpoint.assert_called_once_with(self.fake_uuid)
@mock.patch('shutil.rmtree')
@mock.patch('zun.common.mount.get_mountpoint')
def test_detach(self, mock_get_mountpoint, mock_rmtree):
mock_get_mountpoint.return_value = self.fake_mountpoint
volume_driver = driver.Local()
volume_driver.detach(self.context, self.volume)
mock_get_mountpoint.assert_called_once_with(self.fake_uuid)
mock_rmtree.assert_called_once_with(self.fake_mountpoint)
@mock.patch('zun.common.mount.get_mountpoint')
def test_bind_mount(self, mock_get_mountpoint):
mock_get_mountpoint.return_value = self.fake_mountpoint
volume_driver = driver.Local()
source, destination = volume_driver.bind_mount(
self.context, self.volume)
expected_file_path = self.fake_mountpoint + '/' + self.fake_uuid
self.assertEqual(expected_file_path, source)
self.assertEqual(self.fake_container_path, destination)
mock_get_mountpoint.assert_called_once_with(self.fake_uuid)

View File

@ -33,12 +33,11 @@ LOG = logging.getLogger(__name__)
CONF = zun.conf.CONF
def driver(*args, **kwargs):
name = CONF.volume.driver
LOG.info("Loading volume driver '%s'", name)
def driver(driver_name, *args, **kwargs):
LOG.info("Loading volume driver '%s'", driver_name)
volume_driver = stevedore_driver.DriverManager(
"zun.volume.driver",
name,
driver_name,
invoke_on_load=True,
invoke_args=args,
invoke_kwds=kwargs).driver
@ -84,6 +83,41 @@ class VolumeDriver(object):
raise NotImplementedError()
class Local(VolumeDriver):
supported_providers = ['local']
@validate_volume_provider(supported_providers)
def attach(self, context, volume):
mountpoint = mount.get_mountpoint(volume.uuid)
fileutils.ensure_tree(mountpoint)
filename = '/'.join([mountpoint, volume.uuid])
with open(filename, 'wb') as fd:
fd.write(volume.contents)
def _remove_local_file(self, volume):
mountpoint = mount.get_mountpoint(volume.uuid)
shutil.rmtree(mountpoint)
@validate_volume_provider(supported_providers)
def detach(self, context, volume):
self._remove_local_file(volume)
@validate_volume_provider(supported_providers)
def delete(self, context, volume):
self._remove_local_file(volume)
@validate_volume_provider(supported_providers)
def bind_mount(self, context, volume):
mountpoint = mount.get_mountpoint(volume.uuid)
filename = '/'.join([mountpoint, volume.uuid])
return filename, volume.container_path
@validate_volume_provider(supported_providers)
def get_volume_status(self, context, volume):
return 'available'
class Cinder(VolumeDriver):
supported_providers = [
@ -105,7 +139,7 @@ class Cinder(VolumeDriver):
LOG.exception("Failed to detach volume")
def _mount_device(self, volume, devpath):
mountpoint = mount.get_mountpoint(volume.volume_id)
mountpoint = mount.get_mountpoint(volume.uuid)
fileutils.ensure_tree(mountpoint)
mount.do_mount(devpath, mountpoint, CONF.volume.fstype)
@ -123,13 +157,13 @@ class Cinder(VolumeDriver):
def _unmount_device(self, volume):
if hasattr(volume, 'connection_info'):
mountpoint = mount.get_mountpoint(volume.volume_id)
mountpoint = mount.get_mountpoint(volume.uuid)
mount.do_unmount(mountpoint)
shutil.rmtree(mountpoint)
@validate_volume_provider(supported_providers)
def bind_mount(self, context, volume):
mountpoint = mount.get_mountpoint(volume.volume_id)
mountpoint = mount.get_mountpoint(volume.uuid)
return mountpoint, volume.container_path
@validate_volume_provider(supported_providers)