From e6e37c949a39e4ee3d4f4c9407a85089e7514345 Mon Sep 17 00:00:00 2001 From: Jessica Castelino Date: Mon, 10 Feb 2020 16:26:13 -0500 Subject: [PATCH] Added unit test cases for host file system. Test cases added for API endpoints used by: 1. host-fs-list 2. host-fs-modify 3. host-fs-show This commit also fixes the issue of Host FS disk space calculations yielding different values in Python 2 and Python 3. Change-Id: I50a1ca43c43c3bba30730c616b3788664920d0c9 Story: 2007082 Task: 38013 Partial-Bug: 1862668 Signed-off-by: Jessica Castelino --- api-ref/source/api-ref-sysinv-v1-config.rst | 4 +- .../sysinv/api/controllers/v1/host_fs.py | 10 +- .../sysinv/sysinv/api/controllers/v1/utils.py | 6 +- .../sysinv/sysinv/tests/api/test_host_fs.py | 526 ++++++++++++++++++ 4 files changed, 537 insertions(+), 9 deletions(-) create mode 100644 sysinv/sysinv/sysinv/sysinv/tests/api/test_host_fs.py diff --git a/api-ref/source/api-ref-sysinv-v1-config.rst b/api-ref/source/api-ref-sysinv-v1-config.rst index d3058066ac..d50b91c065 100644 --- a/api-ref/source/api-ref-sysinv-v1-config.rst +++ b/api-ref/source/api-ref-sysinv-v1-config.rst @@ -10289,7 +10289,7 @@ Show detailed information about a host filesystem *************************************************** -.. rest_method:: GET /v1/ihosts/​{host_id}​/host_fs/​{host_fs_id}​ +.. rest_method:: GET /v1/host_fs/​{host_fs_id}​ **Normal response codes** @@ -10355,7 +10355,7 @@ This operation does not accept a request body. Modifies specific host filesystem(s) ************************************* -.. rest_method:: PATCH /v1/ihosts/​{host_id}​/host_fs/​update_many +.. rest_method:: PUT /v1/ihosts/​{host_id}​/host_fs/​update_many **Normal response codes** diff --git a/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/host_fs.py b/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/host_fs.py index 6ad8819242..3d98a11f00 100644 --- a/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/host_fs.py +++ b/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/host_fs.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2019 Wind River Systems, Inc. +# Copyright (c) 2020 Wind River Systems, Inc. # # SPDX-License-Identifier: Apache-2.0 # @@ -33,7 +33,7 @@ class HostFsPatchType(types.JsonPatchType): class HostFs(base.APIBase): - """API representation of a ilvg. + """API representation of a host_fs. This class enforces type checking and value constraints, and converts between the internal object model and the API representation of @@ -154,7 +154,7 @@ class HostFsController(rest.RestController): marker_obj = None if marker: - marker_obj = objects.lvg.get_by_uuid( + marker_obj = objects.host_fs.get_by_uuid( pecan.request.context, marker) @@ -208,7 +208,7 @@ class HostFsController(rest.RestController): if self._from_ihosts: raise exception.OperationNotPermitted - rpc_host_fs = objects.lvg.get_by_uuid(pecan.request.context, + rpc_host_fs = objects.host_fs.get_by_uuid(pecan.request.context, host_fs_uuid) return HostFs.convert_with_links(rpc_host_fs) @@ -326,7 +326,7 @@ class HostFsController(rest.RestController): filesystem_list=modified_fs,) except Exception as e: - msg = _("Failed to update filesystem size for %s" % host.name) + msg = _("Failed to update filesystem size for %s" % host.hostname) LOG.error("%s with patch %s with exception %s" % (msg, patch, e)) raise wsme.exc.ClientSideError(msg) diff --git a/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/utils.py b/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/utils.py index 4a291202bc..82ae7a03f2 100644 --- a/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/utils.py +++ b/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/utils.py @@ -461,12 +461,14 @@ def get_node_cgtsvg_limit(host): for ilvg in ilvgs: if (ilvg.lvm_vg_name == constants.LVG_CGTS_VG and ilvg.lvm_vg_size and ilvg.lvm_vg_total_pe): + # Integer division in Python 2 behaves like floating point division + # in Python 3. Replacing / by // rectifies this behavior. cgtsvg_free_mib = (int(ilvg.lvm_vg_size) * int( ilvg.lvm_vg_free_pe) - / int(ilvg.lvm_vg_total_pe)) / (1024 * 1024) + // int(ilvg.lvm_vg_total_pe)) // (1024 * 1024) break - cgtsvg_max_free_gib = cgtsvg_free_mib / 1024 + cgtsvg_max_free_gib = cgtsvg_free_mib // 1024 LOG.info("get_node_cgtsvg_limit host=%s, cgtsvg_max_free_gib=%s" % (host.hostname, cgtsvg_max_free_gib)) diff --git a/sysinv/sysinv/sysinv/sysinv/tests/api/test_host_fs.py b/sysinv/sysinv/sysinv/sysinv/tests/api/test_host_fs.py new file mode 100644 index 0000000000..b17471a4fc --- /dev/null +++ b/sysinv/sysinv/sysinv/sysinv/tests/api/test_host_fs.py @@ -0,0 +1,526 @@ +# +# Copyright (c) 2020 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# + +""" +Tests for the API / host-fs / methods. +""" + +import mock +import uuid +from six.moves import http_client +from sysinv.tests.api import base +from sysinv.tests.db import base as dbbase +from sysinv.tests.db import utils as dbutils +from sysinv.common import constants + + +class FakeConductorAPI(object): + + def __init__(self): + self.get_controllerfs_lv_sizes = mock.MagicMock() + self.update_host_filesystem_config = mock.MagicMock() + + +class FakeException(Exception): + pass + + +class ApiHostFSTestCaseMixin(base.FunctionalTest, + dbbase.ControllerHostTestCase): + + # API_HEADERS are a generic header passed to most API calls + API_HEADERS = {'User-Agent': 'sysinv-test'} + + # API_PREFIX is the prefix for the URL + API_PREFIX = '/ihosts' + + # RESULT_KEY is the python table key for the list of results + RESULT_KEY = 'host_fs' + + def setUp(self): + super(ApiHostFSTestCaseMixin, self).setUp() + self.host_fs_first = self._create_db_object('scratch', + 8, + 'scratch-lv') + self.host_fs_second = self._create_db_object('backup', + 20, + 'backup-lv') + self.host_fs_third = self._create_db_object('docker', + 30, + 'docker-lv') + self.fake_conductor_api = FakeConductorAPI() + p = mock.patch('sysinv.conductor.rpcapi.ConductorAPI') + self.mock_conductor_api = p.start() + self.mock_conductor_api.return_value = self.fake_conductor_api + self.addCleanup(p.stop) + + def get_list_url(self, host_uuid): + return '%s/%s/host_fs' % (self.API_PREFIX, host_uuid) + + def get_single_fs_url(self, host_fs_uuid): + return '/host_fs/%s' % (host_fs_uuid) + + def get_post_url(self): + return '/host_fs' % (self.API_PREFIX) + + def get_detail_url(self): + return '/host_fs/detail' + + def get_update_many_url(self, host_uuid): + return '%s/%s/host_fs/update_many' % (self.API_PREFIX, host_uuid) + + def get_sorted_list_url(self, host_uuid, sort_attr, sort_dir): + return '%s/%s/host_fs/?sort_key=%s&sort_dir=%s' % (self.API_PREFIX, + host_uuid, + sort_attr, + sort_dir) + + def _create_db_object(self, host_fs_name, host_fs_size, + host_lv, obj_id=None): + return dbutils.create_test_host_fs(id=obj_id, + uuid=None, + name=host_fs_name, + forihostid=self.host.id, + size=host_fs_size, + logical_volume=host_lv) + + +class ApiHostFSListTestSuiteMixin(ApiHostFSTestCaseMixin): + """ Host FileSystem List GET operations + """ + def setUp(self): + super(ApiHostFSListTestSuiteMixin, self).setUp() + + def test_success_fetch_host_fs_list(self): + response = self.get_json(self.get_list_url(self.host.uuid), + headers=self.API_HEADERS) + + # Verify the values of the response with the values stored in database + result_one = response[self.RESULT_KEY][0] + result_two = response[self.RESULT_KEY][1] + self.assertTrue(result_one['name'] == self.host_fs_first.name or + result_two['name'] == self.host_fs_first.name) + self.assertTrue(result_one['name'] == self.host_fs_second.name or + result_two['name'] == self.host_fs_second.name) + + def test_success_fetch_host_fs_sorted_list(self): + response = self.get_json(self.get_sorted_list_url(self.host.uuid, + 'name', + 'asc')) + + # Verify the values of the response are returned in a sorted order + result_one = response[self.RESULT_KEY][0] + result_two = response[self.RESULT_KEY][1] + result_three = response[self.RESULT_KEY][2] + self.assertEqual(result_one['name'], self.host_fs_second.name) + self.assertEqual(result_two['name'], self.host_fs_third.name) + self.assertEqual(result_three['name'], self.host_fs_first.name) + + def test_fetch_list_invalid_host(self): + # Generate random uuid + random_uuid = uuid.uuid1() + response = self.get_json(self.get_list_url(random_uuid), + headers=self.API_HEADERS, + expect_errors=True) + + # Verify that no host fs is returned for a non-existant host UUID + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, http_client.OK) + self.assertEqual(response.json['host_fs'], []) + + +class ApiHostFSShowTestSuiteMixin(ApiHostFSTestCaseMixin): + """ Host FileSystem Show GET operations + """ + def setUp(self): + super(ApiHostFSShowTestSuiteMixin, self).setUp() + + def test_fetch_host_fs_object(self): + url = self.get_single_fs_url(self.host_fs_first.uuid) + response = self.get_json(url) + + # Verify the values of the response with the values stored in database + self.assertTrue(response['name'], self.host_fs_first.name) + self.assertTrue(response['logical_volume'], + self.host_fs_first.logical_volume) + self.assertTrue(response['size'], self.host_fs_first.size) + self.assertTrue(response['uuid'], self.host_fs_first.uuid) + self.assertTrue(response['ihost_uuid'], self.host.uuid) + + +class ApiHostFSPatchSingleTestSuiteMixin(ApiHostFSTestCaseMixin): + """ Individual Host FileSystem Patch operations + """ + + def setUp(self): + super(ApiHostFSPatchSingleTestSuiteMixin, self).setUp() + + def test_individual_patch_not_allowed(self): + url = self.get_single_fs_url(self.host_fs_first.uuid) + response = self.patch_json(url, + [], + headers=self.API_HEADERS, + expect_errors=True) + + # Verify appropriate exception is raised + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, http_client.FORBIDDEN) + self.assertIn("Operation not permitted.", + response.json['error_message']) + + +class ApiHostFSPutTestSuiteMixin(ApiHostFSTestCaseMixin): + """ Host FileSystem Put operations + """ + + def setUp(self): + super(ApiHostFSPutTestSuiteMixin, self).setUp() + + def exception_host_fs(self): + raise FakeException + + def test_put_invalid_fs_name(self): + response = self.put_json(self.get_update_many_url(self.host.uuid), + [[{"path": "/name", + "value": "invalid", + "op": "replace"}, + {"path": "/size", + "value": "10", + "op": "replace"}], + [{"path": "/name", + "value": "scratch", + "op": "replace"}, + {"path": "/size", + "value": "100", + "op": "replace"}]], + headers=self.API_HEADERS, + expect_errors=True) + + # Verify appropriate exception is raised + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, http_client.BAD_REQUEST) + self.assertIn("HostFs update failed: invalid filesystem 'invalid'", + response.json['error_message']) + + def test_put_invalid_fs_size(self): + response = self.put_json(self.get_update_many_url(self.host.uuid), + [[{"path": "/name", + "value": "scratch", + "op": "replace"}, + {"path": "/size", + "value": "invalid_size", + "op": "replace"}], + [{"path": "/name", + "value": "backup", + "op": "replace"}, + {"path": "/size", + "value": "100", + "op": "replace"}]], + headers=self.API_HEADERS, + expect_errors=True) + + # Verify appropriate exception is raised + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, http_client.BAD_REQUEST) + self.assertIn("HostFs update failed: filesystem 'scratch' " + "size must be an integer", response.json['error_message']) + + def test_put_smaller_than_existing_fs_size(self): + response = self.put_json(self.get_update_many_url(self.host.uuid), + [[{"path": "/name", + "value": "scratch", + "op": "replace"}, + {"path": "/size", + "value": "7", + "op": "replace"}], + [{"path": "/name", + "value": "backup", + "op": "replace"}, + {"path": "/size", + "value": "100", + "op": "replace"}]], + headers=self.API_HEADERS, + expect_errors=True) + + # Verify appropriate exception is raised + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, http_client.BAD_REQUEST) + self.assertIn("HostFs update failed: size for filesystem \'scratch\' " + "should be bigger than 8", response.json['error_message']) + + def test_put_unprovisioned_physical_volume(self): + # Create an unprovisioned physical volume in database + dbutils.create_test_pv(lvm_vg_name='cgts-vg', + forihostid=self.host.id, + pv_state='unprovisioned') + + response = self.put_json(self.get_update_many_url(self.host.uuid), + [[{"path": "/name", + "value": "scratch", + "op": "replace"}, + {"path": "/size", + "value": "10", + "op": "replace"}], + [{"path": "/name", + "value": "backup", + "op": "replace"}, + {"path": "/size", + "value": "100", + "op": "replace"}]], + headers=self.API_HEADERS, + expect_errors=True) + + # Verify appropriate exception is raised + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, http_client.BAD_REQUEST) + self.assertIn("There are still unprovisioned physical volumes " + "on \'controller-0\'. Cannot perform operation.", + response.json['error_message']) + + def test_put_not_enough_space(self): + # Create a provisioned physical volume in database + dbutils.create_test_pv(lvm_vg_name='cgts-vg', + forihostid=self.host.id, + pv_state='provisioned') + # Create a logical volume + dbutils.create_test_lvg(lvm_vg_name='cgts-vg', + forihostid=self.host.id, + lvm_vg_size=200, + lvm_vg_free_pe=50) + + response = self.put_json(self.get_update_many_url(self.host.uuid), + [[{"path": "/name", + "value": "scratch", + "op": "replace"}, + {"path": "/size", + "value": "10", + "op": "replace"}], + [{"path": "/name", + "value": "backup", + "op": "replace"}, + {"path": "/size", + "value": "100", + "op": "replace"}]], + headers=self.API_HEADERS, + expect_errors=True) + + # Verify appropriate exception is raised + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, http_client.BAD_REQUEST) + self.assertIn("HostFs update failed: Not enough free space on " + "cgts-vg. Current free space 0 GiB, requested total " + "increase 82 GiB", response.json['error_message']) + + def test_put_success_with_unprovisioned_host(self): + # Create a provisioned physical volume in database + dbutils.create_test_pv(lvm_vg_name='cgts-vg', + forihostid=self.host.id, + pv_state='provisioned') + + # Create a logical volume + dbutils.create_test_lvg(lvm_vg_name='cgts-vg', + forihostid=self.host.id) + + response = self.put_json(self.get_update_many_url(self.host.uuid), + [[{"path": "/name", + "value": "scratch", + "op": "replace"}, + {"path": "/size", + "value": "10", + "op": "replace"}], + [{"path": "/name", + "value": "backup", + "op": "replace"}, + {"path": "/size", + "value": "21", + "op": "replace"}]], + headers=self.API_HEADERS, + expect_errors=True) + + # Verify a NO CONTENT response is given + self.assertEqual(response.status_code, http_client.NO_CONTENT) + + def test_put_success_with_provisioned_host(self): + # Create a provisioned host + self.host = self._create_test_host(personality=constants.CONTROLLER, + unit=1, + invprovision=constants.PROVISIONED) + + # Add host fs for the new host + self.host_fs_first = self._create_db_object('scratch', + 8, + 'scratch-lv') + self.host_fs_second = self._create_db_object('backup', + 20, + 'backup-lv') + self.host_fs_third = self._create_db_object('docker', + 30, + 'docker-lv') + + # Create a provisioned physical volume in database + dbutils.create_test_pv(lvm_vg_name='cgts-vg', + forihostid=self.host.id, + pv_state='provisioned') + + # Create a logical volume + dbutils.create_test_lvg(lvm_vg_name='cgts-vg', + forihostid=self.host.id) + + response = self.put_json(self.get_update_many_url(self.host.uuid), + [[{"path": "/name", + "value": "scratch", + "op": "replace"}, + {"path": "/size", + "value": "10", + "op": "replace"}], + [{"path": "/name", + "value": "backup", + "op": "replace"}, + {"path": "/size", + "value": "21", + "op": "replace"}]], + headers=self.API_HEADERS, + expect_errors=True) + + # Verify a NO CONTENT response is given + self.assertEqual(response.status_code, http_client.NO_CONTENT) + + def test_put_update_exception(self): + # Create a provisioned host + self.host = self._create_test_host(personality=constants.CONTROLLER, + unit=1, + invprovision=constants.PROVISIONED) + + # Add host fs for the new host + self.host_fs_first = self._create_db_object('scratch', + 8, + 'scratch-lv') + self.host_fs_second = self._create_db_object('backup', + 20, + 'backup-lv') + self.host_fs_third = self._create_db_object('docker', + 30, + 'docker-lv') + + # Create a provisioned physical volume in database + dbutils.create_test_pv(lvm_vg_name='cgts-vg', + forihostid=self.host.id, + pv_state='provisioned') + + # Create a logical volume + dbutils.create_test_lvg(lvm_vg_name='cgts-vg', + forihostid=self.host.id) + + # Throw a fake exception + fake_update = self.fake_conductor_api.update_host_filesystem_config + fake_update.side_effect = self.exception_host_fs + + response = self.put_json(self.get_update_many_url(self.host.uuid), + [[{"path": "/name", + "value": "scratch", + "op": "replace"}, + {"path": "/size", + "value": "10", + "op": "replace"}], + [{"path": "/name", + "value": "backup", + "op": "replace"}, + {"path": "/size", + "value": "21", + "op": "replace"}]], + headers=self.API_HEADERS, + expect_errors=True) + + # Verify appropriate exception is raised + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, http_client.BAD_REQUEST) + self.assertIn("Failed to update filesystem size for controller-1", + response.json['error_message']) + + +class ApiHostFSDetailTestSuiteMixin(ApiHostFSTestCaseMixin): + """ Host FileSystem detail operations + """ + def setUp(self): + super(ApiHostFSDetailTestSuiteMixin, self).setUp() + + # Test that a valid PATCH operation is blocked by the API + def test_success_detail(self): + # Test that a valid PATCH operation is blocked by the API + response = self.get_json(self.get_detail_url(), + headers=self.API_HEADERS, + expect_errors=True) + + self.assertEqual(response.status_code, http_client.OK) + result_one = response.json[self.RESULT_KEY][0] + result_two = response.json[self.RESULT_KEY][1] + result_three = response.json[self.RESULT_KEY][2] + + # Response object 1 + self.assertEqual(result_one['size'], self.host_fs_first.size) + self.assertEqual(result_one['name'], self.host_fs_first.name) + self.assertEqual(result_one['logical_volume'], + self.host_fs_first.logical_volume) + self.assertEqual(result_one['ihost_uuid'], self.host.uuid) + self.assertEqual(result_one['uuid'], self.host_fs_first.uuid) + + # Response object 2 + self.assertEqual(result_two['size'], self.host_fs_second.size) + self.assertEqual(result_two['name'], self.host_fs_second.name) + self.assertEqual(result_two['logical_volume'], + self.host_fs_second.logical_volume) + self.assertEqual(result_two['ihost_uuid'], self.host.uuid) + self.assertEqual(result_two['uuid'], self.host_fs_second.uuid) + + # Response object 3 + self.assertEqual(result_three['size'], self.host_fs_third.size) + self.assertEqual(result_three['name'], self.host_fs_third.name) + self.assertEqual(result_three['logical_volume'], + self.host_fs_third.logical_volume) + self.assertEqual(result_three['ihost_uuid'], self.host.uuid) + self.assertEqual(result_three['uuid'], self.host_fs_third.uuid) + + +class ApiHostFSDeleteTestSuiteMixin(ApiHostFSTestCaseMixin): + """ Host FileSystem delete operations + """ + def setUp(self): + super(ApiHostFSDeleteTestSuiteMixin, self).setUp() + + # Test that a valid DELETE operation is blocked by the API + # API should return 400 BAD_REQUEST or FORBIDDEN 403 + def test_delete_not_allowed(self): + uuid = self.host_fs_third.uuid + response = self.delete(self.get_single_fs_url(uuid), + headers=self.API_HEADERS, + expect_errors=True) + + # Verify appropriate exception is raised + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, http_client.FORBIDDEN) + self.assertIn("Operation not permitted", response.json['error_message']) + + +class ApiHostFSPostTestSuiteMixin(ApiHostFSTestCaseMixin): + """ Host FileSystem post operations + """ + def setUp(self): + super(ApiHostFSPostTestSuiteMixin, self).setUp() + + # Test that a valid POST operation is blocked by the API + # API should return 400 BAD_REQUEST or FORBIDDEN 403 + def test_post_not_allowed(self): + response = self.post_json('/host_fs', + {'name': 'kubelet', + 'size': 10, + 'logical_volume': 'kubelet-lv'}, + headers=self.API_HEADERS, + expect_errors=True) + + # Verify appropriate exception is raised + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, http_client.FORBIDDEN) + self.assertIn("Operation not permitted", response.json['error_message'])