[Designate] Adding few more designate tests
Provider vxlan, vlan and reverse lookup tests Change-Id: Id421fe8bb0f448417394a3d56a5ea675cc5ada9d
This commit is contained in:
parent
e31ac9e179
commit
f5bbbd368f
|
@ -79,6 +79,8 @@ REJECT = "REJECT"
|
|||
AUDIT_WAIT_TIME = 300
|
||||
# ZONE Designate
|
||||
ZONE_WAIT_TIME = 120
|
||||
REGION_NAME = "RegionOne"
|
||||
ZONE_NAME = 'tempest-dns-network.com.'
|
||||
# VPN
|
||||
PEER_ID = "172.24.4.12"
|
||||
PFS = "group14"
|
||||
|
|
|
@ -193,6 +193,40 @@ class ApplianceManager(manager.NetworkScenarioTest):
|
|||
self.topology_networks[network_name] = network
|
||||
return network
|
||||
|
||||
def create_provider_network(self, net_type,
|
||||
zone_name,
|
||||
admin_state_up=True,
|
||||
tz_id=None,
|
||||
vlan_id_unique=None):
|
||||
networks_client = self.cmgr_adm.networks_client
|
||||
if net_type == constants.VXLAN_TYPE:
|
||||
name = "provider_network_vxlan"
|
||||
body = {"provider:physical_network": tz_id,
|
||||
"provider:network_type": net_type,
|
||||
"admin_state_up": admin_state_up,
|
||||
"dns_domain": zone_name}
|
||||
elif net_type == constants.VLAN_TYPE:
|
||||
name = "provider_network_vlan"
|
||||
if vlan_id_unique is not None:
|
||||
vlan_id_no = vlan_id_unique
|
||||
else:
|
||||
vlan_id_no = constants.VLAN
|
||||
if tz_id is None:
|
||||
body = {"provider:segmentation_id": vlan_id_no,
|
||||
"provider:network_type": net_type,
|
||||
"admin_state_up": admin_state_up,
|
||||
"dns_domain": zone_name}
|
||||
else:
|
||||
body = {"provider:segmentation_id": vlan_id_no,
|
||||
"provider:network_type": net_type,
|
||||
"provider:physical_network": tz_id,
|
||||
"admin_state_up": admin_state_up,
|
||||
"dns_domain": zone_name}
|
||||
network = self.create_topology_network(name,
|
||||
networks_client=networks_client,
|
||||
**body)
|
||||
return network
|
||||
|
||||
def update_topology_network(
|
||||
self, network_id, networks_client=None, **update_kwargs):
|
||||
if not networks_client:
|
||||
|
|
|
@ -124,6 +124,12 @@ class FeatureManager(traffic_manager.IperfManager,
|
|||
net_client.region,
|
||||
net_client.endpoint_type,
|
||||
**_params)
|
||||
cls.ptr_client = openstack_network_clients.DesignatePtrClient(
|
||||
net_client.auth_provider,
|
||||
net_client.service,
|
||||
net_client.region,
|
||||
net_client.endpoint_type,
|
||||
**_params)
|
||||
|
||||
#
|
||||
# FwaasV2 base class
|
||||
|
@ -944,14 +950,12 @@ class FeatureManager(traffic_manager.IperfManager,
|
|||
return email_id
|
||||
|
||||
def create_zone(self, name=None, email=None, description=None,
|
||||
wait_until=False):
|
||||
wait_until=False, tenant_id=None):
|
||||
"""Create a zone with the specified parameters.
|
||||
:param name: The name of the zone.
|
||||
Default: Random Value
|
||||
:param email: The email for the zone.
|
||||
Default: Random Value
|
||||
:param ttl: The ttl for the zone.
|
||||
Default: Random Value
|
||||
:param description: A description of the zone.
|
||||
Default: Random Value
|
||||
:param wait_until: Block until the zone reaches the desired status
|
||||
|
@ -967,7 +971,6 @@ class FeatureManager(traffic_manager.IperfManager,
|
|||
_, body = self.zones_v2_client.create_zone(wait_until, **zone)
|
||||
self.addCleanup(test_utils.call_and_ignore_notfound_exc,
|
||||
self.delete_zone, body['id'])
|
||||
# Create Zone should Return a HTTP 202
|
||||
return body
|
||||
|
||||
def delete_zone(self, uuid):
|
||||
|
@ -991,30 +994,18 @@ class FeatureManager(traffic_manager.IperfManager,
|
|||
"""
|
||||
return self.zones_v2_client.list_zones()
|
||||
|
||||
def update_zone(self, uuid, email=None, ttl=None,
|
||||
description=None, wait_until=False):
|
||||
"""Update a zone with the specified parameters.
|
||||
:param uuid: The unique identifier of the zone.
|
||||
:param email: The email for the zone.
|
||||
Default: Random Value
|
||||
:param ttl: The ttl for the zone.
|
||||
Default: Random Value
|
||||
:param description: A description of the zone.
|
||||
Default: Random Value
|
||||
:param wait_until: Block until the zone reaches the desiered status
|
||||
:return: A tuple with the server response and the updated zone.
|
||||
"""
|
||||
zone = {
|
||||
'email': email or self.rand_email(),
|
||||
'ttl': ttl or self.rand_ttl(),
|
||||
'description': description or self.rand_name('test-zone'),
|
||||
}
|
||||
_, body = self.zones_v2_client.update_zone(uuid, wait_until, **zone)
|
||||
return body
|
||||
|
||||
def list_record_set_zone(self, uuid):
|
||||
def list_record_set_zone(self, uuid, user=None):
|
||||
"""list recordsets of a zone.
|
||||
:param uuid: The unique identifier of the zone.
|
||||
"""
|
||||
body = self.zones_v2_client.list_recordset_zone(uuid)
|
||||
self.assertGreater(len(body), 0)
|
||||
return body
|
||||
|
||||
def show_ptr_record(self, region, fip_id, user=None):
|
||||
"""list ptr recordsets associated with floating ip.
|
||||
:param fip_id: Unique FloatingIP ID.
|
||||
"""
|
||||
ptr_id = region + ":" + fip_id
|
||||
body = self.ptr_client.show_ptr_record(ptr_id)
|
||||
return body
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
# 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
|
||||
|
||||
from tempest import config
|
||||
|
@ -475,3 +476,17 @@ class ZonesV2Client(designate_base.DnsClientBase):
|
|||
request = self.resource_base_path + '/' + zone_id + '/recordsets'
|
||||
resp, body = self._list_request(request)
|
||||
return resp, body
|
||||
|
||||
|
||||
class DesignatePtrClient(designate_base.DnsClientBase):
|
||||
"""
|
||||
Request resources via API for Designate PTR RecordSet Client
|
||||
PTR recordset show request
|
||||
"""
|
||||
path = "v2/reverse/floatingips/"
|
||||
|
||||
def show_ptr_record(self, ptr_id):
|
||||
"""
|
||||
Show FloatingIP PTR record
|
||||
"""
|
||||
return self._show_request(self.path, ptr_id)
|
||||
|
|
|
@ -1,107 +0,0 @@
|
|||
# Copyright 2017 VMware, Inc.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from oslo_log import log as logging
|
||||
|
||||
from tempest import config
|
||||
from tempest.lib.common.utils import data_utils
|
||||
from tempest.lib import decorators
|
||||
|
||||
from vmware_nsx_tempest_plugin.lib import feature_manager
|
||||
|
||||
|
||||
CONF = config.CONF
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TestZonesV2Ops(feature_manager.FeatureManager):
|
||||
|
||||
@classmethod
|
||||
def skip_checks(cls):
|
||||
super(TestZonesV2Ops, cls).skip_checks()
|
||||
|
||||
@classmethod
|
||||
def setup_credentials(cls):
|
||||
cls.set_network_resources()
|
||||
cls.admin_mgr = cls.get_client_manager('admin')
|
||||
super(TestZonesV2Ops, cls).setup_credentials()
|
||||
|
||||
@classmethod
|
||||
def setup_clients(cls):
|
||||
"""
|
||||
Create various client connections. Such as NSX.
|
||||
"""
|
||||
super(TestZonesV2Ops, cls).setup_clients()
|
||||
|
||||
|
||||
class TestZones(TestZonesV2Ops):
|
||||
|
||||
excluded_keys = ['created_at', 'updated_at', 'version', 'links',
|
||||
'status', 'action']
|
||||
|
||||
@decorators.idempotent_id('e26cf8c6-164d-4097-b066-4e2100382d53')
|
||||
def test_create_zone(self):
|
||||
"""Creating a v2 Zone"""
|
||||
LOG.info('Create a zone')
|
||||
zone = self.create_zone(wait_until=True)
|
||||
LOG.info('Ensure we respond with CREATE+PENDING')
|
||||
self.assertEqual('CREATE', zone['action'])
|
||||
self.assertEqual('PENDING', zone['status'])
|
||||
|
||||
@decorators.idempotent_id('76586e1f-7466-4dd1-bcdf-b6805c63731c')
|
||||
def test_delete_zone(self):
|
||||
LOG.info('Create a zone')
|
||||
zone = self.create_zone()
|
||||
LOG.info('Delete the zone')
|
||||
body = self.delete_zone(zone['id'])
|
||||
LOG.info('Ensure we respond with DELETE+PENDING')
|
||||
self.assertEqual('DELETE', body['action'])
|
||||
self.assertEqual('PENDING', body['status'])
|
||||
|
||||
@decorators.idempotent_id('3fa18ce7-ac47-425f-a1d1-2baa5ead0ed1')
|
||||
def test_show_zone(self):
|
||||
LOG.info('Create a zone')
|
||||
zone = self.create_zone()
|
||||
LOG.info('Fetch the zone')
|
||||
body = self.show_zone(zone['id'])
|
||||
LOG.info('Ensure the fetched response matches the created zone')
|
||||
self.assertEqual(zone['links'], body[1]['links'])
|
||||
self.assertEqual(zone['name'], body[1]['name'])
|
||||
self.assertEqual(zone['email'], body[1]['email'])
|
||||
self.assertEqual(zone['ttl'], body[1]['ttl'])
|
||||
|
||||
@decorators.idempotent_id('7e35c62c-5baf-4d32-b3e8-59e76ea6571f')
|
||||
def test_list_zones(self):
|
||||
LOG.info('Create a zone')
|
||||
self.create_zone()
|
||||
LOG.info('List zones')
|
||||
body = self.list_zones()
|
||||
self.assertGreater(len(body[1]['zones']), 0)
|
||||
|
||||
@decorators.idempotent_id('55ca3fc8-6652-4f00-9af8-c01ea5bae5a0')
|
||||
def test_update_zone(self):
|
||||
LOG.info('Create a zone')
|
||||
zone = self.create_zone()
|
||||
# Generate a random description
|
||||
description = data_utils.rand_name()
|
||||
LOG.info('Update the zone')
|
||||
zone = self.update_zone(
|
||||
zone['id'], email=zone['email'], ttl=zone['ttl'],
|
||||
description=description, wait_until=True)
|
||||
LOG.info('Ensure we respond with UPDATE+PENDING')
|
||||
self.assertEqual('UPDATE', zone['action'])
|
||||
self.assertEqual('PENDING', zone['status'])
|
||||
LOG.info('Ensure we respond with updated values')
|
||||
self.assertEqual(description, zone['description'])
|
|
@ -24,6 +24,7 @@ from tempest.lib import exceptions as lib_exc
|
|||
|
||||
from vmware_nsx_tempest_plugin.common import constants as const
|
||||
from vmware_nsx_tempest_plugin.lib import feature_manager
|
||||
from vmware_nsx_tempest_plugin.services import nsxv3_client
|
||||
|
||||
|
||||
CONF = config.CONF
|
||||
|
@ -45,6 +46,25 @@ class TestZonesV2Ops(feature_manager.FeatureManager):
|
|||
super(TestZonesV2Ops, cls).setup_clients()
|
||||
cls.cmgr_adm = cls.get_client_manager('admin')
|
||||
|
||||
@classmethod
|
||||
def resource_setup(cls):
|
||||
super(TestZonesV2Ops, cls).resource_setup()
|
||||
cls.nsx = nsxv3_client.NSXV3Client(CONF.nsxv3.nsx_manager,
|
||||
CONF.nsxv3.nsx_user,
|
||||
CONF.nsxv3.nsx_password)
|
||||
out = cls.nsx.get_transport_zones()
|
||||
vlan_flag = 0
|
||||
vxlan_flag = 0
|
||||
for tz in out:
|
||||
if "transport_type" in tz.keys() and (vlan_flag == 0
|
||||
or vxlan_flag == 0):
|
||||
if vxlan_flag == 0 and tz['transport_type'] == "OVERLAY":
|
||||
cls.overlay_id = tz['id']
|
||||
vxlan_flag = 1
|
||||
if vlan_flag == 0 and tz['transport_type'] == "VLAN":
|
||||
cls.vlan_id = tz['id']
|
||||
vlan_flag = 1
|
||||
|
||||
def define_security_groups(self, tenant_id):
|
||||
sec_rule_client = self.os_admin.security_group_rules_client
|
||||
sec_client = self.os_admin.security_groups_client
|
||||
|
@ -62,13 +82,28 @@ class TestZonesV2Ops(feature_manager.FeatureManager):
|
|||
ruleclient=sec_rule_client, secclient=sec_client,
|
||||
tenant_id=tenant_id)
|
||||
|
||||
def create_designate_zone(self):
|
||||
LOG.info('Create a zone')
|
||||
zone = self.create_zone(wait_until=True)
|
||||
LOG.info('Ensure we respond with CREATE+PENDING')
|
||||
self.assertEqual('CREATE', zone['action'])
|
||||
self.assertEqual('PENDING', zone['status'])
|
||||
return zone
|
||||
def create_zone_provider_vlan_vxlan_topology(self, network_type,
|
||||
zone_name):
|
||||
if network_type == 'vlan':
|
||||
network_designate = self.create_provider_network(
|
||||
const.VLAN_TYPE,
|
||||
zone_name,
|
||||
tz_id=self.vlan_id)
|
||||
elif network_type == 'vxlan':
|
||||
network_designate = self.create_provider_network(
|
||||
const.VXLAN_TYPE,
|
||||
zone_name,
|
||||
tz_id=self.overlay_id)
|
||||
tenant_id = network_designate['tenant_id']
|
||||
self.define_security_groups(tenant_id)
|
||||
subnet_client = self.os_adm.subnets_client
|
||||
routers_client = self.os_adm.routers_client
|
||||
router_designate = self.create_topology_router("router_designate",
|
||||
routers_client=routers_client)
|
||||
self.create_topology_subnet("subnet_designate",
|
||||
network_designate, subnets_client=subnet_client,
|
||||
routers_client=routers_client, router_id=router_designate['id'])
|
||||
return network_designate
|
||||
|
||||
def create_zone_topology(self, zone_name):
|
||||
networks_client = self.cmgr_adm.networks_client
|
||||
|
@ -129,7 +164,7 @@ class TestZonesScenario(TestZonesV2Ops):
|
|||
Verify recordset only has SOA and NS record types
|
||||
"""
|
||||
image_id = self.get_glance_image_id(['cirros', 'esx'])
|
||||
zone = self.create_designate_zone()
|
||||
zone = self.create_zone()
|
||||
network_designate = self.create_zone_topology(zone['name'])
|
||||
self.assertEqual(network_designate['dns_domain'], zone['name'])
|
||||
LOG.info('Show recordset of the zone')
|
||||
|
@ -144,6 +179,58 @@ class TestZonesScenario(TestZonesV2Ops):
|
|||
recordset = self.list_record_set_zone(zone['id'])
|
||||
self.verify_recordset(recordset, 2)
|
||||
|
||||
@decorators.idempotent_id('6cb8ce24-f19f-466d-9386-ae0d45ed518f')
|
||||
def test_zone_list_without_fip_instance_provider_vxlan(self):
|
||||
"""
|
||||
Create a zone, check zone exits
|
||||
Create a network and subnet
|
||||
Update network with the zone
|
||||
Boot a VM
|
||||
Verify recordset only has SOA and NS record types
|
||||
"""
|
||||
image_id = self.get_glance_image_id(['cirros', 'esx'])
|
||||
zone = self.create_zone()
|
||||
network_designate = self.create_zone_provider_vlan_vxlan_topology(
|
||||
'vxlan', zone['name'])
|
||||
self.assertEqual(network_designate['dns_domain'], zone['name'])
|
||||
LOG.info('Show recordset of the zone')
|
||||
recordset = self.list_record_set_zone(zone['id'])
|
||||
self.verify_recordset(recordset, 2)
|
||||
self.create_topology_instance(
|
||||
"dns_vm", [network_designate],
|
||||
security_groups=[{'name': self.designate_sg['name']}],
|
||||
clients=self.os_adm,
|
||||
create_floating_ip=False, image_id=image_id)
|
||||
LOG.info('Show recordset of the zone')
|
||||
recordset = self.list_record_set_zone(zone['id'])
|
||||
self.verify_recordset(recordset, 3)
|
||||
|
||||
@decorators.idempotent_id('35ad8341-e96a-49ba-8463-54465051c7a4')
|
||||
def test_zone_list_without_fip_instance_provider_vlan(self):
|
||||
"""
|
||||
Create a zone, check zone exits
|
||||
Create a network and subnet
|
||||
Update network with the zone
|
||||
Boot a VM
|
||||
Verify recordset only has SOA and NS record types
|
||||
"""
|
||||
image_id = self.get_glance_image_id(['cirros', 'esx'])
|
||||
zone = self.create_zone()
|
||||
network_designate = self.create_zone_provider_vlan_vxlan_topology(
|
||||
'vxlan', zone['name'])
|
||||
self.assertEqual(network_designate['dns_domain'], zone['name'])
|
||||
LOG.info('Show recordset of the zone')
|
||||
recordset = self.list_record_set_zone(zone['id'])
|
||||
self.verify_recordset(recordset, 2)
|
||||
self.create_topology_instance(
|
||||
"dns_vm", [network_designate],
|
||||
security_groups=[{'name': self.designate_sg['name']}],
|
||||
clients=self.os_adm,
|
||||
create_floating_ip=False, image_id=image_id)
|
||||
LOG.info('Show recordset of the zone')
|
||||
recordset = self.list_record_set_zone(zone['id'])
|
||||
self.verify_recordset(recordset, 3)
|
||||
|
||||
@decorators.idempotent_id('a4de3cca-54e1-4e8b-8b52-2148e55eed84')
|
||||
def test_zone_list_with_fip_instance(self):
|
||||
"""
|
||||
|
@ -217,7 +304,7 @@ class TestZonesScenario(TestZonesV2Ops):
|
|||
Create a port
|
||||
Verify zone record set has SOA and NS record typres
|
||||
"""
|
||||
zone = self.create_designate_zone()
|
||||
zone = self.create_zone()
|
||||
network_designate = self.create_zone_topology(zone['name'])
|
||||
self.assertEqual(network_designate['dns_domain'], zone['name'])
|
||||
LOG.info('Show recordset of the zone')
|
||||
|
@ -298,6 +385,51 @@ class TestZonesScenario(TestZonesV2Ops):
|
|||
raise Exception('DNS response does not have entry '
|
||||
'for the instance')
|
||||
|
||||
@decorators.idempotent_id('2c3c0f63-c557-458f-a8f4-3b0e3065ed97')
|
||||
def test_zone_reverse_nslookup_from_extvm(self):
|
||||
"""
|
||||
Create a zone
|
||||
Update network with zone
|
||||
Boot an instance and associate fip
|
||||
Perform nslookup for the dns name from ext vm
|
||||
"""
|
||||
image_id = self.get_glance_image_id(['cirros', 'esx'])
|
||||
zone = self.create_zone()
|
||||
network_designate = self.create_zone_topology(zone['name'])
|
||||
self.assertEqual(network_designate['dns_domain'], zone['name'])
|
||||
dns_vm = self.create_topology_instance(
|
||||
"dns_vm", [network_designate],
|
||||
security_groups=[{'name': self.designate_sg['name']}],
|
||||
clients=self.os_adm,
|
||||
create_floating_ip=True, image_id=image_id)
|
||||
fip = dns_vm['floating_ips'][0]['floating_ip_address']
|
||||
fip_id = dns_vm['floating_ips'][0]['id']
|
||||
ptr_rev_name = '.'.join(reversed(fip.split("."))) + ".in-addr.arpa"
|
||||
LOG.info('Show recordset of the zone')
|
||||
recordset = self.list_record_set_zone(zone['id'])
|
||||
self.verify_recordset(recordset, 3)
|
||||
record = self.verify_recordset_floatingip(recordset, fip)
|
||||
if record is None:
|
||||
raise Exception('fip is missing in the recordset')
|
||||
my_resolver = dns.resolver.Resolver()
|
||||
nameserver = CONF.dns.nameservers[0][:-3]
|
||||
my_resolver.nameservers = [nameserver]
|
||||
#wait for status to change from pending to active
|
||||
time.sleep(const.ZONE_WAIT_TIME)
|
||||
region = const.REGION_NAME
|
||||
#check PTR record
|
||||
ptr_record = self.show_ptr_record(region, fip_id)
|
||||
self.assertEqual(fip, ptr_record[1]['address'])
|
||||
try:
|
||||
answer = my_resolver.query(ptr_rev_name, "PTR")
|
||||
except Exception:
|
||||
LOG.error('ns lookup failed on ext-vm')
|
||||
if (ptr_rev_name not in answer.response.to_text()
|
||||
or record['name'] not in answer.response.to_text()):
|
||||
LOG.error('failed to perform reverse dns for the instance')
|
||||
raise Exception('Reverse DNS response does not have entry '
|
||||
'for the instance')
|
||||
|
||||
@decorators.idempotent_id('6286cbd5-b0e4-4daa-9d8f-f27802c95925')
|
||||
def test_zone_deletion_post_fip_association(self):
|
||||
"""
|
||||
|
@ -306,7 +438,10 @@ class TestZonesScenario(TestZonesV2Ops):
|
|||
Boot an instance and associate fip
|
||||
Delete zone successfully
|
||||
"""
|
||||
image_id = self.get_glance_image_id(['cirros', 'esx'])
|
||||
try:
|
||||
image_id = self.get_glance_image_id(['cirros', 'esx'])
|
||||
except Exception:
|
||||
LOG.error('cirros image is absent for esx HV')
|
||||
zone = self.create_zone()
|
||||
network_designate = self.create_zone_topology(zone['name'])
|
||||
self.assertEqual(network_designate['dns_domain'], zone['name'])
|
||||
|
@ -331,3 +466,26 @@ class TestZonesScenario(TestZonesV2Ops):
|
|||
time.sleep(const.ZONE_WAIT_TIME)
|
||||
self.assertRaises(lib_exc.NotFound, self.delete_zone,
|
||||
zone['id'])
|
||||
|
||||
@decorators.idempotent_id('6963e17e-9404-4397-8738-cf0190ab2a66')
|
||||
def test_negative_dns_network_update(self):
|
||||
"""
|
||||
Create a zone
|
||||
Update network with different dns name
|
||||
Boot an instance and associate fip
|
||||
Verify the recordset of the guestVM does not contain
|
||||
'A' record type
|
||||
"""
|
||||
image_id = self.get_glance_image_id(['cirros', 'esx'])
|
||||
zone = self.create_zone()
|
||||
network_designate = self.create_zone_topology(
|
||||
const.ZONE_NAME)
|
||||
self.assertNotEqual(network_designate['dns_domain'], zone['name'])
|
||||
self.create_topology_instance(
|
||||
"dns_vm", [network_designate],
|
||||
security_groups=[{'name': self.designate_sg['name']}],
|
||||
clients=self.os_adm,
|
||||
create_floating_ip=True, image_id=image_id)
|
||||
LOG.info('Show recordset of the zone')
|
||||
recordset = self.list_record_set_zone(zone['id'])
|
||||
self.verify_recordset(recordset, 2)
|
||||
|
|
Loading…
Reference in New Issue