diff --git a/cou/exceptions.py b/cou/exceptions.py index 70f84f41..ba66c2e8 100644 --- a/cou/exceptions.py +++ b/cou/exceptions.py @@ -67,6 +67,10 @@ class RunUpgradeError(COUException): """Exception raised when an upgrade fails.""" +class DataPlaneMachineFilterError(COUException): + """Exception raised when filtering data-plane machines fails.""" + + class ActionFailed(COUException): """Exception raised when action fails.""" diff --git a/cou/steps/analyze.py b/cou/steps/analyze.py index 70278145..33c0d759 100644 --- a/cou/steps/analyze.py +++ b/cou/steps/analyze.py @@ -21,6 +21,7 @@ from cou.apps.base import OpenStackApplication from cou.apps.factory import AppFactory +from cou.apps.machine import Machine from cou.utils import juju_utils from cou.utils.openstack import DATA_PLANE_CHARMS, UPGRADE_ORDER, OpenStackRelease @@ -203,3 +204,38 @@ def _get_minimum_cloud_series(self) -> Optional[str]: (app.series for app in self.apps_control_plane + self.apps_data_plane), default=None, ) + + @property + def data_plane_machines(self) -> dict[str, Machine]: + """Data-plane machines of the model. + + :return: Data-plane machines of the model. + :rtype: dict[str, Machine] + """ + return { + machine_id: app.machines[machine_id] + for app in self.apps_data_plane + for machine_id in app.machines + } + + @property + def control_plane_machines(self) -> dict[str, Machine]: + """Control-plane machines of the model. + + :return: Control-plane machines of the model. + :rtype: dict[str, Machine] + """ + return { + machine_id: app.machines[machine_id] + for app in self.apps_control_plane + for machine_id in app.machines + } + + @property + def machines(self) -> dict[str, Machine]: + """All machines of the model. + + :return: All machines of the model. + :rtype: dict[str, Machine] + """ + return {**self.data_plane_machines, **self.control_plane_machines} diff --git a/cou/steps/plan.py b/cou/steps/plan.py index 1b447b5e..a6ef9880 100644 --- a/cou/steps/plan.py +++ b/cou/steps/plan.py @@ -15,7 +15,7 @@ """Upgrade planning utilities.""" import logging -from typing import Callable +from typing import Callable, Optional, cast # NOTE we need to import the modules to register the charms with the register_application # decorator @@ -40,6 +40,7 @@ from cou.commands import CLIargs from cou.exceptions import ( DataPlaneCannotUpgrade, + DataPlaneMachineFilterError, HaltUpgradePlanGeneration, HighestReleaseAchieved, NoTargetError, @@ -67,6 +68,7 @@ def pre_plan_sanity_checks(args: CLIargs, analysis_result: Analysis) -> None: if args.is_data_plane_command: verify_data_plane_ready_to_upgrade(analysis_result) + verify_data_plane_cli_input(args, analysis_result) def verify_supported_series(analysis_result: Analysis) -> None: @@ -172,6 +174,138 @@ def determine_upgrade_target(analysis_result: Analysis) -> OpenStackRelease: return target +def verify_data_plane_cli_input(args: CLIargs, analysis_result: Analysis) -> None: + """Sane checks from the parameters passed in the cli to upgrade data-plane. + + :param args: CLI arguments + :type args: CLIargs + :param analysis_result: Analysis result + :type analysis_result: Analysis + """ + if cli_machines := args.machines: + verify_data_plane_cli_machines(cli_machines, analysis_result) + + elif cli_hostnames := args.hostnames: + verify_data_plane_cli_hostnames(cli_hostnames, analysis_result) + + elif cli_azs := args.availability_zones: + verify_data_plane_cli_azs(cli_azs, analysis_result) + + +def verify_data_plane_cli_machines(cli_machines: list[str], analysis_result: Analysis) -> None: + """Verify if the machines passed from the CLI are valid. + + :param cli_machines: Machines passed to the CLI as arguments + :type cli_machines: list[str] + :param analysis_result: Analysis result + :type analysis_result: Analysis + """ + data_plane_membership_check( + all_options=set(analysis_result.machines.keys()), + data_plane_options=set(analysis_result.data_plane_machines.keys()), + cli_input=parametrize_cli_inputs(cli_machines), + parameter_type="Machine(s)", + ) + + +def verify_data_plane_cli_hostnames(cli_hostnames: list[str], analysis_result: Analysis) -> None: + """Verify if the hostnames passed from the CLI are valid. + + :param cli_hostnames: Hostnames passed to the CLI as arguments + :type cli_hostnames: list[str] + :param analysis_result: Analysis result + :type analysis_result: Analysis + """ + all_hostnames = {machine.hostname for machine in analysis_result.machines.values()} + data_plane_hostnames = { + machine.hostname for machine in analysis_result.data_plane_machines.values() + } + + data_plane_membership_check( + all_options=all_hostnames, + data_plane_options=data_plane_hostnames, + cli_input=parametrize_cli_inputs(cli_hostnames), + parameter_type="Hostname(s)", + ) + + +def verify_data_plane_cli_azs(cli_azs: list[str], analysis_result: Analysis) -> None: + """Verify if the availability zones passed from the CLI are valid. + + :param cli_azs: AZs passed to the CLI as arguments + :type cli_azs: list[str] + :param analysis_result: Analysis result + :type analysis_result: Analysis + :raises DataPlaneMachineFilterError: When the cloud does not have availability zones. + """ + all_azs_uncast: set[Optional[str]] = { + machine.az for machine in analysis_result.machines.values() + } + data_plane_azs_uncast: set[Optional[str]] = { + machine.az for machine in analysis_result.data_plane_machines.values() + } + + # NOTE(gabrielcocenza) simple deployments may not have AZs. mypy does not understand + # that None is being removed from set, so the type goes from set[Optional[str]] to + # set[str]. As we can be sure of that, we can cast with the correct type to mypy. + all_azs_uncast.discard(None) + data_plane_azs_uncast.discard(None) + + all_azs = cast(set[str], all_azs_uncast) + data_plane_azs = cast(set[str], data_plane_azs_uncast) + + if data_plane_azs and all_azs: + data_plane_membership_check( + all_options=all_azs, + data_plane_options=data_plane_azs, + cli_input=parametrize_cli_inputs(cli_azs), + parameter_type="Availability Zone(s)", + ) + + raise DataPlaneMachineFilterError( + "Cannot find Availability Zone(s). Is this a valid OpenStack cloud?" + ) + + +def parametrize_cli_inputs(cli_input: list[str]) -> set[str]: + """Parametrize the cli inputs. + + :param cli_input: cli inputs. + :type cli_input: list[str] + :return: A set of elements passed in the cli. + :rtype: set[str] + """ + return {raw_item.strip() for raw_items in cli_input for raw_item in raw_items.split(",")} + + +def data_plane_membership_check( + all_options: set[str], + data_plane_options: set[str], + cli_input: set[str], + parameter_type: str, +) -> None: + """Check if the parameter passed are member of data-plane. + + :param all_options: All possible options for a parameter. + :type all_options: set[str] + :param data_plane_options: All data-plane possible options for a parameter. + :type data_plane_options: set[str] + :param cli_input: The input that come from the cli + :type cli_input: set[str] + :param parameter_type: Type of the parameter passed (az, hostname or machine). + :type parameter_type: str + :raises DataPlaneMachineFilterError: When the value passed from the user is not sane. + """ + if not cli_input.issubset(all_options): + raise DataPlaneMachineFilterError( + f"{parameter_type}: {cli_input - all_options} don't exist." + ) + if not cli_input.issubset(data_plane_options): + raise DataPlaneMachineFilterError( + f"{parameter_type}: {cli_input - data_plane_options} are not considered as data-plane." + ) + + def _get_os_release_and_series(analysis_result: Analysis) -> tuple[OpenStackRelease, str]: """Get the current OpenStack release and series of the cloud. diff --git a/tests/unit/steps/test_steps.py b/tests/unit/steps/test_steps.py index ad5d4563..c60917ea 100644 --- a/tests/unit/steps/test_steps.py +++ b/tests/unit/steps/test_steps.py @@ -213,7 +213,7 @@ def test_step_add_step(): def test_step_add_step_failed(): """Test BaseStep adding sub steps failing.""" - exp_error_msg = "only steps that are derived from BaseStep are supported" + exp_error_msg = "Cannot add an upgrade step that is not derived from BaseStep" plan = BaseStep(description="plan") with pytest.raises(TypeError, match=exp_error_msg):