Support opening container's port

Introduce an option to expose container's port(s). In particular,
it introduces a parameter 'exposed_ports' on creating container.
The value is in the form: ``{"<port>/<protocol>": {}}``. For
example, it can be ``{"80": {}}`` or ``{"80/tcp": {}}``.
The format is designed to align with docker's option 'ExposedPorts'.

If this parameter is provided, Zun will create a security group
for the container. The security group will be populated a set of
rules to open those exposed ports.

This feature is a managed security group feature (Zun manages the
security group of the container). Obviously, it cannot be used
with the 'security_groups' parameter, in which users are responsible
to manage the security group.

Change-Id: Id713ce602dca8e74089d4a5eea8df41ea8784db4
Partial-Implements: blueprint support-port-bindings
This commit is contained in:
Hongbin Lu 2018-05-13 21:52:48 +00:00
parent 1efb2b9645
commit 44dc51f8df
20 changed files with 240 additions and 26 deletions

View File

@ -60,6 +60,7 @@ Request
- mounts: mounts
- privileged: privileged-request
- healthcheck: healthcheck-request
- exposed_ports: exposed_ports
Request Example
----------------

View File

@ -482,6 +482,18 @@ exec_url:
The URL to start an exec instance.
in: body
type: dict
exposed_ports:
description: |
A list of dictionary data to specify how to expose container's ports.
If this parameter is specified, Zun will create a security group with
a set of rules to open the ports that should be exposed, and associate
the security group to the container. The value is in the form of
``{"<port>/<protocol>: {}"}``, where the ``port`` is the container's
port and ``protocol`` is either ``tcp`` or ``udp``. If ``protocol``
is not provided, ``tcp`` will be used.
in: body
required: false
type: object
fixed_ips:
description: |
A list of fixed IP addresses with subnet IDs and other detailed

View File

@ -398,6 +398,12 @@ class ContainersController(base.Controller):
if container_dict.get('restart_policy'):
utils.check_for_restart_policy(container_dict)
exposed_ports = container_dict.pop('exposed_ports', None)
if exposed_ports is not None:
api_utils.version_check('exposed_ports', '1.24')
exposed_ports = utils.build_exposed_ports(exposed_ports)
container_dict['exposed_ports'] = exposed_ports
container_dict['status'] = consts.CREATING
extra_spec = {}
extra_spec['hints'] = container_dict.get('hints', None)

View File

@ -39,6 +39,7 @@ _legacy_container_properties = {
'auto_heal': parameter_types.boolean,
'privileged': parameter_types.boolean,
'healthcheck': parameter_types.healthcheck,
'exposed_ports': parameter_types.exposed_ports,
}
legacy_container_create = {
@ -54,7 +55,14 @@ _container_properties['command'] = parameter_types.command_list
container_create = {
'type': 'object',
'properties': _container_properties,
'allOf': [
{
'required': ['image'],
},
{
'not': {'required': ['security_groups', 'exposed_ports']}
}
],
'additionalProperties': False
}

View File

@ -196,6 +196,10 @@ healthcheck = {
}
}
exposed_ports = {
'type': ['object', 'null']
}
mounts = {
'type': ['array', 'null'],
'items': {

View File

@ -56,10 +56,11 @@ REST_API_VERSION_HISTORY = """REST API Version History:
* 1.21 - Add support privileged
* 1.22 - Add healthcheck to container create
* 1.23 - Add attribute 'type' to parameter 'mounts'
* 1.24 - Add exposed_ports to container
"""
BASE_VER = '1.1'
CURRENT_MAX_VER = '1.23'
CURRENT_MAX_VER = '1.24'
class Version(object):

View File

@ -188,3 +188,17 @@ user documentation.
Add support for file injection when creating a container.
The content of the file is sent to Zun server via parameter 'mounts'.
1.24
----
Add a parameter 'exposed_ports' to the request of creating a container.
This parameter is of the following form:
"exposed_ports": { "<port>/<protocol>: {}" }
where 'port' is the container's port and 'protocol' is either 'tcp' or 'udp'.
If this parameter is specified, Zun will create a security group and open
the exposed port. This parameter cannot be used together with the
'security_groups' parameter because Zun will manage the security groups of
the container.

View File

@ -487,6 +487,36 @@ def check_for_restart_policy(container_dict):
container_dict.get('restart_policy')['MaximumRetryCount'] = '0'
def build_exposed_ports(ports):
def validate_protocol(protocol):
if protocol not in ('tcp', 'udp'):
raise exception.InvalidValue(_(
"value %s is an invalid protocol") % protocol)
def validate_port(port):
try:
int(port)
except ValueError:
msg = _("value %s is invalid as publish port.") % port
raise exception.InvalidValue(msg)
exposed_ports = {}
for key, value in ports.items():
try:
port, protocol = key.split('/')
except ValueError:
port, protocol = key, 'tcp'
validate_protocol(protocol)
validate_port(port)
key = '/'.join([port, protocol])
exposed_ports[key] = value
return exposed_ports
def build_requested_networks(context, nets):
"""Build requested networks by calling neutron client

View File

@ -17,6 +17,7 @@ import functools
import types
from docker import errors
from neutronclient.common import exceptions as n_exc
from oslo_log import log as logging
from oslo_utils import timeutils
from oslo_utils import uuidutils
@ -285,6 +286,7 @@ class DockerDriver(driver.ContainerDriver):
# host_config['pid_mode'] = 'container:%s' % sandbox_id
host_config['ipc_mode'] = 'container:%s' % sandbox_id
else:
self._process_exposed_ports(network_api.neutron_api, container)
self._process_networking_config(
context, container, requested_networks, host_config,
kwargs, docker)
@ -343,6 +345,16 @@ class DockerDriver(driver.ContainerDriver):
def get_host_default_base_size(self):
return self.base_device_size
def _process_exposed_ports(self, neutron_api, container):
if not container.exposed_ports:
return
secgroup_name = self._get_secgorup_name(container.uuid)
secgroup_id = neutron_api.create_security_group({'security_group': {
"name": secgroup_name}})['security_group']['id']
neutron_api.expose_ports(secgroup_id, container.exposed_ports)
container.security_groups = [secgroup_id]
def _process_networking_config(self, context, container,
requested_networks, host_config,
container_kwargs, docker):
@ -380,6 +392,9 @@ class DockerDriver(driver.ContainerDriver):
self._get_or_create_docker_network(
context, network_api, rq_network['network'])
def _get_secgorup_name(self, container_uuid):
return consts.NAME_PREFIX + container_uuid
def _get_binds(self, context, requested_volumes):
binds = {}
for volume in requested_volumes:
@ -419,6 +434,8 @@ class DockerDriver(driver.ContainerDriver):
network_api = zun_network.api(context=context,
docker_api=docker)
self._cleanup_network_for_container(container, network_api)
self._cleanup_exposed_ports(network_api.neutron_api,
container)
if container.container_id:
docker.remove_container(container.container_id,
force=force)
@ -438,6 +455,15 @@ class DockerDriver(driver.ContainerDriver):
network_api.disconnect_container_from_network(
container, docker_net, neutron_network_id=neutron_net)
def _cleanup_exposed_ports(self, neutron_api, container):
if not container.exposed_ports:
return
try:
neutron_api.delete_security_group(container.security_groups[0])
except n_exc.NeutronClientException:
LOG.exception("Failed to delete security group")
def check_container_exist(self, container):
with docker_utils.docker_client() as docker:
docker_containers = [c['Id']
@ -975,6 +1001,7 @@ class DockerDriver(driver.ContainerDriver):
'hostname': name[:63],
'volumes': volumes,
}
self._process_exposed_ports(network_api.neutron_api, container)
self._process_networking_config(
context, container, requested_networks, host_config,
kwargs, docker)
@ -1042,6 +1069,7 @@ class DockerDriver(driver.ContainerDriver):
with docker_utils.docker_client() as docker:
network_api = zun_network.api(context=context, docker_api=docker)
self._cleanup_network_for_container(container, network_api)
self._cleanup_exposed_ports(network_api.neutron_api, container)
try:
docker.remove_container(sandbox_id, force=True)
except errors.APIError as api_error:

View File

@ -0,0 +1,37 @@
# 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_exposed_ports_to_container
Revision ID: 02134de8e7d3
Revises: a019998b09b5
Create Date: 2018-08-19 19:29:51.636559
"""
# revision identifiers, used by Alembic.
revision = '02134de8e7d3'
down_revision = 'a019998b09b5'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
import zun
def upgrade():
op.add_column('container',
sa.Column('exposed_ports',
zun.db.sqlalchemy.models.JSONEncodedDict(),
nullable=True))

View File

@ -172,6 +172,7 @@ class Container(Base):
started_at = Column(DateTime)
privileged = Column(Boolean, default=False)
healthcheck = Column(JSONEncodedDict)
exposed_ports = Column(JSONEncodedDict)
class VolumeMapping(Base):

View File

@ -189,18 +189,6 @@ class KuryrNetwork(network.Network):
if requested_network.get('port'):
neutron_port_id = requested_network.get('port')
neutron_port = self.neutron_api.get_neutron_port(neutron_port_id)
# NOTE(hongbin): If existing port is specified, security_group_ids
# is ignored because existing port already has security groups.
# We might revisit this behaviour later. Alternatively, we could
# either throw an exception or overwrite the port's security
# groups.
if not container.security_groups:
container.security_groups = []
if neutron_port.get('security_groups'):
for sg in neutron_port['security_groups']:
if sg not in container.security_groups:
container.security_groups += [sg]
# update device_id in port
port_req_body = {'port': {'device_id': container.uuid}}
self.neutron_api.update_port(neutron_port_id, port_req_body)

View File

@ -11,7 +11,9 @@
# under the License.
from neutron_lib import constants as n_const
from neutronclient.common import exceptions as n_exceptions
from neutronclient.neutron import v2_0 as neutronv20
from oslo_log import log as logging
from oslo_utils import uuidutils
from zun.common import clients
@ -19,6 +21,9 @@ from zun.common import exception
from zun.common.i18n import _
LOG = logging.getLogger(__name__)
class NeutronAPI(object):
def __init__(self, context):
@ -98,3 +103,26 @@ class NeutronAPI(object):
binding_vif_type = port.get('binding:vif_type')
if binding_vif_type == 'binding_failed':
raise exception.PortBindingFailed(port=port['id'])
def expose_ports(self, secgroup_id, ports):
for port in ports:
port, proto = port.split('/')
secgroup_rule = {
'security_group_id': secgroup_id,
'direction': 'ingress',
'port_range_min': port,
'port_range_max': port,
'protocol': proto
}
try:
self.create_security_group_rule({
'security_group_rule': secgroup_rule})
except n_exceptions.NeutronClientException as ex:
LOG.error("Error happened during creating a "
"Neutron security group "
"rule: %s", ex)
self.delete_security_group(secgroup_id)
raise exception.ZunException(_(
"Could not create required security group rules %s "
"for setting up exported port.") % secgroup_rule)

View File

@ -67,7 +67,8 @@ class Container(base.ZunPersistentObject, base.ZunObject):
# Version 1.34: Add privileged to container
# Version 1.35: Add 'healthcheck' attribute
# Version 1.36: Add 'get_count' method
VERSION = '1.36'
# Version 1.37: Add 'exposed_ports' attribute
VERSION = '1.37'
fields = {
'id': fields.IntegerField(),
@ -107,6 +108,7 @@ class Container(base.ZunPersistentObject, base.ZunObject):
'auto_heal': fields.BooleanField(nullable=True),
'capsule_id': fields.IntegerField(nullable=True),
'started_at': fields.DateTimeField(tzinfo_aware=False, nullable=True),
'exposed_ports': z_fields.JsonField(nullable=True),
'exec_instances': fields.ListOfObjectsField('ExecInstance',
nullable=True),
'privileged': fields.BooleanField(nullable=True),

View File

@ -26,7 +26,7 @@ from zun.tests.unit.db import base
PATH_PREFIX = '/v1'
CURRENT_VERSION = "container 1.23"
CURRENT_VERSION = "container 1.24"
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.23',
'max_version': '1.24',
'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.23',
'max_version': '1.24',
'min_version': '1.1',
'status': 'CURRENT'}]}

View File

@ -87,6 +87,24 @@ class TestContainerController(api_base.FunctionalTest):
self.post('/v1/containers?run=true', params=params,
content_type='application/json')
@patch('zun.network.neutron.NeutronAPI.get_available_network')
@patch('zun.compute.api.API.container_create')
def test_run_container_wrong_exposed_ports(
self, mock_container_create, mock_mock_neutron_get_network):
params = ('{"name": "MyDocker", "image": "ubuntu",'
'"exposed_ports": {"foo": {}}}')
with self.assertRaisesRegex(AppError,
"value foo is invalid as publish port"):
self.post('/v1/containers?run=true', params=params,
content_type='application/json')
params = ('{"name": "MyDocker", "image": "ubuntu",'
'"exposed_ports": {"80/foo": {}}}')
with self.assertRaisesRegex(AppError,
"value foo is an invalid protocol"):
self.post('/v1/containers?run=true', params=params,
content_type='application/json')
def test_run_container_runtime_wrong_api_version(self):
params = ('{"name": "MyDocker", "image": "ubuntu",'
'"command": "env", "memory": "512",'
@ -282,6 +300,7 @@ class TestContainerController(api_base.FunctionalTest):
'"runtime": "runc", "hostname": "testhost",'
'"disk": 20, "restart_policy": {"Name": "no"},'
'"nets": [{"network": "testpublicnet"}],'
'"exposed_ports": {"80/tcp": {}, "22": {}},'
'"mounts": [{"source": "s", "destination": "d"}]}')
response = self.post('/v1/containers/',
params=params,
@ -311,6 +330,10 @@ class TestContainerController(api_base.FunctionalTest):
mock_container_create.call_args[1]['requested_volumes']
self.assertEqual(1, len(requested_volumes))
self.assertEqual(fake_volume_id, requested_volumes[0].volume_id)
exposed_ports = mock_container_create.call_args[0][1].exposed_ports
self.assertEqual(2, len(exposed_ports))
self.assertIn("80/tcp", exposed_ports)
self.assertIn("22/tcp", exposed_ports)
# Delete the container we created
def side_effect(*args, **kwargs):

View File

@ -94,6 +94,8 @@ class TestDockerDriver(base.DriverTestCase):
self.driver.images(repo='test')
self.mock_docker.images.assert_called_once_with('test', False)
@mock.patch('neutronclient.v2_0.client.Client.create_security_group')
@mock.patch('zun.network.neutron.NeutronAPI.expose_ports')
@mock.patch('zun.network.kuryr_network.KuryrNetwork'
'.connect_container_to_network')
@mock.patch('zun.network.kuryr_network.KuryrNetwork'
@ -104,7 +106,9 @@ class TestDockerDriver(base.DriverTestCase):
self, mock_save,
mock_get_security_group_ids,
mock_create_or_update_port,
mock_connect):
mock_connect,
mock_expose_ports,
mock_create_security_group):
self.mock_docker.create_host_config = mock.Mock(
return_value={'Id1': 'val1', 'key2': 'val2'})
self.mock_docker.create_container = mock.Mock(
@ -122,6 +126,8 @@ class TestDockerDriver(base.DriverTestCase):
volumes = []
fake_port = {'mac_address': 'fake_mac'}
mock_create_or_update_port.return_value = ([], fake_port)
mock_create_security_group.return_value = {
'security_group': {'id': 'fake-id'}}
# DockerDriver with supported storage driver - overlay2
self.driver._host.sp_disk_quota = True
self.driver._host.storage_driver = 'overlay2'
@ -161,6 +167,8 @@ class TestDockerDriver(base.DriverTestCase):
self.assertEqual(result_container.status,
consts.CREATED)
@mock.patch('neutronclient.v2_0.client.Client.create_security_group')
@mock.patch('zun.network.neutron.NeutronAPI.expose_ports')
@mock.patch('zun.network.kuryr_network.KuryrNetwork'
'.connect_container_to_network')
@mock.patch('zun.network.kuryr_network.KuryrNetwork'
@ -171,7 +179,9 @@ class TestDockerDriver(base.DriverTestCase):
self, mock_save,
mock_get_security_group_ids,
mock_create_or_update_port,
mock_connect):
mock_connect,
mock_expose_ports,
mock_create_security_group):
self.mock_docker.create_host_config = mock.Mock(
return_value={'Id1': 'val1', 'key2': 'val2'})
self.mock_docker.create_container = mock.Mock(
@ -189,6 +199,8 @@ class TestDockerDriver(base.DriverTestCase):
volumes = []
fake_port = {'mac_address': 'fake_mac'}
mock_create_or_update_port.return_value = ([], fake_port)
mock_create_security_group.return_value = {
'security_group': {'id': 'fake-id'}}
# DockerDriver with supported storage driver - overlay2
self.driver._host.sp_disk_quota = True
self.driver._host.storage_driver = 'devicemapper'
@ -229,6 +241,8 @@ class TestDockerDriver(base.DriverTestCase):
self.assertEqual(result_container.status,
consts.CREATED)
@mock.patch('neutronclient.v2_0.client.Client.create_security_group')
@mock.patch('zun.network.neutron.NeutronAPI.expose_ports')
@mock.patch('zun.network.kuryr_network.KuryrNetwork'
'.connect_container_to_network')
@mock.patch('zun.network.kuryr_network.KuryrNetwork'
@ -239,7 +253,9 @@ class TestDockerDriver(base.DriverTestCase):
self, mock_save,
mock_get_security_group_ids,
mock_create_or_update_port,
mock_connect):
mock_connect,
mock_expose_ports,
mock_create_security_group):
CONF.set_override("docker_remote_api_version", "1.24", "docker")
self.mock_docker.create_host_config = mock.Mock(
return_value={'Id1': 'val1', 'key2': 'val2'})
@ -259,6 +275,8 @@ class TestDockerDriver(base.DriverTestCase):
volumes = []
fake_port = {'mac_address': 'fake_mac'}
mock_create_or_update_port.return_value = ([], fake_port)
mock_create_security_group.return_value = {
'security_group': {'id': 'fake-id'}}
result_container = self.driver.create(self.context, mock_container,
image, networks, volumes)
host_config = {}
@ -335,11 +353,15 @@ class TestDockerDriver(base.DriverTestCase):
@mock.patch('zun.container.docker.driver.DockerDriver'
'._cleanup_network_for_container')
def test_delete_success(self, mock_cleanup_network_for_container):
@mock.patch('zun.container.docker.driver.DockerDriver'
'._cleanup_exposed_ports')
def test_delete_success(self, mock_cleanup_network_for_container,
mock_cleanup_exposed_ports):
self.mock_docker.remove_container = mock.Mock()
mock_container = self.mock_default_container
self.driver.delete(self.context, mock_container, True)
self.assertTrue(mock_cleanup_network_for_container.called)
self.assertTrue(mock_cleanup_exposed_ports.called)
self.mock_docker.remove_container.assert_called_once_with(
mock_container.container_id, force=True)
@ -714,6 +736,8 @@ class TestDockerDriver(base.DriverTestCase):
self.mock_docker.commit.assert_called_once_with(
mock_container.container_id, "repo", "tag")
@mock.patch('neutronclient.v2_0.client.Client.create_security_group')
@mock.patch('zun.network.neutron.NeutronAPI.expose_ports')
@mock.patch('zun.network.kuryr_network.KuryrNetwork'
'.connect_container_to_network')
@mock.patch('zun.network.kuryr_network.KuryrNetwork'
@ -722,7 +746,8 @@ class TestDockerDriver(base.DriverTestCase):
@mock.patch('zun.common.utils.get_security_group_ids')
def test_create_sandbox(self, mock_get_security_group_ids,
mock_get_sandbox_name, mock_create_or_update_port,
mock_connect):
mock_connect, mock_expose_ports,
mock_create_security_group):
sandbox_name = 'my_test_sandbox'
mock_get_sandbox_name.return_value = sandbox_name
self.mock_docker.create_container = mock.Mock(
@ -747,6 +772,8 @@ class TestDockerDriver(base.DriverTestCase):
networking_config={'Id': 'val1', 'key1': 'val2'})
self.assertEqual(result_sandbox_id, 'val1')
@mock.patch('neutronclient.v2_0.client.Client.create_security_group')
@mock.patch('zun.network.neutron.NeutronAPI.expose_ports')
@mock.patch('zun.network.kuryr_network.KuryrNetwork'
'.connect_container_to_network')
@mock.patch('zun.network.kuryr_network.KuryrNetwork'
@ -756,7 +783,9 @@ class TestDockerDriver(base.DriverTestCase):
def test_create_sandbox_with_long_name(self, mock_get_security_group_ids,
mock_get_sandbox_name,
mock_create_or_update_port,
mock_connect):
mock_connect,
mock_expose_ports,
mock_create_security_group):
sandbox_name = 'x' * 100
mock_get_sandbox_name.return_value = sandbox_name
self.mock_docker.create_container = mock.Mock(
@ -780,7 +809,8 @@ class TestDockerDriver(base.DriverTestCase):
networking_config={'Id': 'val1', 'key1': 'val2'})
self.assertEqual(result_sandbox_id, 'val1')
def test_delete_sandbox(self):
@mock.patch('neutronclient.v2_0.client.Client.delete_security_group')
def test_delete_sandbox(self, mock_delete_security_group):
self.mock_docker.remove_container = mock.Mock()
mock_container = mock.MagicMock()
mock_container.get_sandbox_id.return_value = 'test_sandbox_id'

View File

@ -107,6 +107,7 @@ def get_test_container(**kwargs):
{"retries": "2", "timeout": 3,
"test": "stat /etc/passwd || exit 1",
"interval": 3}),
'exposed_ports': kwargs.get('exposed_ports', {"80/tcp": {}}),
}

View File

@ -344,7 +344,7 @@ class TestObject(test_base.TestCase, _TestObject):
# For more information on object version testing, read
# https://docs.openstack.org/zun/latest/
object_data = {
'Container': '1.36-ad2bacdaa51afd0047e96003f93ff181',
'Container': '1.37-cdc1537de5adf3570b598da1a3728a68',
'VolumeMapping': '1.3-14e3f9fc64e7afd751727c6ad3f32a94',
'Image': '1.2-80504fdd797e9dd86128a91680e876ad',
'MyObj': '1.0-34c4b1aadefd177b13f9a2f894cc23cd',