From 8e38d9278dc58eedc070c65da78f3a3e83270786 Mon Sep 17 00:00:00 2001 From: Gabriel Cocenza Date: Wed, 24 Jan 2024 08:39:51 -0300 Subject: [PATCH] Add data-plane pre upgrade sanity checks (#222) - check if control-plane is already upgraded if user pass "data-pane" upgrade group - check if data-plane apps exists if user pass "data-pane" upgrade group - splitted the logic on determine_upgrade_target to several functions This PR should be merged after #221 --- cou/commands.py | 9 ++ cou/exceptions.py | 4 + cou/steps/plan.py | 223 +++++++++++++++++++--------- tests/unit/steps/test_steps_plan.py | 172 ++++++++++++++++++--- 4 files changed, 312 insertions(+), 96 deletions(-) diff --git a/cou/commands.py b/cou/commands.py index 5f00b568..f22b54c6 100644 --- a/cou/commands.py +++ b/cou/commands.py @@ -368,6 +368,15 @@ def prompt(self) -> bool: """ return not self.auto_approve + @property + def is_data_plane_command(self) -> bool: + """Whether if the command passed is data-plane related. + + :return: True if is data-plane, false otherwise. + :rtype: bool + """ + return self.upgrade_group == DATA_PLANE + def parse_args(args: Any) -> CLIargs: # pylint: disable=inconsistent-return-statements """Parse cli arguments. diff --git a/cou/exceptions.py b/cou/exceptions.py index 0eb6c036..70f84f41 100644 --- a/cou/exceptions.py +++ b/cou/exceptions.py @@ -122,6 +122,10 @@ class WaitForApplicationsTimeout(COUException): """Waiting for applications hit timeout error.""" +class DataPlaneCannotUpgrade(COUException): + """COU exception when the cloud is inconsistent to generate a plan.""" + + class InterruptError(KeyboardInterrupt): """COU exception when upgrade was interrupted by signal.""" diff --git a/cou/steps/plan.py b/cou/steps/plan.py index 4a3e8030..1b447b5e 100644 --- a/cou/steps/plan.py +++ b/cou/steps/plan.py @@ -15,7 +15,7 @@ """Upgrade planning utilities.""" import logging -from typing import Callable, Optional +from typing import Callable # NOTE we need to import the modules to register the charms with the register_application # decorator @@ -39,6 +39,7 @@ ) from cou.commands import CLIargs from cou.exceptions import ( + DataPlaneCannotUpgrade, HaltUpgradePlanGeneration, HighestReleaseAchieved, NoTargetError, @@ -53,6 +54,152 @@ logger = logging.getLogger(__name__) +def pre_plan_sanity_checks(args: CLIargs, analysis_result: Analysis) -> None: + """Pre checks to generate the upgrade plan. + + :param args: CLI arguments + :type args: CLIargs + :param analysis_result: Analysis result. + :type analysis_result: Analysis + """ + verify_supported_series(analysis_result) + verify_highest_release_achieved(analysis_result) + + if args.is_data_plane_command: + verify_data_plane_ready_to_upgrade(analysis_result) + + +def verify_supported_series(analysis_result: Analysis) -> None: + """Verify the Ubuntu series of the cloud to see if it is supported. + + :param analysis_result: Analysis result. + :type analysis_result: Analysis + :raises OutOfSupportRange: When series is not supported. + """ + supporting_lts_series = ", ".join(LTS_TO_OS_RELEASE) + current_series = analysis_result.current_cloud_series + if current_series not in LTS_TO_OS_RELEASE: + raise OutOfSupportRange( + f"Cloud series '{current_series}' is not a Ubuntu LTS series supported by COU. " + f"The supporting series are: {supporting_lts_series}" + ) + + +def verify_highest_release_achieved(analysis_result: Analysis) -> None: + """Verify if the highest OpenStack release is reached for the current Ubuntu series. + + :param analysis_result: Analysis result. + :type analysis_result: Analysis + :raises HighestReleaseAchieved: When the OpenStack release is the last supported by the series. + """ + current_os_release = analysis_result.current_cloud_os_release + current_series = analysis_result.current_cloud_series or "" + last_supported = LTS_TO_OS_RELEASE.get(current_series, [])[-1] + if current_os_release and current_series and str(current_os_release) == last_supported: + raise HighestReleaseAchieved( + f"No upgrades available for OpenStack {str(current_os_release).capitalize()} on " + f"Ubuntu {current_series.capitalize()}.\nNewer OpenStack releases " + "may be available after upgrading to a later Ubuntu series." + ) + + +def verify_data_plane_ready_to_upgrade(analysis_result: Analysis) -> None: + """Verify if data plane is ready to upgrade. + + To be able to upgrade data-plane, first all control plane apps should be upgraded. + + :param analysis_result: Analysis result + :type analysis_result: Analysis + :raises DataPlaneCannotUpgrade: When data-plane is not ready to upgrade. + """ + if not analysis_result.min_os_version_data_plane: + raise DataPlaneCannotUpgrade( + "Cannot find data-plane apps. Is this a valid OpenStack cloud?" + ) + if not is_control_plane_upgraded(analysis_result): + raise DataPlaneCannotUpgrade("Please, upgrade control-plane before data-plane") + + +def is_control_plane_upgraded(analysis_result: Analysis) -> bool: + """Check if control plane has been fully upgraded. + + Control-plane will be considered as upgraded when the OpenStack version of it + is higher than the data-plane. + + :param analysis_result: Analysis result + :type analysis_result: Analysis + :return: Whether the control plane is already upgraded or not. + :rtype: bool + """ + control_plane = analysis_result.min_os_version_control_plane + data_plane = analysis_result.min_os_version_data_plane + + return bool(control_plane and data_plane and control_plane > data_plane) + + +def determine_upgrade_target(analysis_result: Analysis) -> OpenStackRelease: + """Determine the target release to upgrade to. + + :param analysis_result: Analysis result. + :type analysis_result: Analysis + :raises NoTargetError: When cannot find target to upgrade + :raises OutOfSupportRange: When the upgrade scope is not supported + by the current series. + :return: The target OS release to upgrade the cloud to. + :rtype: OpenStackRelease + """ + current_os_release, current_series = _get_os_release_and_series(analysis_result) + + target = current_os_release.next_release + if not target: + raise NoTargetError( + "Cannot find target to upgrade. Current minimum OS release is " + f"'{str(current_os_release)}'. Current Ubuntu series is '{current_series}'." + ) + + if ( + current_series + and (supporting_os_release := ", ".join(LTS_TO_OS_RELEASE[current_series])) + and str(current_os_release) not in supporting_os_release + or str(target) not in supporting_os_release + ): + raise OutOfSupportRange( + f"Unable to upgrade cloud from Ubuntu series `{current_series}` to '{target}'. " + "Both the from and to releases need to be supported by the current " + f"Ubuntu series '{current_series}': {supporting_os_release}." + ) + + return target + + +def _get_os_release_and_series(analysis_result: Analysis) -> tuple[OpenStackRelease, str]: + """Get the current OpenStack release and series of the cloud. + + This function also checks if the model passed is a valid OpenStack cloud. + + :param analysis_result: Analysis result + :type analysis_result: Analysis + :raises NoTargetError: When cannot determine the current OS release + or Ubuntu series. + :return: Current OpenStack release and series of the cloud. + :rtype: tuple[OpenStackRelease, str] + """ + current_os_release = analysis_result.current_cloud_os_release + current_series = analysis_result.current_cloud_series + if not current_os_release: + raise NoTargetError( + "Cannot determine the current OS release in the cloud. " + "Is this a valid OpenStack cloud?" + ) + + if not current_series: + raise NoTargetError( + "Cannot determine the current Ubuntu series in the cloud. " + "Is this a valid OpenStack cloud?" + ) + return current_os_release, current_series + + async def generate_plan(analysis_result: Analysis, args: CLIargs) -> UpgradePlan: """Generate plan for upgrade. @@ -63,9 +210,8 @@ async def generate_plan(analysis_result: Analysis, args: CLIargs) -> UpgradePlan :return: Plan with all upgrade steps necessary based on the Analysis. :rtype: UpgradePlan """ - target = determine_upgrade_target( - analysis_result.current_cloud_os_release, analysis_result.current_cloud_series - ) + pre_plan_sanity_checks(args, analysis_result) + target = determine_upgrade_target(analysis_result) plan = UpgradePlan( f"Upgrade cloud from '{analysis_result.current_cloud_os_release}' to '{target}'" @@ -149,75 +295,6 @@ async def create_upgrade_group( return group_upgrade_plan -def determine_upgrade_target( - current_os_release: Optional[OpenStackRelease], current_series: Optional[str] -) -> OpenStackRelease: - """Determine the target release to upgrade to. - - Inform user if the cloud is already at the highest supporting release of the current series. - :param current_os_release: The current minimum OS release in cloud. - :type current_os_release: Optional[OpenStackRelease] - :param current_series: The current minimum Ubuntu series in cloud. - :type current_series: Optional[str] - :raises NoTargetError: When cannot find target to upgrade. - :raises HighestReleaseAchieved: When the highest possible OpenStack release is - already achieved. - :raises OutOfSupportRange: When the OpenStack release or Ubuntu series is out of the current - supporting range. - :return: The target OS release to upgrade the cloud to. - :rtype: OpenStackRelease - """ - if not current_os_release: - raise NoTargetError( - "Cannot determine the current OS release in the cloud. " - "Is this a valid OpenStack cloud?" - ) - - if not current_series: - raise NoTargetError( - "Cannot determine the current Ubuntu series in the cloud. " - "Is this a valid OpenStack cloud?" - ) - - # raise exception if the series is not supported - supporting_lts_series = ", ".join(LTS_TO_OS_RELEASE) - if current_series not in supporting_lts_series: - raise OutOfSupportRange( - f"Cloud series '{current_series}' is not a Ubuntu LTS series supported by COU. " - f"The supporting series are: {supporting_lts_series}" - ) - - # Check if the release is the "last" supported by the series - if str(current_os_release) == LTS_TO_OS_RELEASE[current_series][-1]: - raise HighestReleaseAchieved( - f"No upgrades available for OpenStack {str(current_os_release).capitalize()} on " - f"Ubuntu {current_series.capitalize()}.\nNewer OpenStack releases may be available " - "after upgrading to a later Ubuntu series." - ) - - # get the next release as the target from the current cloud os release - target = current_os_release.next_release - if not target: - raise NoTargetError( - "Cannot find target to upgrade. Current minimum OS release is " - f"'{str(current_os_release)}'. Current Ubuntu series is '{current_series}'." - ) - - supporting_os_release = ", ".join(LTS_TO_OS_RELEASE[current_series]) - # raise exception if the upgrade scope is not supported by the current series - if ( - str(current_os_release) not in supporting_os_release - or str(target) not in supporting_os_release - ): - raise OutOfSupportRange( - f"Unable to upgrade cloud from `{current_series}` to '{target}'. Both the from and " - f"to releases need to be supported by the current Ubuntu series '{current_series}': " - f"{supporting_os_release}." - ) - - return target - - def manually_upgrade_data_plane(analysis_result: Analysis) -> None: """Warning message to upgrade data plane charms if necessary. diff --git a/tests/unit/steps/test_steps_plan.py b/tests/unit/steps/test_steps_plan.py index edbc8f22..2a6e84b9 100644 --- a/tests/unit/steps/test_steps_plan.py +++ b/tests/unit/steps/test_steps_plan.py @@ -17,6 +17,7 @@ from cou.apps.base import OpenStackApplication from cou.exceptions import ( + DataPlaneCannotUpgrade, HaltUpgradePlanGeneration, HighestReleaseAchieved, NoTargetError, @@ -35,7 +36,12 @@ create_upgrade_group, determine_upgrade_target, generate_plan, + is_control_plane_upgraded, manually_upgrade_data_plane, + pre_plan_sanity_checks, + verify_data_plane_ready_to_upgrade, + verify_highest_release_achieved, + verify_supported_series, ) from cou.utils import app_utils from cou.utils.openstack import OpenStackRelease @@ -132,6 +138,7 @@ def generate_expected_upgrade_plan_subordinate(app, target, model): @pytest.mark.asyncio async def test_generate_plan(apps, model, cli_args): + cli_args.is_data_plane_command = False target = OpenStackRelease("victoria") app_keystone = apps["keystone_ussuri"] app_cinder = apps["cinder_ussuri"] @@ -179,6 +186,119 @@ async def test_generate_plan(apps, model, cli_args): assert upgrade_plan == expected_plan +@pytest.mark.parametrize( + "is_data_plane_command, expected_call", + [ + (False, False), + ("data-plane", True), + ], +) +@patch("cou.steps.plan.verify_supported_series") +@patch("cou.steps.plan.verify_highest_release_achieved") +@patch("cou.steps.plan.verify_data_plane_ready_to_upgrade") +def test_pre_plan_sanity_checks( + mock_verify_data_plane_ready_to_upgrade, + mock_verify_highest_release_achieved, + mock_verify_supported_series, + is_data_plane_command, + expected_call, + cli_args, +): + mock_analysis_result = MagicMock(spec=Analysis)() + mock_analysis_result.current_cloud_os_release = OpenStackRelease("ussuri") + mock_analysis_result.current_cloud_series = "focal" + cli_args.is_data_plane_command = is_data_plane_command + pre_plan_sanity_checks(cli_args, mock_analysis_result) + mock_verify_highest_release_achieved.assert_called_once() + mock_verify_supported_series.assert_called_once() + if expected_call: + mock_verify_data_plane_ready_to_upgrade.assert_called_once_with(mock_analysis_result) + else: + mock_verify_data_plane_ready_to_upgrade.assert_not_called() + + +@pytest.mark.parametrize( + "current_os_release, current_series, exp_error_msg", + [ + ( + OpenStackRelease("yoga"), + "jammy", + "Cloud series 'jammy' is not a Ubuntu LTS series supported by COU. " + "The supporting series are: focal", + ), + ( + OpenStackRelease("train"), + "bionic", + "Cloud series 'bionic' is not a Ubuntu LTS series supported by COU. " + "The supporting series are: focal", + ), + ], +) +def test_verify_supported_series(current_os_release, current_series, exp_error_msg): + mock_analysis_result = MagicMock(spec=Analysis)() + with pytest.raises(OutOfSupportRange, match=exp_error_msg): + mock_analysis_result.current_cloud_os_release = current_os_release + mock_analysis_result.current_cloud_series = current_series + verify_supported_series(mock_analysis_result) + + +def test_verify_highest_release_achieved(): + mock_analysis_result = MagicMock(spec=Analysis)() + mock_analysis_result.current_cloud_os_release = OpenStackRelease("yoga") + mock_analysis_result.current_cloud_series = "focal" + exp_error_msg = ( + "No upgrades available for OpenStack Yoga on Ubuntu Focal.\n" + "Newer OpenStack releases may be available after upgrading to a later Ubuntu series." + ) + with pytest.raises(HighestReleaseAchieved, match=exp_error_msg): + verify_highest_release_achieved(mock_analysis_result) + + +@pytest.mark.parametrize( + "min_os_version_control_plane, min_os_version_data_plane, exp_error_msg", + [ + ( + OpenStackRelease("ussuri"), + OpenStackRelease("ussuri"), + "Please, upgrade control-plane before data-plane", + ), + ( + OpenStackRelease("ussuri"), + None, + "Cannot find data-plane apps. Is this a valid OpenStack cloud?", + ), + ], +) +def test_verify_data_plane_ready_to_upgrade_error( + min_os_version_control_plane, min_os_version_data_plane, exp_error_msg, cli_args +): + cli_args.upgrade_group = "data-plane" + mock_analysis_result = MagicMock(spec=Analysis)() + mock_analysis_result.current_cloud_series = "focal" + mock_analysis_result.min_os_version_control_plane = min_os_version_control_plane + mock_analysis_result.min_os_version_data_plane = min_os_version_data_plane + with pytest.raises(DataPlaneCannotUpgrade, match=exp_error_msg): + verify_data_plane_ready_to_upgrade(mock_analysis_result) + + +@pytest.mark.parametrize( + "min_os_version_control_plane, min_os_version_data_plane, expected_result", + [ + (OpenStackRelease("ussuri"), OpenStackRelease("ussuri"), False), + (OpenStackRelease("ussuri"), OpenStackRelease("victoria"), False), + (OpenStackRelease("ussuri"), None, False), + (OpenStackRelease("victoria"), OpenStackRelease("ussuri"), True), + ], +) +def test_is_control_plane_upgraded( + min_os_version_control_plane, min_os_version_data_plane, expected_result +): + mock_analysis_result = MagicMock(spec=Analysis)() + mock_analysis_result.min_os_version_control_plane = min_os_version_control_plane + mock_analysis_result.min_os_version_data_plane = min_os_version_data_plane + assert is_control_plane_upgraded(mock_analysis_result) is expected_result + + @pytest.mark.parametrize( "current_os_release, current_series, next_release", [ @@ -187,16 +307,13 @@ async def test_generate_plan(apps, model, cli_args): ], ) def test_determine_upgrade_target(current_os_release, current_series, next_release): - target = determine_upgrade_target(current_os_release, current_series) - - assert target == next_release + mock_analysis_result = MagicMock(spec=Analysis)() + mock_analysis_result.current_cloud_os_release = current_os_release + mock_analysis_result.current_cloud_series = current_series + target = determine_upgrade_target(mock_analysis_result) -def test_determine_upgrade_target_no_upgrade_available(): - current_os_release = OpenStackRelease("yoga") - current_series = "focal" - with pytest.raises(HighestReleaseAchieved): - determine_upgrade_target(current_os_release, current_series) + assert target == next_release @pytest.mark.parametrize( @@ -216,15 +333,22 @@ def test_determine_upgrade_target_no_upgrade_available(): ), # current_series is None ], ) -def test_determine_upgrade_target_invalid_input(current_os_release, current_series, exp_error_msg): +def test_determine_upgrade_target_current_os_and_series( + current_os_release, current_series, exp_error_msg +): with pytest.raises(NoTargetError, match=exp_error_msg): - determine_upgrade_target(current_os_release, current_series) + mock_analysis_result = MagicMock(spec=Analysis)() + mock_analysis_result.current_cloud_series = current_series + mock_analysis_result.current_cloud_os_release = current_os_release + determine_upgrade_target(mock_analysis_result) def test_determine_upgrade_target_no_next_release(): + mock_analysis_result = MagicMock(spec=Analysis)() + mock_analysis_result.current_cloud_series = "focal" + exp_error_msg = "Cannot find target to upgrade. Current minimum OS release is " "'ussuri'. Current Ubuntu series is 'focal'." - current_series = "focal" with pytest.raises(NoTargetError, match=exp_error_msg), patch( "cou.utils.openstack.OpenStackRelease.next_release", new_callable=PropertyMock @@ -233,20 +357,22 @@ def test_determine_upgrade_target_no_next_release(): current_os_release = OpenStackRelease( "ussuri" ) # instantiate OpenStackRelease with any valid codename - determine_upgrade_target(current_os_release, current_series) + mock_analysis_result.current_cloud_os_release = current_os_release + determine_upgrade_target(mock_analysis_result) -@pytest.mark.parametrize( - "current_os_release, current_series", - [ - (OpenStackRelease("yoga"), "jammy"), - (OpenStackRelease("train"), "bionic"), - (OpenStackRelease("zed"), "focal"), - ], -) -def test_determine_upgrade_target_release_out_of_range(current_os_release, current_series): - with pytest.raises(OutOfSupportRange): - determine_upgrade_target(current_os_release, current_series) +def test_determine_upgrade_target_out_support_range(): + mock_analysis_result = MagicMock(spec=Analysis)() + mock_analysis_result.current_cloud_series = "focal" + mock_analysis_result.current_cloud_os_release = OpenStackRelease("zed") + + exp_error_msg = ( + "Unable to upgrade cloud from Ubuntu series `focal` to 'antelope'. " + "Both the from and to releases need to be supported by the current " + "Ubuntu series 'focal': ussuri, victoria, wallaby, xena, yoga." + ) + with pytest.raises(OutOfSupportRange, match=exp_error_msg): + determine_upgrade_target(mock_analysis_result) @pytest.mark.asyncio