From 5eb0c7359cdd6fdeea9775f996721bde384988d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20LEFER?= Date: Mon, 26 May 2025 11:16:55 +0200 Subject: [PATCH] feat: allow volume deletion --- README.md | 44 ++-- pyproject.toml | 2 +- src/docker_volume_analyzer/docker_client.py | 22 ++ src/docker_volume_analyzer/tui.py | 101 +++++++- src/docker_volume_analyzer/tui.tcss | 34 +++ src/docker_volume_analyzer/volume_manager.py | 16 ++ tests/test_docker_client.py | 60 +++++ tests/test_tui.py | 249 ++++++++++++++++++- tests/test_volume_manager.py | 30 ++- 9 files changed, 531 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 50b6dd8..cbe2774 100644 --- a/README.md +++ b/README.md @@ -11,53 +11,53 @@ Docker Volume Analyzer is a tool designed to simplify the management of Docker v This project aims to make Docker volume management more intuitive and user-friendly. -[![Build Status](https://github.com/glefer/docker-volumes-analyzer/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/glefer/docker-volumes-analyzer/actions) +[![Python Poetry Application](https://github.com/glefer/docker-volumes-analyzer/actions/workflows/main.yml/badge.svg)](https://github.com/glefer/docker-volumes-analyzer/actions/workflows/main.yml) [![codecov](https://codecov.io/gh/glefer/docker-volumes-analyzer/branch/main/graph/badge.svg?token=JRjmc0emjT)](https://codecov.io/gh/glefer/docker-volumes-analyzer) ![Python](https://img.shields.io/badge/python-3.13-blue) - +[![Docker](https://img.shields.io/docker/pulls/glefer/docker-volumes-analyzer)](https://hub.docker.com/r/glefer/docker-volumes-analyzer) ## Installation -### Prérequis +### Prerequisites - **Python** `>=3.13,<4.0.0` -- **Poetry** `>=2.1.2` installé globalement ([lien d’installation](https://python-poetry.org/docs/#installation)) -- Docker en local (si tu veux analyser des volumes) +- **Poetry** `>=2.1.2` installed globally ([installation link](https://python-poetry.org/docs/#installation)) +- Docker installed locally (if you want to analyze volumes) --- -### 1. Cloner le projet +### 1. Clone the project ```bash git clone https://github.com/glefer/docker-volumes-analyzer.git cd docker-volumes-analyzer ``` -### 2. Installer les dépendances +### 2. Install dependencies ```bash poetry install ``` -### 3. Lancer l'application +### 3. Run the application ```bash poetry run start ``` -> ⚠️ L'application utilise le socket Docker à l'emplacement standard : `/var/run/docker.sock` +> ⚠️ The application uses the Docker socket at the standard location: `/var/run/docker.sock` --- -## 🐳 Utilisation via Docker +## 🐳 Usage via Docker -Pas envie d’installer Python ? Utilise simplement l’image Docker : +Don't want to install Python? Simply use the Docker image: ```bash docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -ti glefer/docker-volumes-analyzer:latest ``` -### Utiliser une version spécifique +### Use a specific version ```bash docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -ti glefer/docker-volumes-analyzer:0.1.0 @@ -65,13 +65,13 @@ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -ti glefer/docker-v --- -## Lancer les tests +## Run tests ```bash poetry run pytest ``` -Avec couverture : +With coverage: ```bash poetry run pytest --cov=docker_volume_analyzer @@ -79,15 +79,15 @@ poetry run pytest --cov=docker_volume_analyzer --- -## 🛠 Développement +## 🛠 Development -Lance un shell virtuel : +Start a virtual shell: ```bash poetry shell ``` -Formatage et vérifications : +Formatting and checks: ```bash poetry run pre-commit run --all-files @@ -95,13 +95,13 @@ poetry run pre-commit run --all-files --- -## 🔧 Structure du projet +## 🔧 Project structure ``` . ├── src/ │ └── docker_volume_analyzer/ -│ └── main.py # Point d’entrée +│ └── main.py # Entry point ├── tests/ ├── README.md ├── pyproject.toml @@ -122,11 +122,11 @@ For major changes, please open an issue first to discuss what you would like to --- -## 📝 Licence +## 📝 License -Ce projet est sous licence **MIT** — voir le fichier [LICENSE](./LICENSE). +This project is licensed under the **MIT** license — see the [LICENSE](./LICENSE) file. --- -## 👨‍💻 Auteur +## 👨‍💻 Author [github.com/glefer](https://github.com/glefer) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index f578fe5..d1c19f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "docker-volume-analyzer" version = "0.1.0" -description = "" +description = "Docker volume analyzer" authors = [ {name = "Grégory LEFER",email = "contact@glefer.fr"} ] diff --git a/src/docker_volume_analyzer/docker_client.py b/src/docker_volume_analyzer/docker_client.py index 58efbf3..2db36a8 100644 --- a/src/docker_volume_analyzer/docker_client.py +++ b/src/docker_volume_analyzer/docker_client.py @@ -72,3 +72,25 @@ def get_volume_size(self, volume_name: str) -> str: ["sh", "-c", "du -sh /mnt/docker_volume"], volume_name ) return output.split()[0] if output else "0" + + def remove_volume(self, volume_name: str) -> None: + """ + Removes a Docker volume by name. + + Args: + volume_name (str): Name of the Docker volume to remove. + + Raises: + docker.errors.APIError: If the volume cannot be removed. + """ + try: + volume = self.client.volumes.get(volume_name) + volume.remove(force=True) + except docker.errors.NotFound as e: + raise docker.errors.APIError( + f"Volume '{volume_name}' not found." + ) from e + except docker.errors.APIError as e: + raise docker.errors.APIError( + f"Failed to remove volume '{volume_name}': {e}" + ) from e diff --git a/src/docker_volume_analyzer/tui.py b/src/docker_volume_analyzer/tui.py index f00ab63..19a9fd1 100644 --- a/src/docker_volume_analyzer/tui.py +++ b/src/docker_volume_analyzer/tui.py @@ -3,7 +3,7 @@ from textual.app import App, ComposeResult from textual.containers import Container, Horizontal, Vertical from textual.screen import ModalScreen -from textual.widgets import DataTable, Footer, Header, Static +from textual.widgets import Button, DataTable, Footer, Header, Static from docker_volume_analyzer.volume_manager import VolumeManager @@ -18,6 +18,7 @@ class DockerTUI(App): ("t", "toggle_dark", "Toggle dark mode"), ("ctrl+q", "quit", "Quit"), ("i", "information", "Show information"), + ("d", "delete_volume", "Delete volume"), ] CSS_PATH = "tui.tcss" @@ -90,6 +91,54 @@ def action_information(self): volume_information = self.volumes.get(volume_name[0]) self.push_screen(VolumeDetailScreen(volume_information)) + def action_delete_volume(self): + """ + An action to delete the selected volume. + """ + table = self.query_one(DataTable) + selected_row = table.cursor_row + + if selected_row is None: + return + volume_row = table.get_row_at(selected_row) + if volume_row[2] != 0: + self.push_screen( + ErrorScreen("Cannot delete volume with attached containers.") + ) + return + + def delete_callback(confirmed: bool) -> None: + """ + Callback function to handle the confirmation of volume deletion. + + Args: + confirmed (bool): True if the user confirmed deletion + False otherwise. + """ + if confirmed: + try: + self.manager.delete_volume(volume_row[0]) + row_key, _ = table.coordinate_to_cell_key( + table.cursor_coordinate + ) + table.remove_row(row_key) + self.pop_screen() + self.refresh() + except Exception as e: + self.pop_screen() + self.push_screen( + ErrorScreen(f"Error deleting volume: {e}") + ) + else: + self.pop_screen() + + self.push_screen( + ConfirmationScreen( + f"Are you sure you want to delete volume '{volume_row[0]}'?", + delete_callback, + ) + ) + class VolumeDetailScreen(ModalScreen): """ @@ -158,5 +207,55 @@ def action_back(self) -> None: self.app.refresh() +class ErrorScreen(ModalScreen): + """ + A simple modal screen to display a message. + """ + + def __init__(self, message: str): + super().__init__() + self.message = message + + def compose(self) -> ComposeResult: + yield Header() + with Container(id="dialog"): + yield Static(self.message, classes="message") + yield Footer() + + BINDINGS = [ + ("b", "back", "Back"), + ] + + def action_back(self) -> None: + """Go back to the previous screen.""" + self.app.pop_screen() + + +class ConfirmationScreen(ModalScreen): + """ + A simple modal screen to confirm an action. + """ + + def __init__(self, message: str, callback): + super().__init__() + self.message = message + self.callback = callback + + def compose(self) -> ComposeResult: + yield Header() + with Container(id="dialog"): + yield Static(self.message, classes="question") + with Horizontal(classes="buttons"): + yield Button("No", variant="error", id="no_button") + yield Button("Yes", variant="success", id="yes_button") + yield Footer() + + def on_button_pressed(self, event): + if event.button.id == "yes_button": + self.callback(True) + else: + self.callback(False) + + if __name__ == "__main__": # pragma: no cover DockerTUI().run() diff --git a/src/docker_volume_analyzer/tui.tcss b/src/docker_volume_analyzer/tui.tcss index 304163d..5cd6667 100644 --- a/src/docker_volume_analyzer/tui.tcss +++ b/src/docker_volume_analyzer/tui.tcss @@ -16,6 +16,40 @@ Screen { width: 100%; } +#dialog { + height: 70%; + background: $panel; + color: $text; + border: tall $background; + padding: 1 2; +} + +/* The button class */ +Button { + width: 1fr; +} + +/* Matches the question text */ +.question { + text-style: bold; + height: 100%; + content-align: center middle; +} + +/* Matches the button container */ +.buttons { + width: 100%; + height: auto; + dock: bottom; + padding: 0 20; +} + +.message { + text-style: bold; + height: 100%; + content-align: center middle; +} + .info-panel Static { padding: 0 1; height: auto; diff --git a/src/docker_volume_analyzer/volume_manager.py b/src/docker_volume_analyzer/volume_manager.py index 911c2b8..d380660 100644 --- a/src/docker_volume_analyzer/volume_manager.py +++ b/src/docker_volume_analyzer/volume_manager.py @@ -64,3 +64,19 @@ def get_volume_size(self, volume_name: str) -> int: int: Size of the volume in bytes. """ return self.client.get_volume_size(volume_name) + + def delete_volume(self, volume_name: str) -> bool: + """ + Delete a Docker volume by its name. + + Args: + volume_name (str): Name of the Docker volume to delete. + + Returns: + bool: True if the volume was deleted successfully, False otherwise. + """ + try: + self.client.remove_volume(volume_name) + return True + except Exception: + return False diff --git a/tests/test_docker_client.py b/tests/test_docker_client.py index 4f77aed..70d3c38 100644 --- a/tests/test_docker_client.py +++ b/tests/test_docker_client.py @@ -110,3 +110,63 @@ def test_docker_not_available_error(): # Assert that DockerNotAvailableError is raised with pytest.raises(DockerNotAvailableError): DockerClient() + + +def test_remove_volume_success(): + """ + Test the remove_volume method of DockerClient when + the volume is successfully removed. + """ + mock_client = MagicMock() + mock_volume = MagicMock() + mock_client.volumes.get.return_value = mock_volume + + docker_client = DockerClient() + docker_client.client = mock_client + + docker_client.remove_volume("test_volume") + + mock_client.volumes.get.assert_called_with("test_volume") + mock_volume.remove.assert_called_with(force=True) + + +def test_remove_volume_not_found(): + """ + Test the remove_volume method of DockerClient when the volume is not found. + """ + mock_client = MagicMock() + mock_client.volumes.get.side_effect = docker.errors.NotFound( + "Volume not found" + ) + + docker_client = DockerClient() + docker_client.client = mock_client + + with pytest.raises( + docker.errors.APIError, match="Volume 'test_volume' not found." + ): + docker_client.remove_volume("test_volume") + + mock_client.volumes.get.assert_called_with("test_volume") + + +def test_remove_volume_api_error(): + """ + Test the remove_volume method of DockerClient when an API error occurs. + """ + mock_client = MagicMock() + mock_volume = MagicMock() + mock_client.volumes.get.return_value = mock_volume + mock_volume.remove.side_effect = docker.errors.APIError("API error") + + docker_client = DockerClient() + docker_client.client = mock_client + + with pytest.raises( + docker.errors.APIError, + match="Failed to remove volume 'test_volume': API error", + ): + docker_client.remove_volume("test_volume") + + mock_client.volumes.get.assert_called_with("test_volume") + mock_volume.remove.assert_called_with(force=True) diff --git a/tests/test_tui.py b/tests/test_tui.py index c593038..435b440 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -1,9 +1,14 @@ from unittest.mock import MagicMock, PropertyMock, patch import pytest -from textual.widgets import DataTable +from textual.widgets import Button, DataTable -from docker_volume_analyzer.tui import DockerTUI, VolumeDetailScreen +from docker_volume_analyzer.tui import ( + ConfirmationScreen, + DockerTUI, + ErrorScreen, + VolumeDetailScreen, +) @pytest.mark.asyncio @@ -184,3 +189,243 @@ async def test_action_previous_removes_screen_and_refreshes(): mock_app.pop_screen.assert_called_once() mock_app.refresh.assert_called_once() + + +@pytest.mark.asyncio +async def test_action_delete_volume_with_no_selection(): + """ + Test that the action_delete_volume method does nothing + when no row is selected in the DataTable. + """ + mock_manager = MagicMock() + mock_manager.get_volumes.return_value = { + "volume1": { + "name": "volume1", + "size": "10GB", + "containers": [], + "created_at": "2023-01-01T00:00:00Z", + } + } + + with patch( + "docker_volume_analyzer.tui.VolumeManager", return_value=mock_manager + ): + async with DockerTUI().run_test() as pilot: + await pilot.pause() + + app = pilot.app + table: DataTable = app.query_one( + "#volumes_table", expect_type=DataTable + ) + + # Patch cursor_row to simulate no row being selected + with patch.object( + type(table), "cursor_row", new_callable=PropertyMock + ) as mock_cursor_row: + mock_cursor_row.return_value = None + + app.action_delete_volume() + + # Assertions: no new screen was pushed + assert len(app.screen_stack) == 1 + + +@pytest.mark.asyncio +async def test_action_delete_volume_with_attached_containers(): + """ + Test that the action_delete_volume method shows an error screen + when the selected volume has attached containers. + """ + mock_manager = MagicMock() + mock_manager.get_volumes.return_value = { + "volume1": { + "name": "volume1", + "size": "10GB", + "containers": [{"container_name": "container1"}], + "created_at": "2023-01-01T00:00:00Z", + } + } + + with patch( + "docker_volume_analyzer.tui.VolumeManager", return_value=mock_manager + ): + async with DockerTUI().run_test() as pilot: + await pilot.pause() + + app = pilot.app + table: DataTable = app.query_one( + "#volumes_table", expect_type=DataTable + ) + table.add_row("volume1", "10GB", 1, "2023-01-01T00:00:00Z") + with patch.object( + type(table), "cursor_row", new_callable=PropertyMock + ) as mock_cursor_row: + mock_cursor_row.return_value = ( + 0 # Simulate the cursor on the first row + ) + + app.action_delete_volume() + + # Assertions: an error screen was pushed + assert isinstance(app.screen_stack[-1], ErrorScreen) + assert ( + app.screen_stack[-1].message + == "Cannot delete volume with attached containers." + ) + + +@pytest.mark.asyncio +async def test_action_delete_volume_success(): + """ + Test that the action_delete_volume method deletes the selected volume + when confirmed. + """ + mock_manager = MagicMock() + mock_manager.get_volumes.return_value = { + "volume1": { + "name": "volume1", + "size": "10GB", + "containers": [], + "created_at": "2023-01-01T00:00:00Z", + } + } + + with patch( + "docker_volume_analyzer.tui.VolumeManager", return_value=mock_manager + ): + async with DockerTUI().run_test() as pilot: + await pilot.pause() + + app = pilot.app + table: DataTable = app.query_one( + "#volumes_table", expect_type=DataTable + ) + table.cursor_type = 0 + + # Simulate user confirming the deletion + app.action_delete_volume() + confirmation_screen = app.screen_stack[-1] + assert isinstance(confirmation_screen, ConfirmationScreen) + + # Simulate pressing "Yes" on the confirmation screen + confirmation_screen.on_button_pressed( + Button.Pressed(Button("Yes", id="yes_button")) + ) + + # Assertions + mock_manager.delete_volume.assert_called_once_with("volume1") + assert len(table.rows) == 0 # Volume was removed from the table + + +@pytest.mark.asyncio +async def test_action_delete_volume_not_confirmed(): + """ + Test that the action_delete_volume method does not delete the volume + when the user does not confirm. + """ + mock_manager = MagicMock() + mock_manager.get_volumes.return_value = { + "volume1": { + "name": "volume1", + "size": "10GB", + "containers": [], + "created_at": "2023-01-01T00:00:00Z", + } + } + + with patch( + "docker_volume_analyzer.tui.VolumeManager", return_value=mock_manager + ): + async with DockerTUI().run_test() as pilot: + await pilot.pause() + + app = pilot.app + table: DataTable = app.query_one( + "#volumes_table", expect_type=DataTable + ) + table.cursor_type = 0 + + # Simulate user confirming the deletion + app.action_delete_volume() + confirmation_screen = app.screen_stack[-1] + assert isinstance(confirmation_screen, ConfirmationScreen) + + # Simulate pressing "No" on the confirmation screen + confirmation_screen.on_button_pressed( + Button.Pressed(Button("No", id="no_button")) + ) + + # Assertions + mock_manager.delete_volume.assert_not_called() + assert len(table.rows) == 1 + + +@pytest.mark.asyncio +async def test_action_delete_volume_throw_exception(): + """ + Test that the action_delete_volume method handles exceptions + when trying to delete a volume. + """ + mock_manager = MagicMock() + mock_manager.get_volumes.return_value = { + "volume1": { + "name": "volume1", + "size": "10GB", + "containers": [], + "created_at": "2023-01-01T00:00:00Z", + } + } + mock_manager.delete_volume.side_effect = Exception("Docker error") + + with patch( + "docker_volume_analyzer.tui.VolumeManager", return_value=mock_manager + ): + async with DockerTUI().run_test() as pilot: + await pilot.pause() + + app = pilot.app + table: DataTable = app.query_one( + "#volumes_table", expect_type=DataTable + ) + table.cursor_type = 0 + + # Simulate user confirming the deletion + app.action_delete_volume() + confirmation_screen = app.screen_stack[-1] + assert isinstance(confirmation_screen, ConfirmationScreen) + + # Simulate pressing "Yes" on the confirmation screen + confirmation_screen.on_button_pressed( + Button.Pressed(Button("Yes", id="yes_button")) + ) + + # Assertions: an error screen was pushed + assert isinstance(app.screen_stack[-1], ErrorScreen) + assert ( + app.screen_stack[-1].message + == "Error deleting volume: Docker error" + ) + + +@pytest.mark.asyncio +async def test_error_screen_action_back(): + """ + Test that the action_back method pops the ErrorScreen. + """ + error_message = "This is an error message." + screen = ErrorScreen(error_message) + + # Create a mock app to test screen behavior + mock_app = MagicMock() + + # Patch the 'app' property of the screen to return the mock app + with patch.object( + type(screen), "app", new_callable=PropertyMock + ) as mock_app_prop: + mock_app_prop.return_value = mock_app + + # Call the action_back method + screen.action_back() + + # Assert that the screen was popped + mock_app.pop_screen.assert_called_once() diff --git a/tests/test_volume_manager.py b/tests/test_volume_manager.py index 06c6210..f6929d9 100644 --- a/tests/test_volume_manager.py +++ b/tests/test_volume_manager.py @@ -88,7 +88,6 @@ def test_get_volumes() -> None: num_volumes, max_containers_per_volume=5 ) - # Mock du client Docker mock_client = MagicMock() mock_client.list_volumes.return_value = volumes mock_client.list_containers.return_value = containers @@ -101,3 +100,32 @@ def test_get_volumes() -> None: volume_manager.client = mock_client assert volume_manager.get_volumes() == expected + + +def test_delete_volume_success() -> None: + volume_name = "test_volume" + + mock_client = MagicMock() + mock_client.remove_volume.return_value = True + + # Injecte dans VolumeManager + volume_manager = VolumeManager() + volume_manager.client = mock_client + + assert volume_manager.delete_volume(volume_name) is True + mock_client.remove_volume.assert_called_once_with(volume_name) + + +def test_delete_volume_failure() -> None: + volume_name = "non_existent_volume" + + mock_client = MagicMock() + + mock_client.remove_volume.side_effect = Exception("Volume not found") + + # Injecte dans VolumeManager + volume_manager = VolumeManager() + volume_manager.client = mock_client + + assert volume_manager.delete_volume(volume_name) is False + mock_client.remove_volume.assert_called_once_with(volume_name)