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 7961335b..caf61b0d 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 @@ -37,8 +37,9 @@ OpenStackSubordinateApplication, SubordinateBaseClass, ) -from cou.commands import Namespace +from cou.commands import DATA_PLANE, Namespace from cou.exceptions import ( + DataPlaneCannotUpgrade, HaltUpgradePlanGeneration, HighestReleaseAchieved, NoTargetError, @@ -53,6 +54,150 @@ logger = logging.getLogger(__name__) +def pre_plan_sane_checks(args: Namespace, analysis_result: Analysis) -> None: + """Pre checks to generate the upgrade plan. + + :param args: CLI arguments + :type args: Namespace + :param analysis_result: Analysis result. + :type analysis_result: Analysis + """ + is_valid_openstack_cloud(analysis_result) + is_supported_series(analysis_result) + is_highest_release_achieved(analysis_result) + + if args.upgrade_group == DATA_PLANE: + is_data_plane_ready_to_upgrade(analysis_result) + + +def is_valid_openstack_cloud(analysis_result: Analysis) -> None: + """Check 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. + """ + if not analysis_result.current_cloud_os_release: + raise NoTargetError( + "Cannot determine the current OS release in the cloud. " + "Is this a valid OpenStack cloud?" + ) + + if not analysis_result.current_cloud_series: + raise NoTargetError( + "Cannot determine the current Ubuntu series in the cloud. " + "Is this a valid OpenStack cloud?" + ) + + +def is_supported_series(analysis_result: Analysis) -> None: + """Check 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) + # series already checked at is_valid_openstack_cloud + if ( + current_series := analysis_result.current_cloud_series + ) and 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 is_highest_release_achieved(analysis_result: Analysis) -> None: + """Check if the highest OpenStack release is reached by the ubuntu series. + + :param analysis_result: Analysis result. + :type analysis_result: Analysis + :raises HighestReleaseAchieved: When the OpenStack release is the last supported by the series. + """ + if ( + (current_os_release := analysis_result.current_cloud_os_release) + and (current_series := analysis_result.current_cloud_series) + and 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." + ) + + +def is_data_plane_ready_to_upgrade(analysis_result: Analysis) -> None: + """Check 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 is already upgraded. + + Control-plane will be considered as upgraded when the OpenStack version of it + is bigger 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 + """ + if ( + (current_os_release := analysis_result.current_cloud_os_release) + and (current_series := analysis_result.current_cloud_series) + and not (target := current_os_release.next_release) + ): + 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 `{current_series}` to '{target}'. " + "Both the from and to releases need to be supported by the current " + "Ubuntu series '{current_series}': {supporting_os_release}." + ) + + return target # type: ignore + + async def generate_plan(analysis_result: Analysis, args: Namespace) -> UpgradePlan: """Generate plan for upgrade. @@ -63,9 +208,8 @@ async def generate_plan(analysis_result: Analysis, args: Namespace) -> UpgradePl :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_sane_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 +293,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 360dc73a..552bdfd5 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,9 @@ create_upgrade_group, determine_upgrade_target, generate_plan, + is_control_plane_upgraded, manually_upgrade_data_plane, + pre_plan_sane_checks, ) from cou.utils import app_utils from cou.utils.openstack import OpenStackRelease @@ -180,23 +183,26 @@ async def test_generate_plan(apps, model, cli_args): @pytest.mark.parametrize( - "current_os_release, current_series, next_release", + "upgrade_group, expected_call", [ - (OpenStackRelease("victoria"), "focal", "wallaby"), - (OpenStackRelease("xena"), "focal", "yoga"), + (None, False), + ("control-plane", False), + ("data-plane", True), ], ) -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 - - -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) +@patch("cou.steps.plan.is_data_plane_ready_to_upgrade") +def test_pre_plan_sane_checks( + mock_is_data_plane_ready_to_upgrade, upgrade_group, 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.upgrade_group = upgrade_group + pre_plan_sane_checks(cli_args, mock_analysis_result) + if expected_call: + mock_is_data_plane_ready_to_upgrade.assert_called_once_with(mock_analysis_result) + else: + mock_is_data_plane_ready_to_upgrade.assert_not_called() @pytest.mark.parametrize( @@ -216,15 +222,107 @@ 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_pre_plan_sane_checks_is_valid_openstack_cloud( + current_os_release, current_series, exp_error_msg, cli_args +): 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 + pre_plan_sane_checks(cli_args, mock_analysis_result) + + +@pytest.mark.parametrize( + "current_os_release, current_series", + [ + (OpenStackRelease("yoga"), "jammy"), + (OpenStackRelease("train"), "bionic"), + ], +) +def test_pre_plan_sane_checks_is_supported_series(current_os_release, current_series, cli_args): + mock_analysis_result = MagicMock(spec=Analysis)() + with pytest.raises(OutOfSupportRange): + mock_analysis_result.current_cloud_os_release = current_os_release + mock_analysis_result.current_cloud_series = current_series + pre_plan_sane_checks(cli_args, mock_analysis_result) + + +def test_pre_plan_sane_checks_is_highest_release_achieved(cli_args): + mock_analysis_result = MagicMock(spec=Analysis)() + mock_analysis_result.current_cloud_os_release = OpenStackRelease("yoga") + mock_analysis_result.current_cloud_series = "focal" + with pytest.raises(HighestReleaseAchieved): + pre_plan_sane_checks(cli_args, 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_pre_plan_sane_checks_is_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): + pre_plan_sane_checks(cli_args, 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", + [ + (OpenStackRelease("victoria"), "focal", "wallaby"), + (OpenStackRelease("xena"), "focal", "yoga"), + ], +) +def test_determine_upgrade_target(current_os_release, current_series, 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) + + assert target == next_release 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 +331,17 @@ 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): +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") + with pytest.raises(OutOfSupportRange): - determine_upgrade_target(current_os_release, current_series) + determine_upgrade_target(mock_analysis_result) @pytest.mark.asyncio