diff --git a/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/load.py b/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/load.py index 1cb1ddb359..028d7bcff4 100644 --- a/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/load.py +++ b/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/load.py @@ -336,10 +336,11 @@ class LoadController(rest.RestController): " %s is active.") % constants.CONTROLLER_0_HOSTNAME) - system_controller_import_active = False req_content = dict() load_files = dict() is_multiform_req = True + import_type = None + # Request coming from dc-api-proxy is not multiform, file transfer is handled # by dc-api-proxy, the request contains only the vault file location if request.content_type == "application/json": @@ -351,15 +352,27 @@ class LoadController(rest.RestController): if not req_content: raise wsme.exc.ClientSideError(_("Empty request.")) - if 'active' in req_content: - if req_content['active'] == 'true': - if pecan.request.dbapi.isystem_get_one().\ - distributed_cloud_role == \ - constants.DISTRIBUTED_CLOUD_ROLE_SYSTEMCONTROLLER: - LOG.info("System Controller allow start import_load") - system_controller_import_active = True + active = req_content.get('active') + inactive = req_content.get('inactive') - self._check_existing_loads(active_import=system_controller_import_active) + if active == 'true' and inactive == 'true': + raise wsme.exc.ClientSideError(_("Invalid use of --active and" + " --inactive arguments at" + " the same time.")) + + if active == 'true' or inactive == 'true': + isystem = pecan.request.dbapi.isystem_get_one() + + if isystem.distributed_cloud_role == \ + constants.DISTRIBUTED_CLOUD_ROLE_SYSTEMCONTROLLER: + LOG.info("System Controller allow start import_load") + + if active == 'true': + import_type = constants.ACTIVE_LOAD_IMPORT + elif inactive == 'true': + import_type = constants.INACTIVE_LOAD_IMPORT + + self._check_existing_loads(import_type=import_type) try: for file in constants.IMPORT_LOAD_FILES: @@ -395,18 +408,21 @@ class LoadController(rest.RestController): pecan.request.context, load_files[constants.LOAD_ISO], load_files[constants.LOAD_SIGNATURE], - system_controller_import_active) + import_type, + ) if new_load is None: raise wsme.exc.ClientSideError(_("Error importing load. Load not found")) - if not system_controller_import_active: + if import_type != constants.ACTIVE_LOAD_IMPORT: # Signature and upgrade path checks have passed, make rpc call # to the conductor to run import script in the background. pecan.request.rpcapi.import_load( pecan.request.context, load_files[constants.LOAD_ISO], - new_load) + new_load, + import_type, + ) except (rpc.common.Timeout, common.RemoteError) as e: if os.path.isdir(constants.LOAD_FILES_STAGING_DIR): shutil.rmtree(constants.LOAD_FILES_STAGING_DIR) @@ -454,34 +470,50 @@ class LoadController(rest.RestController): return load.convert_with_links(new_load) - def _check_existing_loads(self, active_import=False): + def _check_existing_loads(self, import_type=None): + # Only are allowed at one time: + # - the active load + # - an imported load regardless of its current state + # - an inactive load. + loads = pecan.request.dbapi.load_get_list() - # Only 2 loads are allowed at one time: the active load - # and an imported load regardless of its current state - # (e.g. importing, error, deleting). - load_state = None - if len(loads) > constants.IMPORTED_LOAD_MAX_COUNT: - for load in loads: - if load.state != constants.ACTIVE_LOAD_STATE: - load_state = load.state - else: + if len(loads) <= constants.IMPORTED_LOAD_MAX_COUNT: return - if load_state == constants.ERROR_LOAD_STATE: - err_msg = _("Please remove the load in error state " - "before importing a new one.") - elif load_state == constants.DELETING_LOAD_STATE: - err_msg = _("Please wait for the current load delete " - "to complete before importing a new one.") - elif not active_import: - # Already imported or being imported - err_msg = _("Max number of loads (2) reached. Please " - "remove the old or unused load before " - "importing a new one.") - else: - return - raise wsme.exc.ClientSideError(err_msg) + for load in loads: + if load.state == constants.ACTIVE_LOAD_STATE: + continue + + load_state = load.state + + if load_state == constants.ERROR_LOAD_STATE: + err_msg = _("Please remove the load in error state " + "before importing a new one.") + + elif load_state == constants.DELETING_LOAD_STATE: + err_msg = _("Please wait for the current load delete " + "to complete before importing a new one.") + + elif load_state == constants.INACTIVE_LOAD_STATE: + if import_type != constants.INACTIVE_LOAD_IMPORT: + continue + + err_msg = _("An inactived load already exists. " + "Please, remove the inactive load " + "before trying to import a new one.") + + elif import_type == constants.ACTIVE_LOAD_IMPORT or \ + import_type == constants.INACTIVE_LOAD_IMPORT: + continue + + elif not err_msg: + # Already imported or being imported + err_msg = _("Max number of loads (2) reached. Please " + "remove the old or unused load before " + "importing a new one.") + + raise wsme.exc.ClientSideError(err_msg) @cutils.synchronized(LOCK_NAME) @wsme.validate(six.text_type, [LoadPatchType]) diff --git a/sysinv/sysinv/sysinv/sysinv/common/constants.py b/sysinv/sysinv/sysinv/sysinv/common/constants.py index 866bca216c..6c650742ad 100644 --- a/sysinv/sysinv/sysinv/sysinv/common/constants.py +++ b/sysinv/sysinv/sysinv/sysinv/common/constants.py @@ -851,7 +851,8 @@ ERROR_LOAD_STATE = 'error' DELETING_LOAD_STATE = 'deleting' IMPORTED_LOAD_STATES = [ IMPORTED_LOAD_STATE, - IMPORTED_METADATA_LOAD_STATE + IMPORTED_METADATA_LOAD_STATE, + INACTIVE_LOAD_STATE, ] DELETE_LOAD_SCRIPT = '/etc/sysinv/upgrades/delete_load.sh' diff --git a/sysinv/sysinv/sysinv/sysinv/tests/api/data/bootimage.iso b/sysinv/sysinv/sysinv/sysinv/tests/api/data/bootimage.iso new file mode 100644 index 0000000000..1239cd9aaa --- /dev/null +++ b/sysinv/sysinv/sysinv/sysinv/tests/api/data/bootimage.iso @@ -0,0 +1 @@ +It'S Ok \ No newline at end of file diff --git a/sysinv/sysinv/sysinv/sysinv/tests/api/data/bootimage.sig b/sysinv/sysinv/sysinv/sysinv/tests/api/data/bootimage.sig new file mode 100644 index 0000000000..61f66813c4 --- /dev/null +++ b/sysinv/sysinv/sysinv/sysinv/tests/api/data/bootimage.sig @@ -0,0 +1 @@ +Simple Is Good \ No newline at end of file diff --git a/sysinv/sysinv/sysinv/sysinv/tests/api/test_load.py b/sysinv/sysinv/sysinv/sysinv/tests/api/test_load.py new file mode 100644 index 0000000000..807e900878 --- /dev/null +++ b/sysinv/sysinv/sysinv/sysinv/tests/api/test_load.py @@ -0,0 +1,199 @@ +# +# Copyright (c) 2023 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# + +''' +Tests for the API /loads/import_load methods. +''' + +import os +import webtest.app + +from mock import patch +from mock import MagicMock +from sysinv.common import constants +from sysinv.tests.api import base +from sysinv.tests.db import utils +from sysinv.openstack.common.rpc import common + + +class FakeConductorAPI(object): + def __init__(self): + self.start_import_load = MagicMock() + self.start_import_load.return_value = utils.create_test_load() + + self.import_load = MagicMock() + + +class TestLoad(base.FunctionalTest): + def setUp(self): + super(TestLoad, self).setUp() + + self.API_HEADERS = {'User-Agent': 'sysinv-test'} + + self.PATH_PREFIX = '/loads' + + conductor_api = patch('sysinv.conductor.rpcapiproxy.ConductorAPI') + self.mock_conductor_api = conductor_api.start() + self.fake_conductor_api = FakeConductorAPI() + self.mock_conductor_api.return_value = self.fake_conductor_api + self.addCleanup(conductor_api.stop) + + socket_gethostname = patch('socket.gethostname') + self.mock_socket_gethostname = socket_gethostname.start() + self.mock_socket_gethostname.return_value = 'controller-0' + self.addCleanup(socket_gethostname.stop) + + # TODO: Improve these unit test to don't mock this method. + upload_file = patch( + 'sysinv.api.controllers.v1.load.LoadController._upload_file' + ) + self.mock_upload_file = upload_file.start() + self.mock_upload_file.return_value = '/tmp/iso/' + self.addCleanup(upload_file.stop) + + +@patch('sysinv.common.utils.is_space_available', lambda x, y: True) +class TestLoadImport(TestLoad): + def setUp(self): + super(TestLoadImport, self).setUp() + + path_import = '%s/import_load' % self.PATH_PREFIX + iso = os.path.join( + os.path.dirname(__file__), "data", "bootimage.iso" + ) + sig = os.path.join( + os.path.dirname(__file__), "data", "bootimage.sig" + ) + + self.request_json = { + 'path': path_import, + 'params': { + 'path_to_iso': iso, + 'path_to_sig': sig, + 'active': 'false', + 'inactive': 'false', + }, + 'headers': self.API_HEADERS, + } + + upload_files = [('path_to_iso', iso), ('path_to_sig', sig)] + self.request_multiform = { + 'path': path_import, + 'params': {'active': 'false', 'inactive': 'false'}, + 'upload_files': upload_files, + 'headers': self.API_HEADERS, + 'expect_errors': False, + } + + def _assert_load(self, load): + self.assertEqual(load['software_version'], utils.SW_VERSION) + self.assertEqual(load['compatible_version'], 'N/A') + self.assertEqual(load['required_patches'], 'N/A') + self.assertEqual(load['state'], constants.ACTIVE_LOAD_STATE) + + def test_load_import(self): + response = self.post_with_files(**self.request_multiform) + + self._assert_load(response.json) + self.fake_conductor_api.start_import_load.assert_called_once() + self.fake_conductor_api.import_load.assert_called_once() + + def test_load_import_local(self): + response = self.post_json(**self.request_json) + + self._assert_load(response.json) + self.fake_conductor_api.start_import_load.assert_called_once() + self.fake_conductor_api.import_load.assert_called_once() + + def test_load_import_active(self): + isystem_get_one = self.dbapi.isystem_get_one + self.dbapi.isystem_get_one = MagicMock() + self.dbapi.isystem_get_one.return_value.distributed_cloud_role = \ + constants.DISTRIBUTED_CLOUD_ROLE_SYSTEMCONTROLLER + + self.request_multiform['params']['active'] = 'true' + response = self.post_with_files(**self.request_multiform) + + self.dbapi.isystem_get_one = isystem_get_one + + self._assert_load(response.json) + self.fake_conductor_api.start_import_load.assert_called_once() + self.fake_conductor_api.import_load.assert_not_called() + + def test_load_import_inactive(self): + isystem_get_one = self.dbapi.isystem_get_one + self.dbapi.isystem_get_one = MagicMock() + self.dbapi.isystem_get_one.return_value.distributed_cloud_role = \ + constants.DISTRIBUTED_CLOUD_ROLE_SYSTEMCONTROLLER + + self.request_multiform['params']['inactive'] = 'true' + response = self.post_with_files(**self.request_multiform) + + self.dbapi.isystem_get_one = isystem_get_one + + self._assert_load(response.json) + self.fake_conductor_api.start_import_load.assert_called_once() + self.fake_conductor_api.import_load.assert_called_once() + + def test_load_import_invalid_hostname(self): + self.mock_socket_gethostname.return_value = 'controller-1' + + self.assertRaises( + webtest.app.AppError, + self.post_with_files, + **self.request_multiform, + ) + + self.fake_conductor_api.start_import_load.assert_not_called() + self.fake_conductor_api.import_load.assert_not_called() + + def test_load_import_empty_request(self): + self.request_multiform['upload_files'] = None + + self.assertRaises( + webtest.app.AppError, + self.post_with_files, + **self.request_multiform, + ) + + self.fake_conductor_api.start_import_load.assert_not_called() + self.fake_conductor_api.import_load.assert_not_called() + + def test_load_import_missing_required_file(self): + self.request_multiform['upload_files'].pop() + + self.assertRaises( + webtest.app.AppError, + self.post_with_files, + **self.request_multiform, + ) + + self.fake_conductor_api.start_import_load.assert_not_called() + self.fake_conductor_api.import_load.assert_not_called() + + def test_load_import_failed_to_create_load_conductor(self): + self.fake_conductor_api.start_import_load.return_value = None + + self.assertRaises( + webtest.app.AppError, + self.post_with_files, + **self.request_multiform, + ) + + self.fake_conductor_api.start_import_load.assert_called_once() + self.fake_conductor_api.import_load.assert_not_called() + + def test_load_import_failed_to_import_load_conductor(self): + self.fake_conductor_api.import_load.side_effect = common.RemoteError() + + self.assertRaises( + webtest.app.AppError, + self.post_with_files, + **self.request_multiform, + ) + + self.fake_conductor_api.start_import_load.assert_called_once() + self.fake_conductor_api.import_load.assert_called_once()