diff --git a/doc/source/sdk/proxies/vpc.rst b/doc/source/sdk/proxies/vpc.rst index 29d28407f..b6635b0ab 100644 --- a/doc/source/sdk/proxies/vpc.rst +++ b/doc/source/sdk/proxies/vpc.rst @@ -25,3 +25,19 @@ VPC Route Operations .. autoclass:: otcextensions.sdk.vpc.v1._proxy.Proxy :noindex: :members: routes, get_route, add_route, delete_route + +VPC Operations +^^^^^^^^^^^^^^ + +.. autoclass:: otcextensions.sdk.vpc.v1._proxy.Proxy + :noindex: + :members: vpcs, create_vpc, delete_vpc, get_vpc, + find_vpc, update_vpc + +VPC Subnet Operations +^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: otcextensions.sdk.vpc.v1._proxy.Proxy + :noindex: + :members: subnets, create_subnet, delete_subnet, get_subnet, + find_subnet, update_subnet diff --git a/doc/source/sdk/resources/vpc/index.rst b/doc/source/sdk/resources/vpc/index.rst index 9c255873c..505deb6ef 100644 --- a/doc/source/sdk/resources/vpc/index.rst +++ b/doc/source/sdk/resources/vpc/index.rst @@ -4,5 +4,7 @@ VPC Resources .. toctree:: :maxdepth: 1 + v1/vpc + v1/subnet v2/peering v2/route diff --git a/doc/source/sdk/resources/vpc/v1/subnet.rst b/doc/source/sdk/resources/vpc/v1/subnet.rst new file mode 100644 index 000000000..ce0d48a98 --- /dev/null +++ b/doc/source/sdk/resources/vpc/v1/subnet.rst @@ -0,0 +1,13 @@ +otcextensions.sdk.vpc.v1.subnet +================================ + +.. automodule:: otcextensions.sdk.vpc.v1.subnet + +The VPC Subnet Class +-------------------- + +The ``Subnet`` class inherits from +:class:`~otcextensions.sdk.sdk_resource.Resource`. + +.. autoclass:: otcextensions.sdk.vpc.v1.subnet.Subnet + :members: diff --git a/doc/source/sdk/resources/vpc/v1/vpc.rst b/doc/source/sdk/resources/vpc/v1/vpc.rst new file mode 100644 index 000000000..796ff8eb0 --- /dev/null +++ b/doc/source/sdk/resources/vpc/v1/vpc.rst @@ -0,0 +1,13 @@ +otcextensions.sdk.vpc.v1.vpc +================================ + +.. automodule:: otcextensions.sdk.vpc.v1.vpc + +The VPC Class +------------------- + +The ``Vpc`` class inherits from +:class:`~otcextensions.sdk.sdk_resource.Resource`. + +.. autoclass:: otcextensions.sdk.vpc.v1.vpc.Vpc + :members: diff --git a/otcextensions/sdk/vpc/v1/_proxy.py b/otcextensions/sdk/vpc/v1/_proxy.py index 4997cca45..9ecc0ffdd 100644 --- a/otcextensions/sdk/vpc/v1/_proxy.py +++ b/otcextensions/sdk/vpc/v1/_proxy.py @@ -9,10 +9,12 @@ # 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 openstack import exceptions from openstack import proxy from otcextensions.sdk.vpc.v1 import peering as _peering from otcextensions.sdk.vpc.v1 import route as _route +from otcextensions.sdk.vpc.v1 import subnet as _subnet from otcextensions.sdk.vpc.v1 import vpc as _vpc @@ -187,15 +189,17 @@ def vpcs(self, **query): return self._list(_vpc.Vpc, **query) def create_vpc(self, **attrs): - """ Create a new vpc from attributes - :param dict attrs: Keyword arguments which will be used to create - a :class:`~otcextensions.sdk.vpc.v1.vpc.Vpc` + """Create a new vpc from attributes + + :param dict attrs: Keyword arguments which will be used to create a + :class:`~otcextensions.sdk.vpc.v1.vpc.Vpc` """ return self._create(_vpc.Vpc, **attrs, project_id=self.get_project_id()) def delete_vpc(self, vpc, ignore_missing=True): - """ Delete a vpc + """Delete a vpc + :param vpc: vpc id or an instance of :class:`~otcextensions.sdk.vpc.v1.vpc.Vpc` @@ -212,9 +216,10 @@ def delete_vpc(self, vpc, ignore_missing=True): ignore_missing=ignore_missing) def get_vpc(self, vpc): - """ Get a vpc by id + """Get a vpc by id + :param vpc: vpc id or an instance of - :class:`~otcextensions.sdk.vpc.v1.vpc.Vpc` + :class:`~otcextensions.sdk.vpc.v1.vpc.Vpc` :returns: One :class:`~otcextensions.sdk.vpc.v1.vpc.Vpc` """ @@ -224,6 +229,7 @@ def find_vpc(self, name_or_id, ignore_missing=False): """Find a single vpc :param name_or_id: The name or ID of a vpc + :param bool ignore_missing: When set to ``False`` :class:`~openstack.exceptions.ResourceNotFound` will be raised when the vpc does not exist. @@ -238,7 +244,7 @@ def find_vpc(self, name_or_id, ignore_missing=False): project_id=self.get_project_id()) def update_vpc(self, vpc, **attrs): - """ Update vpc + """Update vpc :param vpc: vpc id or an instance of :class:`~otcextensions.sdk.vpc.v1.vpc.Vpc` @@ -249,6 +255,124 @@ def update_vpc(self, vpc, **attrs): attrs['project_id'] = self.get_project_id() return self._update(_vpc.Vpc, vpc, **attrs) + # ========== Subnet ========== + def subnets(self, **query): + """Return a generator of subnets + + :param dict query: Optional query parameters to be sent to limit + the resources being returned. + + :returns: A generator of subnet objects + + :rtype: :class:`~otcextensions.sdk.vpc.v1.subnet.Subnet` + """ + query['project_id'] = self.get_project_id() + return self._list(_subnet.Subnet, **query) + + def create_subnet(self, **attrs): + """Create a new subnet from attributes + + :param dict attrs: Keyword arguments which will be used to create + a :class:`~otcextensions.sdk.vpc.v1.subnet.Subnet` + """ + attrs['project_id'] = self.get_project_id() + return self._create(_subnet.Subnet, **attrs) + + def get_subnet(self, subnet): + """Get a subnet by id + + :param subnet: subnet id or an instance of + :class:`~otcextensions.sdk.vpc.v1.subnet.Subnet` + + :returns: One :class:`~otcextensions.sdk.vpc.v1.subnet.Subnet` + """ + return self._get(_subnet.Subnet, subnet, + project_id=self.get_project_id()) + + def find_subnet(self, name_or_id, ignore_missing=False): + """Find a single subnet + + :param name_or_id: The name or ID of a subnet + + :param bool ignore_missing: When set to ``False`` + :class:`~openstack.exceptions.ResourceNotFound` will be raised + when the subnet does not exist. + When set to ``True``, no exception will be set when attempting + to delete a nonexistent peering. + + :returns: One :class:`~otcextensions.sdk.vpc.v1.subnet.Subnet` + """ + return self._find( + _subnet.Subnet, name_or_id, + ignore_missing=ignore_missing, + project_id=self.get_project_id()) + + def update_subnet(self, subnet, **attrs): + """Update subnet + + :param subnet: subnet id or an instance of + :class:`~otcextensions.sdk.vpc.v1.subnet.Subnet` + + :param dict attrs: The attributes to update on the subnet + represented by ``subnet``. + """ + attrs['project_id'] = self.get_project_id() + + rs = self._get_resource(_subnet.Subnet, subnet) + if rs.vpc_id is None and 'vpc_id' not in attrs: + raise AttributeError('Updating subnet requires VPC ID') + vpc_id = attrs.pop('vpc_id', rs.vpc_id) # vpc_id can't be changed + + attrs.pop('base_path', None) + base_path = _subnet.vpc_subnet_base_path(vpc_id) + + return self._update(_subnet.Subnet, + subnet, + base_path=base_path, + **attrs) + + def _delete(self, resource_type, value, ignore_missing=True, **attrs): + """Override of ``_delete`` with support of ``base_path``""" + base_path = attrs.pop('base_path', None) + + res = self._get_resource(resource_type, value, **attrs) + + try: + rv = res.delete(self, base_path=base_path) + except exceptions.ResourceNotFound: + if ignore_missing: + return None + raise + + return rv + + def delete_subnet(self, subnet, vpc_id=None, ignore_missing=True): + """Delete a subnet + + :param subnet: subnet id or an instance of + :class:`~otcextensions.sdk.vpc.v1.subnet.Subnet` + + :param vpc_id: VPC id. By default, taken from ``subnet``, if provided. + + :param bool ignore_missing: When set to ``False`` + :class:`~openstack.exceptions.ResourceNotFound` will be raised when + the subnet route does not exist. + When set to ``True``, no exception will be set when attempting to + delete a nonexistent route. + + :returns: none + """ + sn_res = self._get_resource(_subnet.Subnet, subnet) + sn_res.vpc_id = vpc_id or sn_res.vpc_id + if sn_res.vpc_id is None: + raise AttributeError('Deleting subnet requires VPC ID') + + base_path = _subnet.vpc_subnet_base_path(sn_res.vpc_id) + + return self._delete(_subnet.Subnet, subnet, + ignore_missing=ignore_missing, + base_path=base_path) + # ========== Project cleanup ========== def _get_cleanup_dependencies(self): return { diff --git a/otcextensions/sdk/vpc/v1/subnet.py b/otcextensions/sdk/vpc/v1/subnet.py new file mode 100644 index 000000000..b02332f85 --- /dev/null +++ b/otcextensions/sdk/vpc/v1/subnet.py @@ -0,0 +1,66 @@ +# 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 openstack import resource + + +class ExtraDHCPOpt(resource.Resource): + #: Specifies the NTP server address configured for the subnet. + opt_value = resource.Body('opt_value') + #: Specifies the NTP server address name configured for the subnet. + #: Currently, the value can only be set to ntp. + opt_name = resource.Body('opt_name', default='ntp') + + +class Subnet(resource.Resource): + resource_key = 'subnet' + resources_key = 'subnets' + base_path = '/v1/%(project_id)s/subnets' + + # capabilities + allow_create = True + allow_fetch = True + allow_commit = True + allow_delete = True + allow_list = True + + # Properties + project_id = resource.URI('project_id') + description = resource.Body('description') + #: Specifies the subnet CIDR block. + cidr = resource.Body('cidr') + #: Specifies the gateway of the subnet. + gateway_ip = resource.Body('gateway_ip') + #: Specifies whether DHCP is enabled for the subnet. + dhcp_enable = resource.Body('dhcp_enable') + #: Specifies the IP address of DNS server 1 on the subnet. + primary_dns = resource.Body('primary_dns') + #: Specifies the IP address of DNS server 2 on the subnet. + secondary_dns = resource.Body('secondary_dns') + #: Specifies the DNS server address list of a subnet. + #: This field is required if use more than two DNS servers. + dns_list = resource.Body('dnsList', type=list, list_type=str) + #: Specifies the AZ to which the subnet belongs + availability_zone = resource.Body('availability_zone') + #: Specifies the ID of the VPC to which the subnet belongs. + vpc_id = resource.Body('vpc_id') + #: Specifies the NTP server address configured for the subnet. + extra_dhcp_opts = resource.Body('extra_dhcp_opts', type=list, + list_type=ExtraDHCPOpt) + + status = resource.Body('status') + neutron_network_id = resource.Body('neutron_network_id') + neutron_subnet_id = resource.Body('neutron_subnet_id') + + +def vpc_subnet_base_path(vpc_id): + """Special case of subnet resource path""" + return f'/v1/%(project_id)s/vpcs/{vpc_id}/subnets' diff --git a/otcextensions/tests/functional/sdk/vpc/v1/__init__.py b/otcextensions/tests/functional/sdk/vpc/v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/otcextensions/tests/functional/sdk/vpc/v1/test_subnet.py b/otcextensions/tests/functional/sdk/vpc/v1/test_subnet.py new file mode 100644 index 000000000..e0bb5e8dd --- /dev/null +++ b/otcextensions/tests/functional/sdk/vpc/v1/test_subnet.py @@ -0,0 +1,131 @@ +# 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 uuid + +from openstack import _log +from openstack import resource + +from otcextensions.sdk.vpc.v1 import vpc +from otcextensions.tests.functional import base + +_logger = _log.setup_logging('openstack') + + +class TestService(base.BaseFunctionalTest): + _vpc: vpc.Vpc + _seed: str + + @property + def seed(self): + if not hasattr(self, '_seed') or self._seed is None: + self._seed = uuid.uuid4().hex[:8] + return self._seed + + def setUp(self): + super().setUp() + + self.client = self.conn.vpc + attrs = { + 'name': 'test-vpc-' + self.seed, + 'cidr': '192.168.0.0/16' + } + self.vpc = self.conn.vpc.create_vpc(**attrs) + self.addCleanup(self.conn.vpc.delete_vpc, self.vpc) + + def test_create_subnet(self): + cidr = self.vpc.cidr + gateway, _ = cidr.split("/") + gateway = gateway[:-2] + ".1" # .0 -> .1 + + attrs = { + 'vpc_id': self.vpc.id, + 'name': 'test-subnet-' + self.seed, + 'cidr': cidr, + 'gateway_ip': gateway, + 'dns_list': [ + "100.125.4.25", + "100.125.129.199", + ], + } + subnet = self.conn.vpc.create_subnet(**attrs) + self.assertIsNotNone(subnet.id) + self.addCleanup(self._delete_subnet, subnet) + + self.assertIsNotNone(subnet.status) + self.assertIsNotNone(subnet.neutron_subnet_id) + self.assertIsNotNone(subnet.neutron_network_id) + + def test_get_subnet(self): + subnet = self._create_subnet() + found = self.conn.vpc.get_subnet(subnet.id) + + self.assertEqual(found, subnet) + + def test_find_subnet(self): + subnet = self._create_subnet() + found = self.conn.vpc.find_subnet(subnet.name) + + self.assertEqual(found, subnet) + + def test_update_subnet(self): + subnet = self._create_subnet() + + new_attrs = { + 'name': 'test-updated-' + self.seed, + 'dns_list': [ + "100.125.4.25", + "8.8.8.8", + ], + } + updated = self.conn.vpc.update_subnet(subnet, **new_attrs) + self.assertEqual(updated.name, new_attrs['name']) + self.assertEqual(updated.dns_list, new_attrs['dns_list']) + + def test_delete_subnet(self): + subnet = self._create_subnet(False) + self.conn.vpc.delete_subnet(subnet, ignore_missing=False) + resource.wait_for_delete(self.conn.vpc, subnet, 2, 120) + + def test_list_subnets(self): + subnet = self._create_subnet() + + subnets = list(self.conn.vpc.subnets()) + self.assertGreaterEqual(len(subnets), 1) + + self.assertIn(subnet, subnets) + + def _create_subnet(self, remove=True): + cidr: str = self.vpc.cidr + gateway, _ = cidr.split("/") + gateway = gateway[:-2] + ".1" # .0 -> .1 + + attrs = { + 'vpc_id': self.vpc.id, + 'name': 'test-subnet-' + self.seed, + 'cidr': cidr, + 'gateway_ip': gateway, + 'dns_list': [ + "100.125.4.25", + "100.125.129.199", + ], + } + subnet = self.conn.vpc.create_subnet(**attrs) + resource.wait_for_status(self.conn.vpc, subnet, "ACTIVE", None, 2, 20) + + if remove: + self.addCleanup(self._delete_subnet, subnet) + return subnet + + def _delete_subnet(self, subnet): + resource.wait_for_status(self.conn.vpc, subnet, "ACTIVE", None, 2, 20) + self.conn.vpc.delete_subnet(subnet, ignore_missing=False) + resource.wait_for_delete(self.conn.vpc, subnet, 2, 60) diff --git a/otcextensions/tests/unit/sdk/vpc/v1/test_proxy.py b/otcextensions/tests/unit/sdk/vpc/v1/test_proxy.py index 84badfb39..a67a5bf96 100644 --- a/otcextensions/tests/unit/sdk/vpc/v1/test_proxy.py +++ b/otcextensions/tests/unit/sdk/vpc/v1/test_proxy.py @@ -9,13 +9,14 @@ # 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 openstack.tests.unit import test_proxy_base + from otcextensions.sdk.vpc.v1 import _proxy from otcextensions.sdk.vpc.v1 import peering from otcextensions.sdk.vpc.v1 import route +from otcextensions.sdk.vpc.v1 import subnet from otcextensions.sdk.vpc.v1 import vpc -from openstack.tests.unit import test_proxy_base - class TestVpcProxy(test_proxy_base.TestProxyBase): def setUp(self): @@ -30,8 +31,11 @@ def test_peering_create(self): expected_kwargs={'name': 'id'}) def test_peering_delete(self): - self.verify_delete(self.proxy.delete_peering, - peering.Peering, True) + self.verify_delete( + self.proxy.delete_peering, + peering.Peering, True, + mock_method='otcextensions.sdk.vpc.v1._proxy.Proxy._delete', + ) def test_peering_get(self): self.verify_get(self.proxy.get_peering, peering.Peering) @@ -59,8 +63,11 @@ def test_route_add(self): expected_kwargs={'name': 'id'}) def test_route_delete(self): - self.verify_delete(self.proxy.delete_route, - route.Route, True) + self.verify_delete( + self.proxy.delete_route, + route.Route, True, + mock_method='otcextensions.sdk.vpc.v1._proxy.Proxy._delete', + ) def test_route_get(self): self.verify_get(self.proxy.get_route, route.Route) @@ -79,10 +86,13 @@ def test_vpc_create(self): }) def test_vpc_delete(self): - self.verify_delete(self.proxy.delete_vpc, vpc.Vpc, True, - expected_kwargs={ - 'ignore_missing': True, - 'project_id': self.proxy.get_project_id()}) + self.verify_delete( + self.proxy.delete_vpc, vpc.Vpc, True, + mock_method='otcextensions.sdk.vpc.v1._proxy.Proxy._delete', + expected_kwargs={ + 'ignore_missing': True, + 'project_id': self.proxy.get_project_id() + }) def test_vpc_get(self): self.verify_get(self.proxy.get_vpc, vpc.Vpc, 'id', @@ -108,3 +118,49 @@ def test_vpc_find(self): expected_kwargs={ 'project_id': self.proxy.get_project_id() }) + + +class TestSubnet(TestVpcProxy): + def test_subnet_create(self): + self.verify_create(self.proxy.create_subnet, subnet.Subnet, + method_kwargs={'name': 'id'}, + expected_kwargs={ + 'name': 'id', + 'project_id': self.proxy.get_project_id() + }) + + def test_subnets(self): + self.verify_list(self.proxy.subnets, subnet.Subnet, + expected_kwargs={ + 'project_id': self.proxy.get_project_id() + }) + + def test_subnet_find(self): + self.verify_find(self.proxy.find_subnet, subnet.Subnet, 'id', + expected_kwargs={ + 'project_id': self.proxy.get_project_id() + }) + + def test_subnet_get(self): + self.verify_get(self.proxy.get_subnet, subnet.Subnet, 'id', + expected_kwargs={ + 'project_id': self.proxy.get_project_id(), + }) + + def test_subnet_update(self): + self.verify_update(self.proxy.update_subnet, subnet.Subnet, + method_kwargs={'vpc_id': 'vpc'}, + expected_kwargs={ + 'base_path': subnet.vpc_subnet_base_path('vpc'), + 'project_id': self.proxy.get_project_id(), + }) + + def test_subnet_delete(self): + self.verify_delete( + self.proxy.delete_subnet, subnet.Subnet, True, + mock_method='otcextensions.sdk.vpc.v1._proxy.Proxy._delete', + method_kwargs={'vpc_id': 'vpc'}, + expected_kwargs={ + 'ignore_missing': True, + 'base_path': subnet.vpc_subnet_base_path('vpc'), + }) diff --git a/otcextensions/tests/unit/sdk/vpc/v1/test_subnet.py b/otcextensions/tests/unit/sdk/vpc/v1/test_subnet.py new file mode 100644 index 000000000..0b5c7abb0 --- /dev/null +++ b/otcextensions/tests/unit/sdk/vpc/v1/test_subnet.py @@ -0,0 +1,84 @@ +# 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 unittest import mock + +from keystoneauth1 import adapter +from openstack.tests.unit import base + +from otcextensions.sdk.vpc.v1 import subnet + +IDENTIFIER = 'ID' +EXAMPLE = { + 'id': '4779ab1c-7c1a-44b1-a02e-93dfc361b32d', + 'name': 'subnet', + 'description': '', + 'cidr': '192.168.20.0/24', + 'dnsList': [ + '8.8.8.8', + '1.1.1.1' + ], + 'status': 'ACTIVE', + 'vpc_id': '3ec3b33f-ac1c-4630-ad1c-7dba1ed79d85', + 'gateway_ip': '192.168.20.1', + 'dhcp_enable': True, + 'primary_dns': '8.8.8.8', + 'secondary_dns': '1.1.1.1', + 'availability_zone': 'eu-de-01', + 'neutron_network_id': '4779ab1c-7c1a-44b1-a02e-93dfc361b32d', + 'neutron_subnet_id': '213cb9d-3122-2ac1-1a29-91ffc1231a12', + 'extra_dhcp_opts': [ + { + 'opt_value': '10.100.0.33,10.100.0.34', + 'opt_name': 'ntp' + } + ] +} + + +class TestVpc(base.TestCase): + + def setUp(self): + super(TestVpc, self).setUp() + self.sess = mock.Mock(spec=adapter.Adapter) + self.sess.put = mock.Mock() + + def test_basic(self): + sot = subnet.Subnet() + self.assertEqual('subnet', sot.resource_key) + self.assertEqual('subnets', sot.resources_key) + path = '/v1/%(project_id)s/subnets' + self.assertEqual(path, sot.base_path) + self.assertTrue(sot.allow_list) + self.assertTrue(sot.allow_create) + self.assertTrue(sot.allow_fetch) + self.assertTrue(sot.allow_delete) + self.assertTrue(sot.allow_commit) + + def test_make_it(self): + sot = subnet.Subnet(**EXAMPLE) + self.assertEqual(EXAMPLE['id'], sot.id) + self.assertEqual(EXAMPLE['name'], sot.name) + self.assertEqual(EXAMPLE['description'], sot.description) + self.assertEqual(EXAMPLE['cidr'], sot.cidr) + self.assertEqual(EXAMPLE['dnsList'], sot.dns_list) + self.assertEqual(EXAMPLE['status'], sot.status) + self.assertEqual(EXAMPLE['vpc_id'], sot.vpc_id) + self.assertEqual(EXAMPLE['gateway_ip'], sot.gateway_ip) + self.assertEqual(EXAMPLE['dhcp_enable'], sot.dhcp_enable) + self.assertEqual(EXAMPLE['primary_dns'], sot.primary_dns) + self.assertEqual(EXAMPLE['secondary_dns'], sot.secondary_dns) + self.assertEqual(EXAMPLE['availability_zone'], sot.availability_zone) + self.assertEqual(EXAMPLE['neutron_network_id'], sot.neutron_network_id) + self.assertEqual(EXAMPLE['neutron_subnet_id'], sot.neutron_subnet_id) + + self.assertDictEqual(EXAMPLE['extra_dhcp_opts'][0], + sot.extra_dhcp_opts[0].to_dict(ignore_none=True)) diff --git a/releasenotes/notes/vpc-subnet-cb292afbc16d2266.yaml b/releasenotes/notes/vpc-subnet-cb292afbc16d2266.yaml new file mode 100644 index 000000000..5d2ddc10b --- /dev/null +++ b/releasenotes/notes/vpc-subnet-cb292afbc16d2266.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Add VPC Subnet resource implementation