commit d9f5d8a1cdfee28da4c2b3e031f50110216fd97c
Author: Norbert Illes <norbert.e.illes@ericsson.com>
Date:   Tue May 30 11:28:07 2017 +0200

    New resource: Neutron Trunk
    
    Add a new OS::Neutron::Trunk resource and support the creation,
    deletion and update of Neutron Trunks.
    
    Co-Authored-By: Bence Romsics <bence.romsics@ericsson.com>
    Co-Authored-By: David Toth <david.t.toth@ericsson.com>
    Change-Id: Iea12844f77abf8c254f6224d55470663eba66aab
    Implements: blueprint support-trunk-port
    (cherry picked from commit 1f8515ace2b0a8d58f4e02ef6d58fb0947661cea)

diff --git a/heat/engine/resources/openstack/neutron/neutron.py b/heat/engine/resources/openstack/neutron/neutron.py
index 556295793..455a0a9c5 100644
--- a/heat/engine/resources/openstack/neutron/neutron.py
+++ b/heat/engine/resources/openstack/neutron/neutron.py
@@ -86,7 +86,7 @@ class NeutronResource(resource.Resource):
             return False
         if status in ('ACTIVE', 'DOWN'):
             return True
-        elif status == 'ERROR':
+        elif status in ('ERROR', 'DEGRADED'):
             raise exception.ResourceInError(
                 resource_status=status)
         else:
diff --git a/heat/engine/resources/openstack/neutron/trunk.py b/heat/engine/resources/openstack/neutron/trunk.py
new file mode 100644
index 000000000..90ffd9766
--- /dev/null
+++ b/heat/engine/resources/openstack/neutron/trunk.py
@@ -0,0 +1,335 @@
+# Copyright 2017 Ericsson
+#
+#    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 oslo_log import log as logging
+
+from heat.common.i18n import _
+from heat.engine import attributes
+from heat.engine import constraints
+from heat.engine import properties
+from heat.engine.resources.openstack.neutron import neutron
+from heat.engine import support
+from heat.engine import translation
+
+LOG = logging.getLogger(__name__)
+
+
+class Trunk(neutron.NeutronResource):
+    """A resource for managing Neutron trunks.
+
+    Requires Neutron Trunk Extension to be enabled::
+
+      $ openstack extension show trunk
+
+    The network trunk service allows multiple networks to be connected to
+    an instance using a single virtual NIC (vNIC). Multiple networks can
+    be presented to an instance by connecting the instance to a single port.
+
+    Users can create a port, associate it with a trunk (as the trunk's parent)
+    and launch an instance on that port. Users can dynamically attach and
+    detach additional networks without disrupting operation of the instance.
+
+    Every trunk has a parent port and can have any number (0, 1, ...) of
+    subports. The parent port is the port that the instance is directly
+    associated with and its traffic is always untagged inside the instance.
+    Users must specify the parent port of the trunk when launching an
+    instance attached to a trunk.
+
+    A network presented by a subport is the network of the associated port.
+    When creating a subport, a ``segmentation_type`` and ``segmentation_id``
+    may be required by the driver so the user can distinguish the networks
+    inside the instance. As of release Pike only ``segmentation_type``
+    ``vlan`` is supported. ``segmentation_id`` defines the segmentation ID
+    on which the subport network is presented to the instance.
+
+    Note that some Neutron backends (primarily Open vSwitch) only allow
+    trunk creation before an instance is booted on the parent port. To avoid
+    a possible race condition when booting an instance with a trunk it is
+    strongly recommended to refer to the trunk's parent port indirectly in
+    the template via ``get_attr``. For example::
+
+      trunk:
+        type: OS::Neutron::Trunk
+        properties:
+          port: ...
+      instance:
+        type: OS::Nova::Server
+        properties:
+          networks:
+            - { port: { get_attr: [trunk, port_id] } }
+
+    Though other Neutron backends may tolerate the direct port reference
+    (and the possible reverse ordering of API requests implied) it's a good
+    idea to avoid writing Neutron backend specific templates.
+    """
+
+    entity = 'trunk'
+
+    required_service_extension = 'trunk'
+
+    support_status = support.SupportStatus(
+        status=support.SUPPORTED,
+        version='9.0.0',
+    )
+
+    PROPERTIES = (
+        NAME, PARENT_PORT, SUB_PORTS, DESCRIPTION, ADMIN_STATE_UP,
+    ) = (
+        'name', 'port', 'sub_ports', 'description', 'admin_state_up',
+    )
+
+    _SUBPORT_KEYS = (
+        PORT, SEGMENTATION_TYPE, SEGMENTATION_ID,
+    ) = (
+        'port', 'segmentation_type', 'segmentation_id',
+    )
+
+    _subport_schema = {
+        PORT: properties.Schema(
+            properties.Schema.STRING,
+            _('ID or name of a port to be used as a subport.'),
+            required=True,
+            constraints=[
+                constraints.CustomConstraint('neutron.port'),
+            ],
+        ),
+        SEGMENTATION_TYPE: properties.Schema(
+            properties.Schema.STRING,
+            _('Segmentation type to be used on the subport.'),
+            required=True,
+            # TODO(nilles): custom constraint 'neutron.trunk_segmentation_type'
+            constraints=[
+                constraints.AllowedValues(['vlan']),
+            ],
+        ),
+        SEGMENTATION_ID: properties.Schema(
+            properties.Schema.INTEGER,
+            _('The segmentation ID on which the subport network is presented '
+              'to the instance.'),
+            required=True,
+            # TODO(nilles): custom constraint 'neutron.trunk_segmentation_id'
+            constraints=[
+                constraints.Range(1, 4094),
+            ],
+        ),
+    }
+
+    ATTRIBUTES = (
+        PORT_ATTR,
+    ) = (
+        'port_id',
+    )
+
+    properties_schema = {
+        NAME: properties.Schema(
+            properties.Schema.STRING,
+            _('A string specifying a symbolic name for the trunk, which is '
+              'not required to be uniqe.'),
+            update_allowed=True,
+        ),
+        PARENT_PORT: properties.Schema(
+            properties.Schema.STRING,
+            _('ID or name of a port to be used as a parent port.'),
+            required=True,
+            immutable=True,
+            constraints=[
+                constraints.CustomConstraint('neutron.port'),
+            ],
+        ),
+        SUB_PORTS: properties.Schema(
+            properties.Schema.LIST,
+            _('List with 0 or more map elements containing subport details.'),
+            schema=properties.Schema(
+                properties.Schema.MAP,
+                schema=_subport_schema,
+            ),
+            update_allowed=True,
+        ),
+        DESCRIPTION: properties.Schema(
+            properties.Schema.STRING,
+            _('Description for the trunk.'),
+            update_allowed=True,
+        ),
+        ADMIN_STATE_UP: properties.Schema(
+            properties.Schema.BOOLEAN,
+            _('Enable/disable subport addition, removal and trunk delete.'),
+            update_allowed=True,
+        ),
+    }
+
+    attributes_schema = {
+        PORT_ATTR: attributes.Schema(
+            _('ID or name of a port used as a parent port.'),
+            type=attributes.Schema.STRING,
+        ),
+    }
+
+    def translation_rules(self, props):
+        return [
+            translation.TranslationRule(
+                props,
+                translation.TranslationRule.RESOLVE,
+                [self.PARENT_PORT],
+                client_plugin=self.client_plugin(),
+                finder='find_resourceid_by_name_or_id',
+                entity='port',
+            ),
+            translation.TranslationRule(
+                props,
+                translation.TranslationRule.RESOLVE,
+                translation_path=[self.SUB_PORTS, self.PORT],
+                client_plugin=self.client_plugin(),
+                finder='find_resourceid_by_name_or_id',
+                entity='port',
+            ),
+        ]
+
+    def handle_create(self):
+        props = self.prepare_properties(
+            self.properties,
+            self.physical_resource_name())
+        props['port_id'] = props.pop(self.PARENT_PORT)
+
+        if self.SUB_PORTS in props and props[self.SUB_PORTS]:
+            for sub_port in props[self.SUB_PORTS]:
+                sub_port['port_id'] = sub_port.pop(self.PORT)
+
+        LOG.debug('attempt to create trunk: %s', props)
+        trunk = self.client().create_trunk({'trunk': props})['trunk']
+        self.resource_id_set(trunk['id'])
+
+    def _show_resource(self):
+        return self.client().show_trunk(
+            self.resource_id)['trunk']
+
+    def check_create_complete(self, *args):
+        attributes = self._show_resource()
+        return self.is_built(attributes)
+
+    def handle_delete(self):
+        if self.resource_id is not None:
+            with self.client_plugin().ignore_not_found:
+                LOG.debug('attempt to delete trunk: %s', self.resource_id)
+                self.client().delete_trunk(self.resource_id)
+
+    def handle_update(self, json_snippet, tmpl_diff, prop_diff):
+        """Handle update to a trunk in (at most) three neutron calls.
+
+        Call #1) Update all changed properties but 'sub_ports'.
+            PUT /v2.0/trunks/TRUNK_ID
+            openstack network trunk set
+
+        Call #2) Delete subports not needed anymore.
+            PUT /v2.0/trunks/TRUNK_ID/remove_subports
+            openstack network trunk unset --subport
+
+        Call #3) Create new subports.
+            PUT /v2.0/trunks/TRUNK_ID/add_subports
+            openstack network trunk set --subport
+
+        A single neutron port cannot be two subports at the same time (ie.
+        have two segmentation (type, ID)s on the same trunk or to belong to
+        two trunks). Therefore we have to delete old subports before creating
+        new ones to avoid conflicts.
+        """
+
+        LOG.debug('attempt to update trunk %s', self.resource_id)
+
+        # NOTE(bence romsics): We want to do set operations on the subports,
+        # however we receive subports represented as dicts. In Python
+        # mutable objects like dicts are not hashable so they cannot be
+        # inserted into sets. So we convert subport dicts to (immutable)
+        # frozensets in order to do the set operations.
+        def dict2frozenset(d):
+            """Convert a dict to a frozenset.
+
+            Create an immutable equivalent of a dict, so it's hashable
+            therefore can be used as an element of a set or a key of another
+            dictionary.
+            """
+            return frozenset(d.items())
+
+        # NOTE(bence romsics): prop_diff contains a shallow diff of the
+        # properties, so if we had used that to update subports we would
+        # re-create all subports even if just a single subport changed. So we
+        # need to forget about prop_diff['sub_ports'] and diff out the real
+        # subport changes from self.properties and json_snippet.
+        if 'sub_ports' in prop_diff:
+            del prop_diff['sub_ports']
+
+        sub_ports_prop_old = self.properties[self.SUB_PORTS] or []
+        sub_ports_prop_new = json_snippet.properties(
+            self.properties_schema)[self.SUB_PORTS] or []
+
+        subports_old = {dict2frozenset(d): d for d in sub_ports_prop_old}
+        subports_new = {dict2frozenset(d): d for d in sub_ports_prop_new}
+
+        old_set = set(subports_old.keys())
+        new_set = set(subports_new.keys())
+
+        delete = old_set - new_set
+        create = new_set - old_set
+
+        dicts_delete = [subports_old[fs] for fs in delete]
+        dicts_create = [subports_new[fs] for fs in create]
+
+        LOG.debug('attempt to delete subports of trunk %s: %s',
+                  self.resource_id, dicts_delete)
+        LOG.debug('attempt to create subports of trunk %s: %s',
+                  self.resource_id, dicts_create)
+
+        if prop_diff:
+            self.prepare_update_properties(prop_diff)
+            self.client().update_trunk(self.resource_id, {'trunk': prop_diff})
+
+        if dicts_delete:
+            delete_body = self.prepare_trunk_remove_subports_body(dicts_delete)
+            self.client().trunk_remove_subports(self.resource_id, delete_body)
+
+        if dicts_create:
+            create_body = self.prepare_trunk_add_subports_body(dicts_create)
+            self.client().trunk_add_subports(self.resource_id, create_body)
+
+    def check_update_complete(self, *args):
+        attributes = self._show_resource()
+        return self.is_built(attributes)
+
+    @staticmethod
+    def prepare_trunk_remove_subports_body(subports):
+        """Prepares body for PUT /v2.0/trunks/TRUNK_ID/remove_subports."""
+
+        return {
+            'sub_ports': [
+                {'port_id': sp['port']} for sp in subports
+            ]
+        }
+
+    @staticmethod
+    def prepare_trunk_add_subports_body(subports):
+        """Prepares body for PUT /v2.0/trunks/TRUNK_ID/add_subports."""
+
+        return {
+            'sub_ports': [
+                {'port_id': sp['port'],
+                 'segmentation_type': sp['segmentation_type'],
+                 'segmentation_id': sp['segmentation_id']}
+                for sp in subports
+            ]
+        }
+
+
+def resource_mapping():
+    return {
+        'OS::Neutron::Trunk': Trunk,
+    }
diff --git a/heat/tests/openstack/neutron/test_neutron_trunk.py b/heat/tests/openstack/neutron/test_neutron_trunk.py
new file mode 100644
index 000000000..ea878d63f
--- /dev/null
+++ b/heat/tests/openstack/neutron/test_neutron_trunk.py
@@ -0,0 +1,431 @@
+# Copyright 2017 Ericsson
+#
+#    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 copy
+import six
+
+from oslo_log import log as logging
+
+from heat.common import exception
+from heat.common import template_format
+from heat.engine.clients.os import neutron
+from heat.engine.resources.openstack.neutron import trunk
+from heat.engine import scheduler
+from heat.tests import common
+from heat.tests import utils
+from neutronclient.common import exceptions as ncex
+from neutronclient.neutron import v2_0 as neutronV20
+from neutronclient.v2_0 import client as neutronclient
+
+
+LOG = logging.getLogger(__name__)
+
+create_template = '''
+heat_template_version: 2017-09-01
+description: Template to test Neutron Trunk resource
+resources:
+  parent_port:
+    type: OS::Neutron::Port
+    properties:
+      network: parent_port_net
+  subport_1:
+    type: OS::Neutron::Port
+    properties:
+      network: subport_1_net
+  subport_2:
+    type: OS::Neutron::Port
+    properties:
+      network: subport_2_net
+  trunk:
+    type: OS::Neutron::Trunk
+    properties:
+      name: trunk name
+      description: trunk description
+      port: { get_resource: parent_port }
+      sub_ports:
+        - { port: { get_resource: subport_1 },
+            segmentation_type: vlan,
+            segmentation_id: 101 }
+        - { port: { get_resource: subport_2 },
+            segmentation_type: vlan,
+            segmentation_id: 102 }
+'''
+
+update_template = '''
+heat_template_version: 2017-09-01
+description: Template to test Neutron Trunk resource
+resources:
+  trunk:
+    type: OS::Neutron::Trunk
+    properties:
+      name: trunk name
+      description: trunk description
+      port: parent_port_id
+      sub_ports:
+        - { port: subport_1_id,
+            segmentation_type: vlan,
+            segmentation_id: 101 }
+        - { port: subport_2_id,
+            segmentation_type: vlan,
+            segmentation_id: 102 }
+'''
+
+
+class NeutronTrunkTest(common.HeatTestCase):
+
+    def setUp(self):
+        super(NeutronTrunkTest, self).setUp()
+
+        self.patchobject(
+            neutron.NeutronClientPlugin, 'has_extension', return_value=True)
+        self.create_trunk_mock = self.patchobject(
+            neutronclient.Client, 'create_trunk')
+        self.delete_trunk_mock = self.patchobject(
+            neutronclient.Client, 'delete_trunk')
+        self.show_trunk_mock = self.patchobject(
+            neutronclient.Client, 'show_trunk')
+        self.update_trunk_mock = self.patchobject(
+            neutronclient.Client, 'update_trunk')
+        self.trunk_remove_subports_mock = self.patchobject(
+            neutronclient.Client, 'trunk_remove_subports')
+        self.trunk_add_subports_mock = self.patchobject(
+            neutronclient.Client, 'trunk_add_subports')
+        self.find_resource_mock = self.patchobject(
+            neutronV20, 'find_resourceid_by_name_or_id')
+
+        rv = {
+            'trunk': {
+                'id': 'trunk id',
+                'status': 'DOWN',
+            }
+        }
+        self.create_trunk_mock.return_value = rv
+        self.show_trunk_mock.return_value = rv
+
+        def find_resourceid_by_name_or_id(
+                _client, _resource, name_or_id, **_kwargs):
+            return name_or_id
+        self.find_resource_mock.side_effect = find_resourceid_by_name_or_id
+
+    def _create_trunk(self, stack):
+        trunk = stack['trunk']
+        scheduler.TaskRunner(trunk.create)()
+
+        self.assertEqual((trunk.CREATE, trunk.COMPLETE), trunk.state)
+
+    def _delete_trunk(self, stack):
+        trunk = stack['trunk']
+        scheduler.TaskRunner(trunk.delete)()
+
+        self.assertEqual((trunk.DELETE, trunk.COMPLETE), trunk.state)
+
+    def test_create_missing_port_property(self):
+        t = template_format.parse(create_template)
+        del t['resources']['trunk']['properties']['port']
+        stack = utils.parse_stack(t)
+
+        self.assertRaises(
+            exception.StackValidationFailed,
+            stack.validate)
+
+    def test_create_no_subport(self):
+        t = template_format.parse(create_template)
+        del t['resources']['trunk']['properties']['sub_ports']
+        del t['resources']['subport_1']
+        del t['resources']['subport_2']
+        stack = utils.parse_stack(t)
+
+        self.patchobject(
+            stack['parent_port'], 'FnGetRefId', return_value='parent port id')
+        self.find_resource_mock.return_value = 'parent port id'
+
+        self._create_trunk(stack)
+
+        self.create_trunk_mock.assert_called_once_with({
+            'trunk': {
+                'description': 'trunk description',
+                'name': 'trunk name',
+                'port_id': 'parent port id',
+            }}
+        )
+
+    def test_create_one_subport(self):
+        t = template_format.parse(create_template)
+        del t['resources']['trunk']['properties']['sub_ports'][1:]
+        del t['resources']['subport_2']
+        stack = utils.parse_stack(t)
+
+        self.patchobject(
+            stack['parent_port'], 'FnGetRefId', return_value='parent port id')
+        self.patchobject(
+            stack['subport_1'], 'FnGetRefId', return_value='subport id')
+
+        self._create_trunk(stack)
+
+        self.create_trunk_mock.assert_called_once_with({
+            'trunk': {
+                'description': 'trunk description',
+                'name': 'trunk name',
+                'port_id': 'parent port id',
+                'sub_ports': [
+                    {'port_id': 'subport id',
+                     'segmentation_type': 'vlan',
+                     'segmentation_id': 101},
+                ],
+            }}
+        )
+
+    def test_create_two_subports(self):
+        t = template_format.parse(create_template)
+        del t['resources']['trunk']['properties']['sub_ports'][2:]
+        stack = utils.parse_stack(t)
+
+        self.patchobject(
+            stack['parent_port'], 'FnGetRefId', return_value='parent_port_id')
+        self.patchobject(
+            stack['subport_1'], 'FnGetRefId', return_value='subport_1_id')
+        self.patchobject(
+            stack['subport_2'], 'FnGetRefId', return_value='subport_2_id')
+
+        self._create_trunk(stack)
+
+        self.create_trunk_mock.assert_called_once_with({
+            'trunk': {
+                'description': 'trunk description',
+                'name': 'trunk name',
+                'port_id': 'parent_port_id',
+                'sub_ports': [
+                    {'port_id': 'subport_1_id',
+                     'segmentation_type': 'vlan',
+                     'segmentation_id': 101},
+                    {'port_id': 'subport_2_id',
+                     'segmentation_type': 'vlan',
+                     'segmentation_id': 102},
+                ],
+            }}
+        )
+
+    def test_create_degraded(self):
+        t = template_format.parse(create_template)
+        stack = utils.parse_stack(t)
+
+        rv = {
+            'trunk': {
+                'id': 'trunk id',
+                'status': 'DEGRADED',
+            }
+        }
+        self.create_trunk_mock.return_value = rv
+        self.show_trunk_mock.return_value = rv
+
+        trunk = stack['trunk']
+        e = self.assertRaises(
+            exception.ResourceInError,
+            trunk.check_create_complete,
+            trunk.resource_id)
+
+        self.assertIn(
+            'Went to status DEGRADED due to',
+            six.text_type(e))
+
+    def test_create_parent_port_by_name(self):
+        t = template_format.parse(create_template)
+        t['resources']['parent_port'][
+            'properties']['name'] = 'parent port name'
+        t['resources']['trunk'][
+            'properties']['port'] = 'parent port name'
+        del t['resources']['trunk']['properties']['sub_ports']
+        stack = utils.parse_stack(t)
+
+        self.patchobject(
+            stack['parent_port'], 'FnGetRefId', return_value='parent port id')
+
+        def find_resourceid_by_name_or_id(
+                _client, _resource, name_or_id, **_kwargs):
+            name_to_id = {
+                'parent port name': 'parent port id',
+                'parent port id': 'parent port id',
+            }
+            return name_to_id[name_or_id]
+        self.find_resource_mock.side_effect = find_resourceid_by_name_or_id
+
+        self._create_trunk(stack)
+
+        self.create_trunk_mock.assert_called_once_with({
+            'trunk': {
+                'description': 'trunk description',
+                'name': 'trunk name',
+                'port_id': 'parent port id',
+            }}
+        )
+
+    def test_create_subport_by_name(self):
+        t = template_format.parse(create_template)
+        del t['resources']['trunk']['properties']['sub_ports'][1:]
+        del t['resources']['subport_2']
+        t['resources']['subport_1'][
+            'properties']['name'] = 'subport name'
+        t['resources']['trunk'][
+            'properties']['sub_ports'][0]['port'] = 'subport name'
+        stack = utils.parse_stack(t)
+
+        self.patchobject(
+            stack['parent_port'], 'FnGetRefId', return_value='parent port id')
+        self.patchobject(
+            stack['subport_1'], 'FnGetRefId', return_value='subport id')
+
+        def find_resourceid_by_name_or_id(
+                _client, _resource, name_or_id, **_kwargs):
+            name_to_id = {
+                'subport name': 'subport id',
+                'subport id': 'subport id',
+                'parent port name': 'parent port id',
+                'parent port id': 'parent port id',
+            }
+            return name_to_id[name_or_id]
+        self.find_resource_mock.side_effect = find_resourceid_by_name_or_id
+
+        self._create_trunk(stack)
+
+        self.create_trunk_mock.assert_called_once_with({
+            'trunk': {
+                'description': 'trunk description',
+                'name': 'trunk name',
+                'port_id': 'parent port id',
+                'sub_ports': [
+                    {'port_id': 'subport id',
+                     'segmentation_type': 'vlan',
+                     'segmentation_id': 101},
+                ],
+            }}
+        )
+
+    def test_delete_proper(self):
+        t = template_format.parse(create_template)
+        stack = utils.parse_stack(t)
+
+        self._create_trunk(stack)
+        self._delete_trunk(stack)
+
+        self.delete_trunk_mock.assert_called_once_with('trunk id')
+
+    def test_delete_already_gone(self):
+        t = template_format.parse(create_template)
+        stack = utils.parse_stack(t)
+
+        self._create_trunk(stack)
+        self.delete_trunk_mock.side_effect = ncex.NeutronClientException(
+            status_code=404)
+        self._delete_trunk(stack)
+
+        self.delete_trunk_mock.assert_called_once_with('trunk id')
+
+    def test_update_basic_properties(self):
+        t = template_format.parse(update_template)
+        stack = utils.parse_stack(t)
+
+        rsrc_defn = stack.t.resource_definitions(stack)['trunk']
+        rsrc = trunk.Trunk('trunk', rsrc_defn, stack)
+        scheduler.TaskRunner(rsrc.create)()
+        self.assertEqual((rsrc.CREATE, rsrc.COMPLETE), rsrc.state)
+
+        props = copy.deepcopy(t['resources']['trunk']['properties'])
+        props['name'] = 'new trunk name'
+        rsrc_defn = rsrc_defn.freeze(properties=props)
+        scheduler.TaskRunner(rsrc.update, rsrc_defn)()
+        self.assertEqual((rsrc.UPDATE, rsrc.COMPLETE), rsrc.state)
+
+        self.update_trunk_mock.assert_called_once_with(
+            'trunk id', {'trunk': {'name': 'new trunk name'}}
+        )
+        self.trunk_remove_subports_mock.assert_not_called()
+        self.trunk_add_subports_mock.assert_not_called()
+
+    def test_update_subport_delete(self):
+        t = template_format.parse(update_template)
+        stack = utils.parse_stack(t)
+
+        rsrc_defn = stack.t.resource_definitions(stack)['trunk']
+        rsrc = trunk.Trunk('trunk', rsrc_defn, stack)
+        scheduler.TaskRunner(rsrc.create)()
+        self.assertEqual((rsrc.CREATE, rsrc.COMPLETE), rsrc.state)
+
+        props = copy.deepcopy(t['resources']['trunk']['properties'])
+        del props['sub_ports'][1]
+        rsrc_defn = rsrc_defn.freeze(properties=props)
+        scheduler.TaskRunner(rsrc.update, rsrc_defn)()
+        self.assertEqual((rsrc.UPDATE, rsrc.COMPLETE), rsrc.state)
+
+        self.update_trunk_mock.assert_not_called()
+        self.trunk_remove_subports_mock.assert_called_once_with(
+            'trunk id', {'sub_ports': [{'port_id': u'subport_2_id'}]}
+        )
+        self.trunk_add_subports_mock.assert_not_called()
+
+    def test_update_subport_add(self):
+        t = template_format.parse(update_template)
+        stack = utils.parse_stack(t)
+
+        rsrc_defn = stack.t.resource_definitions(stack)['trunk']
+        rsrc = trunk.Trunk('trunk', rsrc_defn, stack)
+        scheduler.TaskRunner(rsrc.create)()
+        self.assertEqual((rsrc.CREATE, rsrc.COMPLETE), rsrc.state)
+
+        props = copy.deepcopy(t['resources']['trunk']['properties'])
+        props['sub_ports'].append(
+            {'port': 'subport_3_id',
+             'segmentation_type': 'vlan',
+             'segmentation_id': 103})
+        rsrc_defn = rsrc_defn.freeze(properties=props)
+        scheduler.TaskRunner(rsrc.update, rsrc_defn)()
+        self.assertEqual((rsrc.UPDATE, rsrc.COMPLETE), rsrc.state)
+
+        self.update_trunk_mock.assert_not_called()
+        self.trunk_remove_subports_mock.assert_not_called()
+        self.trunk_add_subports_mock.assert_called_once_with(
+            'trunk id',
+            {'sub_ports': [
+                {'port_id': 'subport_3_id',
+                 'segmentation_id': 103,
+                 'segmentation_type': 'vlan'}
+            ]}
+        )
+
+    def test_update_subport_change(self):
+        t = template_format.parse(update_template)
+        stack = utils.parse_stack(t)
+
+        rsrc_defn = stack.t.resource_definitions(stack)['trunk']
+        rsrc = trunk.Trunk('trunk', rsrc_defn, stack)
+        scheduler.TaskRunner(rsrc.create)()
+        self.assertEqual((rsrc.CREATE, rsrc.COMPLETE), rsrc.state)
+
+        props = copy.deepcopy(t['resources']['trunk']['properties'])
+        props['sub_ports'][1]['segmentation_id'] = 103
+        rsrc_defn = rsrc_defn.freeze(properties=props)
+        scheduler.TaskRunner(rsrc.update, rsrc_defn)()
+        self.assertEqual((rsrc.UPDATE, rsrc.COMPLETE), rsrc.state)
+
+        self.update_trunk_mock.assert_not_called()
+        self.trunk_remove_subports_mock.assert_called_once_with(
+            'trunk id', {'sub_ports': [{'port_id': u'subport_2_id'}]}
+        )
+        self.trunk_add_subports_mock.assert_called_once_with(
+            'trunk id',
+            {'sub_ports': [
+                {'port_id': 'subport_2_id',
+                 'segmentation_id': 103,
+                 'segmentation_type': 'vlan'}
+            ]}
+        )
diff --git a/releasenotes/notes/bp-support-trunk-port-733019c49a429826.yaml b/releasenotes/notes/bp-support-trunk-port-733019c49a429826.yaml
new file mode 100644
index 000000000..a308b2c75
--- /dev/null
+++ b/releasenotes/notes/bp-support-trunk-port-733019c49a429826.yaml
@@ -0,0 +1,3 @@
+---
+features:
+    - New resource ``OS::Neutron::Trunk`` is added to manage Neutron Trunks.
