Add magnum certificate api tests
This patch will add cert API test for signing and showing certificates. The tests for certificates are integrated into test_bay tests in order to reuse an already existing bay for cert testing. As a side effect, this patch also combines update bay test with create, list, and delete to minimize the time spent waiting on bay create. Implements: blueprint magnum-tempest Change-Id: Ifbb4c779376fa401ca2538aba5097f7af8b4973e
This commit is contained in:
parent
ad21348330
commit
6ccda1ad10
|
@ -59,6 +59,7 @@ keypair_id = default
|
|||
flavor_id = m1.magnum2
|
||||
master_flavor_id = m1.magnum
|
||||
copy_logs = true
|
||||
csr_location = $MAGNUM_DIR/default.csr
|
||||
EOF
|
||||
|
||||
# Note(eliqiao): Let's keep this only for debugging on gate.
|
||||
|
@ -70,6 +71,40 @@ EOF
|
|||
ssh-keygen -t rsa -N "" -f ~/.ssh/id_rsa
|
||||
nova keypair-add --pub-key ~/.ssh/id_rsa.pub default
|
||||
|
||||
# create a valid sample csr
|
||||
export CSR_FILE=$MAGNUM_DIR/default.csr
|
||||
cat <<EOF > $CSR_FILE
|
||||
-----BEGIN CERTIFICATE REQUEST-----
|
||||
MIIByjCCATMCAQAwgYkxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlh
|
||||
MRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRMwEQYDVQQKEwpHb29nbGUgSW5jMR8w
|
||||
HQYDVQQLExZJbmZvcm1hdGlvbiBUZWNobm9sb2d5MRcwFQYDVQQDEw53d3cuZ29v
|
||||
Z2xlLmNvbTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEApZtYJCHJ4VpVXHfV
|
||||
IlstQTlO4qC03hjX+ZkPyvdYd1Q4+qbAeTwXmCUKYHThVRd5aXSqlPzyIBwieMZr
|
||||
WFlRQddZ1IzXAlVRDWwAo60KecqeAXnnUK+5fXoTI/UgWshre8tJ+x/TMHaQKR/J
|
||||
cIWPhqaQhsJuzZbvAdGA80BLxdMCAwEAAaAAMA0GCSqGSIb3DQEBBQUAA4GBAIhl
|
||||
4PvFq+e7ipARgI5ZM+GZx6mpCz44DTo0JkwfRDf+BtrsaC0q68eTf2XhYOsq4fkH
|
||||
Q0uA0aVog3f5iJxCa3Hp5gxbJQ6zV6kJ0TEsuaaOhEko9sdpCoPOnRBm2i/XRD2D
|
||||
6iNh8f8z0ShGsFqjDgFHyF3o+lUyj+UC6H1QW7bn
|
||||
-----END CERTIFICATE REQUEST-----
|
||||
EOF
|
||||
|
||||
# create an ivalid sample csr
|
||||
export INVALID_CSR_FILE=$MAGNUM_DIR/invalid.csr
|
||||
cat <<EOF > $INVALID_CSR_FILE
|
||||
-----BEGIN CERTIFICATE REQUEST-----
|
||||
FAKERFAKERyjCCATMCAQAwgYkxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlh
|
||||
MRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRMwEQYDVQQKEwpHb29nbGUgSW5jMR8w
|
||||
HQYDVQQLExZJbmZvcm1hdGlvbiBUZWNobm9sb2d5MRcwFQYDVQQDEw53d3cuZ29v
|
||||
Z2xlLmNvbTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEApZtYJCHJ4VpVXHfV
|
||||
IlstQTlO4qC03hjX+ZkPyvdYd1Q4+qbAeTwXmCUKYHThVRd5aXSqlPzyIBwieMZr
|
||||
WFlRQddZ1IzXAlVRDWwAo60KecqeAXnnUK+5fXoTI/UgWshre8tJ+x/TMHaQKR/J
|
||||
cIWPhqaQhsJuzZbvAdGA80BLxdMCAwEAAaAAMA0GCSqGSIb3DQEBBQUAA4GBAIhl
|
||||
4PvFq+e7ipARgI5ZM+GZx6mpCz44DTo0JkwfRDf+BtrsaC0q68eTf2XhYOsq4fkH
|
||||
Q0uA0aVog3f5iJxCa3Hp5gxbJQ6zV6kJ0TEsuaaOhEko9sdpCoPOnRBm2i/XRD2D
|
||||
6iNh8f8z0ShGsFqjDgFHyF3o+lUyj+UC6H1QW7bn
|
||||
-----END CERTIFICATE REQUEST-----
|
||||
EOF
|
||||
|
||||
}
|
||||
|
||||
function add_flavor {
|
||||
|
@ -149,6 +184,7 @@ if [[ "api" == "$coe" ]]; then
|
|||
iniset $BASE/new/tempest/etc/tempest.conf magnum keypair_id default
|
||||
iniset $BASE/new/tempest/etc/tempest.conf magnum flavor_id m1.magnum2
|
||||
iniset $BASE/new/tempest/etc/tempest.conf magnum master_flavor_id m1.magnum
|
||||
iniset $BASE/new/tempest/etc/tempest.conf magnum csr_location $CSR_FILE
|
||||
|
||||
# show tempest config with magnum
|
||||
cat etc/tempest.conf
|
||||
|
|
|
@ -111,16 +111,32 @@ class BayClient(client.MagnumClient):
|
|||
utils.wait_for_condition(
|
||||
lambda: self.does_bay_not_exist(bay_id), 10, 3600)
|
||||
|
||||
def wait_for_created_bay(self, bay_id):
|
||||
def wait_for_created_bay(self, bay_id, delete_on_error=True):
|
||||
try:
|
||||
utils.wait_for_condition(
|
||||
lambda: self.does_bay_exist(bay_id), 10, 3600)
|
||||
except Exception:
|
||||
# In error state. Clean up the bay id
|
||||
self.delete_bay(bay_id)
|
||||
self.wait_for_bay_to_delete(bay_id)
|
||||
# In error state. Clean up the bay id if desired
|
||||
if delete_on_error:
|
||||
self.delete_bay(bay_id)
|
||||
self.wait_for_bay_to_delete(bay_id)
|
||||
raise
|
||||
|
||||
def wait_for_final_state(self, bay_id):
|
||||
utils.wait_for_condition(
|
||||
lambda: self.is_bay_in_final_state(bay_id), 10, 3600)
|
||||
|
||||
def is_bay_in_final_state(self, bay_id):
|
||||
try:
|
||||
resp, model = self.get_bay(bay_id)
|
||||
if model.status in ['CREATED', 'CREATE_COMPLETE',
|
||||
'ERROR', 'CREATE_FAILED']:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except exceptions.NotFound:
|
||||
return False
|
||||
|
||||
def does_bay_exist(self, bay_id):
|
||||
try:
|
||||
resp, model = self.get_bay(bay_id)
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
# 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 magnum.tests.functional.api.v1.models import cert_model
|
||||
from magnum.tests.functional.common import client
|
||||
|
||||
|
||||
class CertClient(client.MagnumClient):
|
||||
"""Encapsulates REST calls and maps JSON to/from models"""
|
||||
|
||||
url = "/certificates"
|
||||
|
||||
@classmethod
|
||||
def cert_uri(cls, bay_id):
|
||||
"""Construct bay uri
|
||||
|
||||
:param bay_id: bay uuid or name
|
||||
:returns: url string
|
||||
"""
|
||||
|
||||
return "{0}/{1}".format(cls.url, bay_id)
|
||||
|
||||
def get_cert(self, bay_id, **kwargs):
|
||||
"""Makes GET /certificates/bay_id request and returns CertEntity
|
||||
|
||||
Abstracts REST call to return a single cert based on uuid or name
|
||||
|
||||
:param bay_id: bay uuid or name
|
||||
:returns: response object and BayCollection object
|
||||
"""
|
||||
|
||||
resp, body = self.get(self.cert_uri(bay_id))
|
||||
return self.deserialize(resp, body, cert_model.CertEntity)
|
||||
|
||||
def post_cert(self, model, **kwargs):
|
||||
"""Makes POST /certificates request and returns CertEntity
|
||||
|
||||
Abstracts REST call to sign new certificate
|
||||
|
||||
:param model: CertEntity
|
||||
:returns: response object and CertEntity object
|
||||
"""
|
||||
|
||||
resp, body = self.post(
|
||||
CertClient.url,
|
||||
body=model.to_json(), **kwargs)
|
||||
return self.deserialize(resp, body, cert_model.CertEntity)
|
|
@ -0,0 +1,24 @@
|
|||
# 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 magnum.tests.functional.common import models
|
||||
|
||||
|
||||
class CertData(models.BaseModel):
|
||||
"""Data that encapsulates cert attributes"""
|
||||
pass
|
||||
|
||||
|
||||
class CertEntity(models.EntityModel):
|
||||
"""Entity Model that represents a single instance of CertData"""
|
||||
ENTITY_NAME = 'certificate'
|
||||
MODEL_TYPE = CertData
|
|
@ -35,6 +35,7 @@ class BayTest(base.BaseMagnumTest):
|
|||
self.baymodel_client = None
|
||||
self.keypairs_client = None
|
||||
self.bay_client = None
|
||||
self.cert_client = None
|
||||
|
||||
def setUp(self):
|
||||
try:
|
||||
|
@ -49,6 +50,10 @@ class BayTest(base.BaseMagnumTest):
|
|||
creds=self.credentials,
|
||||
type_of_creds='default',
|
||||
request_type='bay')
|
||||
(self.cert_client, _) = self.get_clients_with_existing_creds(
|
||||
creds=self.credentials,
|
||||
type_of_creds='default',
|
||||
request_type='cert')
|
||||
model = datagen.valid_swarm_baymodel()
|
||||
_, self.baymodel = self._create_baymodel(model)
|
||||
|
||||
|
@ -108,12 +113,25 @@ class BayTest(base.BaseMagnumTest):
|
|||
def test_create_list_and_delete_bays(self):
|
||||
gen_model = datagen.valid_bay_data(
|
||||
baymodel_id=self.baymodel.uuid, node_count=1)
|
||||
|
||||
# test bay create
|
||||
_, temp_model = self._create_bay(gen_model)
|
||||
|
||||
# test bay list
|
||||
resp, model = self.bay_client.list_bays()
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertGreater(len(model.bays), 0)
|
||||
self.assertIn(
|
||||
temp_model.uuid, list([x['uuid'] for x in model.bays]))
|
||||
|
||||
# test invalid bay update
|
||||
patch_model = datagen.bay_name_patch_data()
|
||||
self.assertRaises(
|
||||
exceptions.BadRequest,
|
||||
self.bay_client.patch_bay,
|
||||
temp_model.uuid, patch_model)
|
||||
|
||||
# test bay delete
|
||||
self._delete_bay(temp_model.uuid)
|
||||
self.bays.remove(temp_model.uuid)
|
||||
|
||||
|
@ -153,3 +171,39 @@ class BayTest(base.BaseMagnumTest):
|
|||
self.assertRaises(
|
||||
exceptions.NotFound,
|
||||
self.bay_client.delete_bay, data_utils.rand_uuid())
|
||||
|
||||
@testtools.testcase.attr('positive')
|
||||
def test_certificate_sign_and_show(self):
|
||||
first_model = datagen.valid_bay_data(baymodel_id=self.baymodel.uuid,
|
||||
name='test')
|
||||
_, bay_model = self._create_bay(first_model)
|
||||
|
||||
# test ca show
|
||||
resp, model = self.cert_client.get_cert(
|
||||
bay_model.uuid)
|
||||
self.LOG.info("cert resp: %s" % resp)
|
||||
self.LOG.info("cert model: %s" % model)
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(model.bay_uuid, bay_model.uuid)
|
||||
self.assertIsNotNone(model.pem)
|
||||
self.assertIn('-----BEGIN CERTIFICATE-----', model.pem)
|
||||
self.assertIn('-----END CERTIFICATE-----', model.pem)
|
||||
|
||||
# test ca sign
|
||||
model = datagen.cert_data(bay_uuid=bay_model.uuid)
|
||||
resp, model = self.cert_client.post_cert(model)
|
||||
self.LOG.info("cert resp: %s" % resp)
|
||||
self.LOG.info("cert model: %s" % model)
|
||||
self.assertEqual(resp.status, 201)
|
||||
self.assertEqual(model.bay_uuid, bay_model.uuid)
|
||||
self.assertIsNotNone(model.pem)
|
||||
self.assertIn('-----BEGIN CERTIFICATE-----', model.pem)
|
||||
self.assertIn('-----END CERTIFICATE-----', model.pem)
|
||||
|
||||
# test ca sign invalid
|
||||
model = datagen.cert_data(bay_uuid=bay_model.uuid,
|
||||
csr_data="invalid_path")
|
||||
self.assertRaises(
|
||||
exceptions.ServerFault,
|
||||
self.cert_client.post_cert,
|
||||
model)
|
||||
|
|
|
@ -39,7 +39,8 @@ class MagnumServiceTest(base.BaseMagnumTest):
|
|||
# get json object
|
||||
(self.service_client, _) = self.get_clients_with_new_creds(
|
||||
type_of_creds='admin',
|
||||
request_type='service')
|
||||
request_type='service',
|
||||
class_cleanup=False)
|
||||
resp, msvcs = self.service_client.magnum_service_list()
|
||||
self.assertEqual(200, resp.status)
|
||||
# Note(suro-patz): Following code assumes that we have only
|
||||
|
|
|
@ -22,9 +22,11 @@ from magnum.tests.functional.common import manager
|
|||
class BaseMagnumTest(base.BaseTestCase):
|
||||
"""Sets up configuration required for functional tests"""
|
||||
|
||||
ic_class_list = []
|
||||
ic_method_list = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(BaseMagnumTest, self).__init__(*args, **kwargs)
|
||||
self.ic = None
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
|
@ -34,13 +36,27 @@ class BaseMagnumTest(base.BaseTestCase):
|
|||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
super(BaseMagnumTest, cls).tearDownClass()
|
||||
cls.clear_credentials(clear_class_creds=True)
|
||||
|
||||
def tearDown(self):
|
||||
if self.ic is not None:
|
||||
self.ic.clear_creds()
|
||||
super(BaseMagnumTest, self).tearDown()
|
||||
self.clear_credentials(clear_method_creds=True)
|
||||
|
||||
def get_credentials(self, name=None, type_of_creds="default"):
|
||||
@classmethod
|
||||
def clear_credentials(cls,
|
||||
clear_class_creds=False,
|
||||
clear_method_creds=False):
|
||||
if clear_class_creds:
|
||||
for ic in cls.ic_class_list:
|
||||
ic.clear_creds()
|
||||
if clear_method_creds:
|
||||
for ic in cls.ic_method_list:
|
||||
ic.clear_creds()
|
||||
|
||||
@classmethod
|
||||
def get_credentials(cls, name=None,
|
||||
type_of_creds="default",
|
||||
class_cleanup=False):
|
||||
if name is None:
|
||||
# Get name of test method
|
||||
name = inspect.stack()[1][3]
|
||||
|
@ -48,22 +64,27 @@ class BaseMagnumTest(base.BaseTestCase):
|
|||
name = name[0:32]
|
||||
|
||||
# Choose type of isolated creds
|
||||
self.ic = common_creds.get_credentials_provider(
|
||||
ic = common_creds.get_credentials_provider(
|
||||
name,
|
||||
identity_version=config.Config.auth_version
|
||||
)
|
||||
|
||||
if class_cleanup:
|
||||
cls.ic_class_list.append(ic)
|
||||
else:
|
||||
cls.ic_method_list.append(ic)
|
||||
|
||||
creds = None
|
||||
if "admin" == type_of_creds:
|
||||
creds = self.ic.get_admin_creds()
|
||||
creds = ic.get_admin_creds()
|
||||
elif "alt" == type_of_creds:
|
||||
creds = self.ic.get_alt_creds()
|
||||
creds = ic.get_alt_creds()
|
||||
elif "default" == type_of_creds:
|
||||
creds = self.ic.get_primary_creds()
|
||||
creds = ic.get_primary_creds()
|
||||
else:
|
||||
creds = self.ic.self.get_credentials(type_of_creds)
|
||||
creds = ic.self.get_credentials(type_of_creds)
|
||||
|
||||
_, keypairs_client = self.get_clients(
|
||||
_, keypairs_client = cls.get_clients(
|
||||
creds, type_of_creds, 'keypair_setup')
|
||||
try:
|
||||
keypairs_client.show_keypair(config.Config.keypair_id)
|
||||
|
@ -72,7 +93,8 @@ class BaseMagnumTest(base.BaseTestCase):
|
|||
|
||||
return creds
|
||||
|
||||
def get_clients(self, creds, type_of_creds, request_type):
|
||||
@classmethod
|
||||
def get_clients(cls, creds, type_of_creds, request_type):
|
||||
if "admin" == type_of_creds:
|
||||
manager_inst = manager.AdminManager(credentials=creds,
|
||||
request_type=request_type)
|
||||
|
@ -89,22 +111,27 @@ class BaseMagnumTest(base.BaseTestCase):
|
|||
# create client with isolated creds
|
||||
return (manager_inst.client, manager_inst.keypairs_client)
|
||||
|
||||
def get_clients_with_existing_creds(self,
|
||||
@classmethod
|
||||
def get_clients_with_existing_creds(cls,
|
||||
name=None,
|
||||
creds=None,
|
||||
type_of_creds="default",
|
||||
request_type=None):
|
||||
request_type=None,
|
||||
class_cleanup=False):
|
||||
if creds is None:
|
||||
return self.get_clients_with_isolated_creds(name,
|
||||
type_of_creds,
|
||||
request_type)
|
||||
return cls.get_clients_with_new_creds(name,
|
||||
type_of_creds,
|
||||
request_type,
|
||||
class_cleanup)
|
||||
else:
|
||||
return self.get_clients(creds, type_of_creds, request_type)
|
||||
return cls.get_clients(creds, type_of_creds, request_type)
|
||||
|
||||
def get_clients_with_new_creds(self,
|
||||
@classmethod
|
||||
def get_clients_with_new_creds(cls,
|
||||
name=None,
|
||||
type_of_creds="default",
|
||||
request_type=None):
|
||||
request_type=None,
|
||||
class_cleanup=False):
|
||||
"""Creates isolated creds.
|
||||
|
||||
:param name: name, will be used for dynamic creds
|
||||
|
@ -113,5 +140,5 @@ class BaseMagnumTest(base.BaseTestCase):
|
|||
:returns: MagnumClient -- client with isolated creds.
|
||||
:returns: KeypairClient -- allows for creating of keypairs
|
||||
"""
|
||||
creds = self.get_credentials(name, type_of_creds)
|
||||
return self.get_clients(creds, type_of_creds, request_type)
|
||||
creds = cls.get_credentials(name, type_of_creds, class_cleanup)
|
||||
return cls.get_clients(creds, type_of_creds, request_type)
|
||||
|
|
|
@ -97,6 +97,12 @@ class Config(object):
|
|||
raise Exception('config missing master_flavor_id key')
|
||||
cls.master_flavor_id = CONF.magnum.master_flavor_id
|
||||
|
||||
@classmethod
|
||||
def set_csr_location(cls, config):
|
||||
if 'csr_location' not in CONF.magnum:
|
||||
raise Exception('config missing csr_location key')
|
||||
cls.csr_location = CONF.magnum.csr_location
|
||||
|
||||
@classmethod
|
||||
def setUp(cls):
|
||||
cls.set_admin_creds(config)
|
||||
|
@ -112,3 +118,4 @@ class Config(object):
|
|||
cls.set_flavor_id(config)
|
||||
cls.set_magnum_url(config)
|
||||
cls.set_master_flavor_id(config)
|
||||
cls.set_csr_location(config)
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import string
|
||||
|
@ -21,6 +22,7 @@ from magnum.tests.functional.api.v1.models import bay_model
|
|||
from magnum.tests.functional.api.v1.models import baymodel_model
|
||||
from magnum.tests.functional.api.v1.models import baymodelpatch_model
|
||||
from magnum.tests.functional.api.v1.models import baypatch_model
|
||||
from magnum.tests.functional.api.v1.models import cert_model
|
||||
from magnum.tests.functional.common import config
|
||||
|
||||
|
||||
|
@ -282,3 +284,18 @@ def bay_node_count_patch_data(node_count=2):
|
|||
"op": "replace"
|
||||
}]
|
||||
return baypatch_model.BayPatchCollection.from_dict(data)
|
||||
|
||||
|
||||
def cert_data(bay_uuid, csr_data=None):
|
||||
if csr_data is None:
|
||||
csr_data = config.Config.csr_location
|
||||
data = {
|
||||
"bay_uuid": bay_uuid
|
||||
}
|
||||
if csr_data is not None and os.path.isfile(csr_data):
|
||||
with open(csr_data, 'r') as f:
|
||||
data['csr'] = f.read()
|
||||
|
||||
model = cert_model.CertEntity.from_dict(data)
|
||||
|
||||
return model
|
||||
|
|
|
@ -15,6 +15,7 @@ from tempest.common import credentials_factory as common_creds
|
|||
|
||||
from magnum.tests.functional.api.v1.clients import bay_client
|
||||
from magnum.tests.functional.api.v1.clients import baymodel_client
|
||||
from magnum.tests.functional.api.v1.clients import cert_client
|
||||
from magnum.tests.functional.api.v1.clients import magnum_service_client
|
||||
from magnum.tests.functional.common import client
|
||||
from magnum.tests.functional.common import config
|
||||
|
@ -33,6 +34,8 @@ class Manager(clients.Manager):
|
|||
self.client = baymodel_client.BayModelClient(self.auth_provider)
|
||||
elif request_type == 'bay':
|
||||
self.client = bay_client.BayClient(self.auth_provider)
|
||||
elif request_type == 'cert':
|
||||
self.client = cert_client.CertClient(self.auth_provider)
|
||||
elif request_type == 'service':
|
||||
self.client = magnum_service_client.MagnumServiceClient(
|
||||
self.auth_provider)
|
||||
|
|
|
@ -51,4 +51,8 @@ MagnumGroup = [
|
|||
cfg.StrOpt("master_flavor_id",
|
||||
default="m1.magnum",
|
||||
help="Master flavor id to use for baymodels."),
|
||||
|
||||
cfg.StrOpt("csr_location",
|
||||
default="/opt/stack/new/magnum/default.csr",
|
||||
help="CSR location for certificates."),
|
||||
]
|
||||
|
|
Loading…
Reference in New Issue