diff --git a/dem/cli/command/uninstall_cmd.py b/dem/cli/command/uninstall_cmd.py new file mode 100644 index 0000000..5f24019 --- /dev/null +++ b/dem/cli/command/uninstall_cmd.py @@ -0,0 +1,29 @@ +"""uninstall CLI command implementation.""" +# dem/cli/command/uninstall_cmd.py + +from dem.core.dev_env import DevEnv +from dem.core.platform import DevEnvLocalSetup +from dem.cli.console import stderr, stdout + + +def try_to_uninstall_dev_env(dev_env_to_uninstall: DevEnv, is_dev_env_installed: str, platform: DevEnvLocalSetup) -> bool: + if is_dev_env_installed == "True": + return platform.try_to_remove_tool_images(dev_env_to_uninstall) + else: + return False + + +def execute(platform: DevEnvLocalSetup, dev_env_name: str) -> None: + dev_env_to_uninstall = platform.get_dev_env_by_name(dev_env_name) + is_dev_env_installed = platform.get_dev_env_status_by_name(dev_env_name) + + + if dev_env_to_uninstall is None: + stderr.print("[red]Error: The [bold]" + dev_env_name + "[/bold] Development Environment doesn't exist.") + else: + if True == try_to_uninstall_dev_env(dev_env_to_uninstall,is_dev_env_installed,platform): + stdout.print("[green]Successfully deleted the " + dev_env_name + "![/]") + platform.update_dev_env_status_in_json(dev_env_to_uninstall) + + else: + stderr.print("[red]Error: The [bold]" + dev_env_name + "[/bold] Development Environment uninstall failed") \ No newline at end of file diff --git a/dem/cli/main.py b/dem/cli/main.py index 08d4e76..51ffd7b 100644 --- a/dem/cli/main.py +++ b/dem/cli/main.py @@ -8,7 +8,7 @@ from dem.cli.command import cp_cmd, info_cmd, list_cmd, pull_cmd, create_cmd, modify_cmd, delete_cmd, \ rename_cmd, run_cmd, export_cmd, load_cmd, clone_cmd, add_reg_cmd, \ list_reg_cmd, del_reg_cmd, add_cat_cmd, list_cat_cmd, del_cat_cmd, \ - add_host_cmd + add_host_cmd, uninstall_cmd from dem.cli.console import stdout from dem.core.platform import DevEnvLocalSetup from dem.core.exceptions import InternalError @@ -194,6 +194,18 @@ def delete(dev_env_name: Annotated[str, typer.Argument(help="Name of the Develop else: raise InternalError("Error: The platform hasn't been initialized properly!") +@typer_cli.command() +def uninstall(dev_env_name: Annotated[str, typer.Argument(help="Name of the Development Environment to uninstall.", + autocompletion=autocomplete_dev_env_name)]) -> None: + """ + Uninstall the Development Environment from the local setup. If a tool image is not required + anymore by any of the available local Development Environments, the DEM will delete it. + """ + if platform is not None: + uninstall_cmd.execute(platform, dev_env_name) + else: + raise InternalError("Error: The platform hasn't been initialized properly!") + @typer_cli.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) def run(dev_env_name: Annotated[str, typer.Argument(help="Run the container in this Development Environment context", autocompletion=autocomplete_dev_env_name)], diff --git a/dem/core/container_engine.py b/dem/core/container_engine.py index 261b518..57810f3 100644 --- a/dem/core/container_engine.py +++ b/dem/core/container_engine.py @@ -106,7 +106,19 @@ def remove(self, image: str) -> None: Args: image -- the tool image to remove """ - self._docker_client.images.remove(image) + retVal=True + try: + self._docker_client.images.remove(image) + except docker.errors.ImageNotFound: + self.user_output.error("[yellow]" + image + " doesn't exist. Unable to remove it.[/]\n") + retVal=False + except docker.errors.APIError: + self.user_output.error("[red]Error: " + image + " is used by a container. Unable to remove it.[/]\n") + retVal=False + else: + self.user_output.msg("[green]Successfully removed![/]\n") + retVal=True + return retVal def search(self, registry: str) -> list[str]: """ Search repository in the axemsolutions registry. diff --git a/dem/core/dev_env.py b/dem/core/dev_env.py index 8af0b9e..cc8e62d 100755 --- a/dem/core/dev_env.py +++ b/dem/core/dev_env.py @@ -33,6 +33,7 @@ def __init__(self, descriptor: dict | None = None, if descriptor: self.name = descriptor["name"] self.tools = descriptor["tools"] + self.installed = descriptor["installed"] else: self.name = dev_env_to_copy.name self.tools = dev_env_to_copy.tools diff --git a/dem/core/platform.py b/dem/core/platform.py index d44fa4b..8dc777f 100644 --- a/dem/core/platform.py +++ b/dem/core/platform.py @@ -10,6 +10,8 @@ from dem.core.tool_images import ToolImages from dem.core.dev_env import DevEnv, DevEnv, DevEnv +import docker.errors + class DevEnvSetup(Core): """ Representation of the Development Platform: - The available tool images. @@ -136,6 +138,7 @@ def get_deserialized(self) -> dict: for dev_env in self.local_dev_envs: dev_env_descriptor = {} dev_env_descriptor["name"] = dev_env.name + dev_env_descriptor["installed"] = dev_env.installed dev_env_descriptor["tools"] = dev_env.tools dev_env_descriptors.append(dev_env_descriptor) dev_env_json_deserialized["development_environments"] = dev_env_descriptors @@ -153,6 +156,30 @@ def get_dev_env_by_name(self, dev_env_name: str) -> ("DevEnv | DevEnv | None"): for dev_env in self.local_dev_envs: if dev_env.name == dev_env_name: return dev_env + + def get_dev_env_status_by_name(self, dev_env_name: str) -> ("DevEnv | DevEnv | None"): + """ Get the Development Environment status by name. + + Args: + dev_env_name -- name of the Development Environment to get + + Return with the instance representing the Development Environment. If the Development + Environment doesn't exist in the setup, return with None. + """ + for dev_env in self.local_dev_envs: + if dev_env.name == dev_env_name: + return dev_env.installed + + def set_dev_env_status_by_name(self, dev_env_name: str, status: str) -> ("DevEnv | DevEnv | None"): + """ Set the Development Environment status by name. + + Args: + dev_env_name -- name of the Development Environment to set + status -- status of the Development Environment + """ + for dev_env in self.local_dev_envs: + if dev_env.name == dev_env_name: + dev_env.installed = status def get_local_dev_env(self, catalog_dev_env: DevEnv) -> DevEnv | None: """ Get the local copy of the catalog's Dev Env if exists. @@ -165,6 +192,43 @@ def get_local_dev_env(self, catalog_dev_env: DevEnv) -> DevEnv | None: for local_dev_env in self.local_dev_envs: if catalog_dev_env.name == local_dev_env.name: return local_dev_env + + + def try_to_remove_tool_images(self, uninstalled_dev_env: DevEnv) -> None: + retVal=True + all_required_tool_images = set() + for dev_env in self.local_dev_envs: + for tool in dev_env.tools: + if (dev_env.installed == "True") and (dev_env.name != uninstalled_dev_env.name) : + all_required_tool_images.add(tool["image_name"] + ":" + tool["image_version"]) + + uninstalled_dev_env_tool_images = set() + for tool in uninstalled_dev_env.tools: + uninstalled_dev_env_tool_images.add(tool["image_name"] + ":" + tool["image_version"]) + + + for tool_image in uninstalled_dev_env_tool_images: + if tool_image in all_required_tool_images: + self.user_output.msg("[yellow] Can't delete" + tool_image + "tool images!") + else: + retVal = self.delete_tool_image(tool_image) + if retVal == False: + return retVal + else: + retVal = True + + + return retVal + + def delete_tool_image(self, tool_image: str) -> bool: + self.user_output.msg("\nThe tool image [bold]" + tool_image + "[/bold] is not required by any Development Environment anymore.") + retVal=self.container_engine.remove(tool_image) + return retVal + + def update_dev_env_status_in_json(self, uninstalled_dev_env: DevEnv): + listindex = self.local_dev_envs.index(uninstalled_dev_env) + self.local_dev_envs[listindex].installed = "False" + self.flush_to_file() class DevEnvLocalSetup(DevEnvSetup): def __init__(self) -> None: diff --git a/docs/commands.md b/docs/commands.md index 556b7d8..ce0f8a3 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -296,4 +296,16 @@ Arguments: `ADDRESS` IP or hostname of the host. [required] +--- + +## **`dem uninstall DEV_ENV_NAME`** + +Uninstall the selected Development Environment. Set installed flag to False if it was True. Dem checks whether a tool image is +required or not by any of the remaining installed local Development Environments. In case the tool image is +not required anymore, the dem delete it. + +Arguments: + +`DEV_ENV_NAME` Name of the Development Environment to uninstall. [required] + --- \ No newline at end of file diff --git a/tests/cli/test_export_cmd.py b/tests/cli/test_export_cmd.py index c0baa11..5955a95 100644 --- a/tests/cli/test_export_cmd.py +++ b/tests/cli/test_export_cmd.py @@ -24,6 +24,7 @@ def test_create_exported_dev_env_json(mock_os_path_isdir,mock_open): dev_env_name = "dev_env_name" dev_env_json = { "name": "Cica", + "installed": "True", "tools": [ { "type": "build system", diff --git a/tests/cli/test_uninstall_cmd.py b/tests/cli/test_uninstall_cmd.py new file mode 100644 index 0000000..db4349c --- /dev/null +++ b/tests/cli/test_uninstall_cmd.py @@ -0,0 +1,103 @@ +"""Tests for the uninstall command.""" + + +# Unit under test: +import dem.cli.main as main +import dem.cli.command.uninstall_cmd as uninstall_cmd + +# Test framework +from typer.testing import CliRunner +from unittest.mock import patch, MagicMock, call + +import docker.errors + +## Global test variables +runner = CliRunner() + + +@patch("dem.cli.command.uninstall_cmd.stderr.print") +def test_uninstall_dev_env_invalid_name(mock_stderr_print): + # Test setup + test_invalid_name = "fake_dev_env_name" + + mock_platform = MagicMock() + mock_platform.get_dev_env_by_name.return_value = None + main.platform = mock_platform + + # Run unit under test + runner_result = runner.invoke(main.typer_cli, ["uninstall", test_invalid_name], color=True) + + # Check expectations + assert 0 == runner_result.exit_code + + mock_platform.get_dev_env_by_name.assert_called_once_with(test_invalid_name) + mock_stderr_print.assert_called_once_with("[red]Error: The [bold]" + test_invalid_name + "[/bold] Development Environment doesn't exist.") + + +@patch("dem.cli.command.uninstall_cmd.stdout.print") +def test_uninstall_dev_env_valid_name(mock_stdout_print): + # Test setup + fake_dev_env_to_uninstall = MagicMock() + fake_dev_env_to_uninstall.name = "dev_env" + fake_dev_env_to_uninstall.installed = "True" + mock_platform = MagicMock() + mock_platform.get_dev_env_by_name.return_value = [fake_dev_env_to_uninstall] + mock_platform.get_dev_env_status_by_name.return_value = "True" + main.platform = mock_platform + mock_platform.try_to_remove_tool_images.return_value = True + + + # Run unit under test + runner_result = runner.invoke(main.typer_cli, ["uninstall", fake_dev_env_to_uninstall.name ], color=True) + + # Check expectations + assert 0 == runner_result.exit_code + + mock_platform.get_dev_env_by_name.assert_called_once_with(fake_dev_env_to_uninstall.name ) + mock_stdout_print.assert_called_once_with("[green]Successfully deleted the " + fake_dev_env_to_uninstall.name + "![/]") + + +@patch("dem.cli.command.uninstall_cmd.stderr.print") +def test_uninstall_dev_env_valid_name_not_installed(mock_stderr_print): + # Test setup + fake_dev_env_to_uninstall = MagicMock() + fake_dev_env_to_uninstall.name = "dev_env" + fake_dev_env_to_uninstall.installed = "False" + mock_platform = MagicMock() + mock_platform.get_dev_env_by_name.return_value = [fake_dev_env_to_uninstall] + mock_platform.get_dev_env_status_by_name.return_value = "False" + main.platform = mock_platform + + + # Run unit under test + runner_result = runner.invoke(main.typer_cli, ["uninstall", fake_dev_env_to_uninstall.name ], color=True) + + # Check expectations + assert 0 == runner_result.exit_code + + mock_platform.get_dev_env_by_name.assert_called_once_with(fake_dev_env_to_uninstall.name ) + mock_stderr_print.assert_called_once_with("[red]Error: The [bold]" + fake_dev_env_to_uninstall.name + "[/bold] Development Environment uninstall failed") + + +@patch("dem.cli.command.uninstall_cmd.stderr.print") +def test_uninstall_dev_env_valid_name_failed(mock_stderr_print): + # Test setup + fake_dev_env_to_uninstall = MagicMock() + fake_dev_env_to_uninstall.name = "dev_env" + fake_dev_env_to_uninstall.installed = "True" + mock_platform = MagicMock() + mock_platform.get_dev_env_by_name.return_value = [fake_dev_env_to_uninstall] + mock_platform.get_dev_env_status_by_name.return_value = "True" + mock_platform.try_to_remove_tool_images.return_value = False + main.platform = mock_platform + + + # Run unit under test + runner_result = runner.invoke(main.typer_cli, ["uninstall", fake_dev_env_to_uninstall.name ], color=True) + + # Check expectations + assert 0 == runner_result.exit_code + + mock_platform.get_dev_env_by_name.assert_called_once_with(fake_dev_env_to_uninstall.name ) + mock_stderr_print.assert_called_once_with("[red]Error: The [bold]" + fake_dev_env_to_uninstall.name + "[/bold] Development Environment uninstall failed") + diff --git a/tests/core/test_dev_env.py b/tests/core/test_dev_env.py index 8946715..ac6895b 100644 --- a/tests/core/test_dev_env.py +++ b/tests/core/test_dev_env.py @@ -11,6 +11,7 @@ def test_DevEnv(): # Test setup test_descriptor = { "name": "test_name", + "installed": "True", "tools": [MagicMock()] } @@ -38,6 +39,7 @@ def test_DevEnv_check_image_availability(): # Test setup test_descriptor = { "name": "test_name", + "installed": "True", "tools": [ { "image_name": "test_image_name1", @@ -91,6 +93,7 @@ def test_DevEnv_check_image_availability_local_only(): # Test setup test_descriptor = { "name": "test_name", + "installed": "True", "tools": [ { "image_name": "test_image_name1", diff --git a/tests/fake_data.py b/tests/fake_data.py index 3864326..3225a55 100644 --- a/tests/fake_data.py +++ b/tests/fake_data.py @@ -2,6 +2,7 @@ "version": "0.1", "development_environments": [{ "name": "demo", + "installed": "True", "tools": [{ "type": "build system", "image_name": "axemsolutions/make_gnu_arm", @@ -31,6 +32,7 @@ }, { "name": "nagy_cica_project", + "installed": "True", "tools": [{ "type": "build system", "image_name": "axemsolutions/bazel", @@ -72,6 +74,7 @@ "version": "0.1", "development_environments": [{ "name": "demo", + "installed": "False", "tools": [{ "type": "build_system", "image_name": "axemsolutions/make_gnu_arm",