Modify Ironic driver to support multi-podm arch
This commit adds support for Ironic driver to work in multi-podm architecture Change-Id: I09e2be5eb3a2651d6f960ac5610fca89335e5b5b
This commit is contained in:
parent
0104b783ba
commit
ecd965149b
@ -45,12 +45,12 @@ class Node(Resource):
|
|||||||
def get(self, node_uuid):
|
def get(self, node_uuid):
|
||||||
return utils.make_response(
|
return utils.make_response(
|
||||||
http_client.OK,
|
http_client.OK,
|
||||||
nodes.Node(node_id=node_uuid).get_composed_node_by_uuid(node_uuid))
|
nodes.Node(node_id=node_uuid).get_composed_node_by_uuid())
|
||||||
|
|
||||||
def delete(self, node_uuid):
|
def delete(self, node_uuid):
|
||||||
return utils.make_response(
|
return utils.make_response(
|
||||||
http_client.OK,
|
http_client.OK,
|
||||||
nodes.Node(node_id=node_uuid).delete_composed_node(node_uuid))
|
nodes.Node(node_id=node_uuid).delete_composed_node())
|
||||||
|
|
||||||
|
|
||||||
class NodeAction(Resource):
|
class NodeAction(Resource):
|
||||||
@ -59,8 +59,7 @@ class NodeAction(Resource):
|
|||||||
def post(self, node_uuid):
|
def post(self, node_uuid):
|
||||||
return utils.make_response(
|
return utils.make_response(
|
||||||
http_client.NO_CONTENT,
|
http_client.NO_CONTENT,
|
||||||
nodes.Node(node_id=node_uuid).node_action(node_uuid,
|
nodes.Node(node_id=node_uuid).node_action(request.get_json()))
|
||||||
request.get_json()))
|
|
||||||
|
|
||||||
|
|
||||||
class NodeManage(Resource):
|
class NodeManage(Resource):
|
||||||
|
@ -151,7 +151,7 @@ class Node(object):
|
|||||||
|
|
||||||
return self._show_node_brief_info(composed_node)
|
return self._show_node_brief_info(composed_node)
|
||||||
|
|
||||||
def get_composed_node_by_uuid(self, node_uuid):
|
def get_composed_node_by_uuid(self):
|
||||||
"""Get composed node details
|
"""Get composed node details
|
||||||
|
|
||||||
Get the detail of specific composed node. In some cases db data may be
|
Get the detail of specific composed node. In some cases db data may be
|
||||||
@ -159,7 +159,6 @@ class Node(object):
|
|||||||
through valence api. So compare it with node info from redfish, and
|
through valence api. So compare it with node info from redfish, and
|
||||||
update db if it's inconsistent.
|
update db if it's inconsistent.
|
||||||
|
|
||||||
param node_uuid: uuid of composed node
|
|
||||||
return: detail of this composed node
|
return: detail of this composed node
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -169,15 +168,14 @@ class Node(object):
|
|||||||
node_hw.update(self.node)
|
node_hw.update(self.node)
|
||||||
return node_hw
|
return node_hw
|
||||||
|
|
||||||
def delete_composed_node(self, node_uuid):
|
def delete_composed_node(self):
|
||||||
"""Delete a composed node
|
"""Delete a composed node
|
||||||
|
|
||||||
param node_uuid: uuid of composed node
|
|
||||||
return: message of this deletion
|
return: message of this deletion
|
||||||
"""
|
"""
|
||||||
# Call podmanager to delete node, and delete corresponding entry in db
|
# Call podmanager to delete node, and delete corresponding entry in db
|
||||||
message = self.connection.delete_composed_node(self.node['index'])
|
message = self.connection.delete_composed_node(self.node['index'])
|
||||||
db_api.Connection.delete_composed_node(node_uuid)
|
db_api.Connection.delete_composed_node(self.node['uuid'])
|
||||||
|
|
||||||
return message
|
return message
|
||||||
|
|
||||||
@ -190,7 +188,7 @@ class Node(object):
|
|||||||
return [cls._show_node_brief_info(node.as_dict())
|
return [cls._show_node_brief_info(node.as_dict())
|
||||||
for node in db_api.Connection.list_composed_nodes(filters)]
|
for node in db_api.Connection.list_composed_nodes(filters)]
|
||||||
|
|
||||||
def node_action(self, node_uuid, request_body):
|
def node_action(self, request_body):
|
||||||
"""Post action to a composed node
|
"""Post action to a composed node
|
||||||
|
|
||||||
param node_uuid: uuid of composed node
|
param node_uuid: uuid of composed node
|
||||||
|
@ -17,13 +17,15 @@ from valence.redfish.sushy import sushy_instance
|
|||||||
|
|
||||||
class PodManagerBase(object):
|
class PodManagerBase(object):
|
||||||
|
|
||||||
def __init__(self, username, password, podm_url):
|
def __init__(self, username, password, podm_url, **kwargs):
|
||||||
self.podm_url = podm_url
|
self.podm_url = podm_url
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
self.driver = sushy_instance.RedfishInstance(username=username,
|
self.driver = sushy_instance.RedfishInstance(username=username,
|
||||||
password=password,
|
password=password,
|
||||||
base_url=podm_url)
|
base_url=podm_url)
|
||||||
|
|
||||||
# TODO(ramineni): rebase on nate's patch
|
# TODO(): use rsd_lib here
|
||||||
def get_status(self):
|
def get_status(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -62,6 +64,28 @@ class PodManagerBase(object):
|
|||||||
def get_all_devices(self):
|
def get_all_devices(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def get_ironic_node_params(self, node_info, **param):
|
||||||
|
# TODO(): change to 'rsd' once ironic driver is implemented.
|
||||||
|
driver_info = {
|
||||||
|
'redfish_address': self.podm_url,
|
||||||
|
'redfish_username': self.username,
|
||||||
|
'redfish_password': self.password,
|
||||||
|
'redfish_system_id': node_info['computer_system']
|
||||||
|
}
|
||||||
|
node_args = {}
|
||||||
|
if param and param.get('driver_info', None):
|
||||||
|
driver_info.update(param.pop('driver_info'))
|
||||||
|
|
||||||
|
node_args.update({'driver': 'redfish', 'name': node_info['name'],
|
||||||
|
'driver_info': driver_info})
|
||||||
|
port_args = {'address': node_info['metadata']['network'][0]['mac']}
|
||||||
|
|
||||||
|
# update any remaining params passed
|
||||||
|
if param:
|
||||||
|
node_args.update(param)
|
||||||
|
|
||||||
|
return node_args, port_args
|
||||||
|
|
||||||
def get_resource_info_by_url(self, resource_url):
|
def get_resource_info_by_url(self, resource_url):
|
||||||
return self.driver.get_resources_by_url(resource_url)
|
return self.driver.get_resources_by_url(resource_url)
|
||||||
|
|
||||||
|
@ -33,48 +33,37 @@ class IronicDriver(driver.ProvisioningDriver):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super(IronicDriver, self).__init__()
|
super(IronicDriver, self).__init__()
|
||||||
|
self.ironic = utils.create_ironicclient()
|
||||||
|
|
||||||
def node_register(self, node_uuid, param):
|
def node_register(self, node_uuid, param):
|
||||||
LOG.debug('Registering node %s with ironic' % node_uuid)
|
LOG.debug('Registering node %s with ironic' % node_uuid)
|
||||||
node_info = nodes.Node.get_composed_node_by_uuid(node_uuid)
|
node_controller = nodes.Node(node_id=node_uuid)
|
||||||
try:
|
try:
|
||||||
ironic = utils.create_ironicclient()
|
node_info = node_controller.get_composed_node_by_uuid()
|
||||||
except Exception as e:
|
node_args, port_args = (
|
||||||
message = ('Error occurred while communicating to '
|
node_controller.connection.get_ironic_node_params(node_info,
|
||||||
'Ironic: %s' % six.text_type(e))
|
**param))
|
||||||
LOG.error(message)
|
ironic_node = self.ironic.node.create(**node_args)
|
||||||
raise exception.ValenceException(message)
|
|
||||||
try:
|
|
||||||
# NOTE(mkrai): Below implementation will be changed in future to
|
|
||||||
# support the multiple pod manager in which we access pod managers'
|
|
||||||
# detail from podm object associated with a node.
|
|
||||||
driver_info = {
|
|
||||||
'redfish_address': CONF.podm.url,
|
|
||||||
'redfish_username': CONF.podm.username,
|
|
||||||
'redfish_password': CONF.podm.password,
|
|
||||||
'redfish_verify_ca': CONF.podm.verify_ca,
|
|
||||||
'redfish_system_id': node_info['computer_system']}
|
|
||||||
node_args = {}
|
|
||||||
if param:
|
|
||||||
if param.get('driver_info', None):
|
|
||||||
driver_info.update(param.get('driver_info'))
|
|
||||||
del param['driver_info']
|
|
||||||
node_args.update({'driver': 'redfish', 'name': node_info['name'],
|
|
||||||
'driver_info': driver_info})
|
|
||||||
if param:
|
|
||||||
node_args.update(param)
|
|
||||||
ironic_node = ironic.node.create(**node_args)
|
|
||||||
port_args = {'node_uuid': ironic_node.uuid,
|
|
||||||
'address': node_info['metadata']['network'][0]['mac']}
|
|
||||||
ironic.port.create(**port_args)
|
|
||||||
db_api.Connection.update_composed_node(node_uuid,
|
|
||||||
{'managed_by': 'ironic'})
|
|
||||||
return exception.confirmation(
|
|
||||||
confirm_code="Node Registered",
|
|
||||||
confirm_detail="The composed node {0} has been registered "
|
|
||||||
"with Ironic successfully.".format(node_uuid))
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
message = ('Unexpected error while registering node with '
|
message = ('Unexpected error while registering node with '
|
||||||
'Ironic: %s' % six.text_type(e))
|
'Ironic: %s' % six.text_type(e))
|
||||||
LOG.error(message)
|
LOG.error(message)
|
||||||
raise exception.ValenceException(message)
|
raise exception.ValenceException(message)
|
||||||
|
|
||||||
|
node_info = db_api.Connection.update_composed_node(
|
||||||
|
node_uuid, {'managed_by': 'ironic'}).as_dict()
|
||||||
|
|
||||||
|
if port_args:
|
||||||
|
# If MAC provided, create ports, else skip
|
||||||
|
port_args['node_uuid'] = ironic_node.uuid
|
||||||
|
self.ironic_port_create(**port_args)
|
||||||
|
|
||||||
|
return node_info
|
||||||
|
|
||||||
|
def ironic_port_create(self, **port):
|
||||||
|
try:
|
||||||
|
self.ironic.port.create(**port)
|
||||||
|
LOG.debug('Successfully created ironic ports %s', port)
|
||||||
|
except Exception as e:
|
||||||
|
LOG.debug("Ironic port creation failed with error %s", str(e))
|
||||||
|
@ -13,7 +13,12 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from valence.common import clients
|
from valence.common import clients
|
||||||
|
from valence.common import exception
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def create_ironicclient():
|
def create_ironicclient():
|
||||||
@ -21,5 +26,10 @@ def create_ironicclient():
|
|||||||
|
|
||||||
:returns: Ironic client object
|
:returns: Ironic client object
|
||||||
"""
|
"""
|
||||||
osc = clients.OpenStackClients()
|
try:
|
||||||
return osc.ironic()
|
osc = clients.OpenStackClients()
|
||||||
|
return osc.ironic()
|
||||||
|
except Exception:
|
||||||
|
message = ('Error occurred while communicating to Ironic')
|
||||||
|
LOG.exception(message)
|
||||||
|
raise exception.ValenceException(message)
|
||||||
|
@ -30,6 +30,7 @@ class TestAPINodes(unittest.TestCase):
|
|||||||
@mock.patch('valence.redfish.sushy.sushy_instance.RedfishInstance')
|
@mock.patch('valence.redfish.sushy.sushy_instance.RedfishInstance')
|
||||||
def setUp(self, mock_redfish, mock_connection):
|
def setUp(self, mock_redfish, mock_connection):
|
||||||
self.node_controller = nodes.Node(podm_id='test-podm-1')
|
self.node_controller = nodes.Node(podm_id='test-podm-1')
|
||||||
|
self.node_controller.node = test_utils.get_test_composed_node_db_info()
|
||||||
self.node_controller.connection = podm_base.PodManagerBase(
|
self.node_controller.connection = podm_base.PodManagerBase(
|
||||||
'fake', 'fake-pass', 'http://fake-url')
|
'fake', 'fake-pass', 'http://fake-url')
|
||||||
|
|
||||||
@ -182,7 +183,7 @@ class TestAPINodes(unittest.TestCase):
|
|||||||
self.node_controller.node = node_db
|
self.node_controller.node = node_db
|
||||||
mock_redfish_get_node.return_value = node_hw
|
mock_redfish_get_node.return_value = node_hw
|
||||||
|
|
||||||
result = self.node_controller.get_composed_node_by_uuid("fake_uuid")
|
result = self.node_controller.get_composed_node_by_uuid()
|
||||||
expected = copy.deepcopy(node_hw)
|
expected = copy.deepcopy(node_hw)
|
||||||
expected.update(node_db)
|
expected.update(node_db)
|
||||||
self.assertEqual(expected, result)
|
self.assertEqual(expected, result)
|
||||||
@ -192,7 +193,7 @@ class TestAPINodes(unittest.TestCase):
|
|||||||
"""Test delete composed node"""
|
"""Test delete composed node"""
|
||||||
node_db = test_utils.get_test_composed_node_db_info()
|
node_db = test_utils.get_test_composed_node_db_info()
|
||||||
self.node_controller.node = node_db
|
self.node_controller.node = node_db
|
||||||
self.node_controller.delete_composed_node(node_db["uuid"])
|
self.node_controller.delete_composed_node()
|
||||||
mock_db_delete_composed_node.assert_called_once_with(
|
mock_db_delete_composed_node.assert_called_once_with(
|
||||||
node_db["uuid"])
|
node_db["uuid"])
|
||||||
|
|
||||||
@ -216,8 +217,8 @@ class TestAPINodes(unittest.TestCase):
|
|||||||
"""Test reset composed node status"""
|
"""Test reset composed node status"""
|
||||||
action = {"Reset": {"Type": "On"}}
|
action = {"Reset": {"Type": "On"}}
|
||||||
self.node_controller.node = {'index': '1', 'name': 'test-node'}
|
self.node_controller.node = {'index': '1', 'name': 'test-node'}
|
||||||
self.node_controller.node_action("fake_uuid", action)
|
self.node_controller.node_action(action)
|
||||||
mock_node_action.assert_called_once_with("1", action)
|
mock_node_action.assert_called_once_with('1', action)
|
||||||
|
|
||||||
@mock.patch("valence.provision.driver.node_register")
|
@mock.patch("valence.provision.driver.node_register")
|
||||||
def test_node_register(self, mock_node_register):
|
def test_node_register(self, mock_node_register):
|
||||||
|
@ -21,52 +21,52 @@ from valence.provision.ironic import driver
|
|||||||
|
|
||||||
|
|
||||||
class TestDriver(base.BaseTestCase):
|
class TestDriver(base.BaseTestCase):
|
||||||
def setUp(self):
|
|
||||||
|
@mock.patch("valence.provision.ironic.utils.create_ironicclient")
|
||||||
|
def setUp(self, mock_ironic_client):
|
||||||
super(TestDriver, self).setUp()
|
super(TestDriver, self).setUp()
|
||||||
self.ironic = driver.IronicDriver()
|
self.driver = driver.IronicDriver()
|
||||||
|
self.driver.ironic = mock.MagicMock()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
super(TestDriver, self).tearDown()
|
super(TestDriver, self).tearDown()
|
||||||
|
|
||||||
@mock.patch("valence.controller.nodes.Node.get_composed_node_by_uuid")
|
@mock.patch("valence.db.api.Connection.get_composed_node_by_uuid")
|
||||||
def test_node_register_node_not_found(self, mock_db):
|
def test_node_register_node_not_found(self, mock_db):
|
||||||
mock_db.side_effect = exception.NotFound
|
mock_db.side_effect = exception.NotFound("node not found")
|
||||||
self.assertRaises(exception.NotFound,
|
self.assertRaises(exception.NotFound,
|
||||||
self.ironic.node_register,
|
self.driver.node_register,
|
||||||
'fake-uuid', {})
|
|
||||||
|
|
||||||
@mock.patch("valence.controller.nodes.Node.get_composed_node_by_uuid")
|
|
||||||
@mock.patch("valence.provision.ironic.utils.create_ironicclient")
|
|
||||||
def test_node_register_ironic_client_failure(self, mock_client,
|
|
||||||
mock_db):
|
|
||||||
mock_client.side_effect = Exception()
|
|
||||||
self.assertRaises(exception.ValenceException,
|
|
||||||
self.ironic.node_register,
|
|
||||||
'fake-uuid', {})
|
'fake-uuid', {})
|
||||||
|
|
||||||
|
@mock.patch('valence.redfish.sushy.sushy_instance.RedfishInstance')
|
||||||
@mock.patch("valence.db.api.Connection.update_composed_node")
|
@mock.patch("valence.db.api.Connection.update_composed_node")
|
||||||
@mock.patch("valence.controller.nodes.Node.get_composed_node_by_uuid")
|
@mock.patch('valence.controller.nodes.Node')
|
||||||
@mock.patch("valence.provision.ironic.utils.create_ironicclient")
|
def test_node_register(self, node_mock, mock_node_update, mock_sushy):
|
||||||
def test_node_register(self, mock_client,
|
ironic = self.driver.ironic
|
||||||
mock_node_get, mock_node_update):
|
node_mock.return_value = mock.MagicMock()
|
||||||
ironic = mock.MagicMock()
|
node_info = {
|
||||||
mock_client.return_value = ironic
|
'id': 'fake-uuid', 'podm_id': 'fake-podm_id',
|
||||||
mock_node_get.return_value = {
|
'index': '1',
|
||||||
'name': 'test', 'metadata':
|
'name': 'test', 'metadata':
|
||||||
{'network': [{'mac': 'fake-mac'}]},
|
{'network': [{'mac': 'fake-mac'}]},
|
||||||
'computer_system': '/redfish/v1/Systems/437XR1138R2'}
|
'computer_system': '/redfish/v1/Systems/437XR1138R2'}
|
||||||
|
node_controller = node_mock.return_value
|
||||||
|
node_controller.get_composed_node_by_uuid.return_value = node_info
|
||||||
|
node_info.update({'managed_by': 'ironic'})
|
||||||
|
mock_node_update.return_value.as_dict.return_value = node_info
|
||||||
|
node_controller.connection = mock.MagicMock()
|
||||||
ironic.node.create.return_value = mock.MagicMock(uuid='ironic-uuid')
|
ironic.node.create.return_value = mock.MagicMock(uuid='ironic-uuid')
|
||||||
port_arg = {'node_uuid': 'ironic-uuid', 'address': 'fake-mac'}
|
n_args = ({'driver_info': {'username': 'fake1'}}, {})
|
||||||
resp = self.ironic.node_register('fake-uuid',
|
node_controller.connection.get_ironic_node_params.return_value = n_args
|
||||||
|
resp = self.driver.node_register('fake-uuid',
|
||||||
{"extra": {"foo": "bar"}})
|
{"extra": {"foo": "bar"}})
|
||||||
self.assertEqual({
|
self.assertEqual(node_info, resp)
|
||||||
'code': 'Node Registered',
|
node_mock.assert_called_once_with(node_id='fake-uuid')
|
||||||
'detail': 'The composed node fake-uuid has been '
|
|
||||||
'registered with Ironic successfully.',
|
|
||||||
'request_id': '00000000-0000-0000-0000-000000000000'}, resp)
|
|
||||||
mock_client.assert_called_once()
|
|
||||||
mock_node_get.assert_called_once_with('fake-uuid')
|
|
||||||
mock_node_update.assert_called_once_with('fake-uuid',
|
mock_node_update.assert_called_once_with('fake-uuid',
|
||||||
{'managed_by': 'ironic'})
|
{'managed_by': 'ironic'})
|
||||||
ironic.node.create.assert_called_once()
|
ironic.node.create.assert_called_once()
|
||||||
ironic.port.create.assert_called_once_with(**port_arg)
|
|
||||||
|
def test_ironic_port_create(self):
|
||||||
|
port_args = {'mac_address': 'fake-mac'}
|
||||||
|
self.driver.ironic_port_create(**port_args)
|
||||||
|
self.driver.ironic.port.create.assert_called_once()
|
||||||
|
@ -15,6 +15,7 @@ import mock
|
|||||||
|
|
||||||
from oslotest import base
|
from oslotest import base
|
||||||
|
|
||||||
|
from valence.common import exception
|
||||||
from valence.provision.ironic import utils
|
from valence.provision.ironic import utils
|
||||||
|
|
||||||
|
|
||||||
@ -27,3 +28,9 @@ class TestUtils(base.BaseTestCase):
|
|||||||
ironic = utils.create_ironicclient()
|
ironic = utils.create_ironicclient()
|
||||||
self.assertTrue(ironic)
|
self.assertTrue(ironic)
|
||||||
mock_ironic.assert_called_once_with()
|
mock_ironic.assert_called_once_with()
|
||||||
|
|
||||||
|
@mock.patch('valence.common.clients.OpenStackClients.ironic')
|
||||||
|
def test_create_ironic_client_failure(self, mock_client):
|
||||||
|
mock_client.side_effect = Exception()
|
||||||
|
self.assertRaises(exception.ValenceException,
|
||||||
|
utils.create_ironicclient)
|
||||||
|
@ -219,7 +219,7 @@ class TestNodeApi(TestApiValidation):
|
|||||||
resp = self.app.post('/v1/nodes/fake-node/action',
|
resp = self.app.post('/v1/nodes/fake-node/action',
|
||||||
content_type='application/json',
|
content_type='application/json',
|
||||||
data=json.dumps(req))
|
data=json.dumps(req))
|
||||||
mock_action.assert_called_once_with('fake-node', req)
|
mock_action.assert_called_once_with(req)
|
||||||
self.assertEqual(http_client.NO_CONTENT, resp.status_code)
|
self.assertEqual(http_client.NO_CONTENT, resp.status_code)
|
||||||
|
|
||||||
@mock.patch('valence.controller.nodes.Node.node_action')
|
@mock.patch('valence.controller.nodes.Node.node_action')
|
||||||
@ -235,7 +235,7 @@ class TestNodeApi(TestApiValidation):
|
|||||||
resp = self.app.post('/v1/nodes/fake-node/action',
|
resp = self.app.post('/v1/nodes/fake-node/action',
|
||||||
content_type='application/json',
|
content_type='application/json',
|
||||||
data=json.dumps(req))
|
data=json.dumps(req))
|
||||||
mock_action.assert_called_once_with('fake-node', req)
|
mock_action.assert_called_once_with(req)
|
||||||
self.assertEqual(http_client.NO_CONTENT, resp.status_code)
|
self.assertEqual(http_client.NO_CONTENT, resp.status_code)
|
||||||
|
|
||||||
@mock.patch('valence.controller.nodes.Node.node_action')
|
@mock.patch('valence.controller.nodes.Node.node_action')
|
||||||
@ -251,7 +251,7 @@ class TestNodeApi(TestApiValidation):
|
|||||||
resp = self.app.post('/v1/nodes/fake-node/action',
|
resp = self.app.post('/v1/nodes/fake-node/action',
|
||||||
content_type='application/json',
|
content_type='application/json',
|
||||||
data=json.dumps(req))
|
data=json.dumps(req))
|
||||||
mock_action.assert_called_once_with('fake-node', req)
|
mock_action.assert_called_once_with(req)
|
||||||
self.assertEqual(http_client.NO_CONTENT, resp.status_code)
|
self.assertEqual(http_client.NO_CONTENT, resp.status_code)
|
||||||
|
|
||||||
@mock.patch('valence.db.api.Connection.get_composed_node_by_uuid')
|
@mock.patch('valence.db.api.Connection.get_composed_node_by_uuid')
|
||||||
|
Loading…
x
Reference in New Issue
Block a user