diff --git a/docs/releases/pending/4593.fmf b/docs/releases/pending/4593.fmf new file mode 100644 index 0000000000..08369fa94e --- /dev/null +++ b/docs/releases/pending/4593.fmf @@ -0,0 +1,3 @@ +description: | + The ``tmt clean`` command now shows the size of each workdir + and a summary of total disk space freed. diff --git a/tests/clean/runs/test.sh b/tests/clean/runs/test.sh index fc9b26e3b2..4f559ae554 100755 --- a/tests/clean/runs/test.sh +++ b/tests/clean/runs/test.sh @@ -22,6 +22,7 @@ rlJournalStart rlRun -s "tmt clean runs --dry -v --workdir-root $tmprun" rlAssertGrep "Would remove workdir '$run1'" "$rlRun_LOG" rlAssertGrep "Would remove workdir '$run2'" "$rlRun_LOG" + rlAssertGrep "Summary: Would free.*of disk space" "$rlRun_LOG" rlRun -s "tmt status --workdir-root $tmprun -vv" rlAssertGrep "(done\s+){1}(todo\s+){6}$run1\s+/plan1" "$rlRun_LOG" -E rlAssertGrep "(done\s+){1}(todo\s+){6}$run2\s+/plan1" "$rlRun_LOG" -E @@ -30,12 +31,14 @@ rlJournalStart rlPhaseStartTest "Specify ID" rlRun -s "tmt clean runs -v -i $run1" rlAssertGrep "Removing workdir '$run1'" "$rlRun_LOG" + rlAssertGrep "Summary: Freed.*of disk space" "$rlRun_LOG" rlRun -s "tmt status --workdir-root $tmprun -vv" rlAssertNotGrep "(done\s+){1}(todo\s+){6}$run1\s+/plan1" "$rlRun_LOG" -E rlAssertGrep "(done\s+){1}(todo\s+){6}$run2\s+/plan1" "$rlRun_LOG" -E rlRun -s "tmt clean runs -v -l --workdir-root $tmprun" rlAssertGrep "Removing workdir '$run2'" "$rlRun_LOG" + rlAssertGrep "Summary: Freed.*of disk space" "$rlRun_LOG" rlRun -s "tmt status --workdir-root $tmprun -vv" rlAssertNotGrep "(done\s+){1}(todo\s+){6}$run2\s+/plan1" "$rlRun_LOG" -E diff --git a/tmt/base/core.py b/tmt/base/core.py index 11a0a84d2b..bda619d807 100644 --- a/tmt/base/core.py +++ b/tmt/base/core.py @@ -42,6 +42,7 @@ import tmt.convert import tmt.export import tmt.frameworks +import tmt.hardware import tmt.identifier import tmt.lint import tmt.log @@ -3613,6 +3614,11 @@ def show(self) -> None: CleanCallback = Callable[[], bool] +def _dir_size(path: Path) -> int: + """Return the total size in bytes of all files under path.""" + return sum(f.lstat().st_size for f in path.rglob('*')) + + class Clean(tmt.utils.Common): """ A class for cleaning up workdirs, guests or images @@ -3751,20 +3757,22 @@ def guests(self, run_ids: tuple[str, ...], keep: Optional[int]) -> bool: successful = False return successful - def _clean_workdir(self, path: Path) -> bool: + def _clean_workdir(self, path: Path) -> tuple[bool, int]: """ Remove a workdir (unless in dry mode) """ + size = _dir_size(path) + formatted_size = round(tmt.hardware.UNITS(f'{size} bytes').to_compact(), 1) if self.is_dry_run: - self.verbose(f"Would remove workdir '{path}'.", shift=1) + self.verbose(f"Would remove workdir '{path}' ({formatted_size}).", shift=1) else: - self.verbose(f"Removing workdir '{path}'.", shift=1) + self.verbose(f"Removing workdir '{path}' ({formatted_size}).", shift=1) try: shutil.rmtree(path) except OSError as error: self.warn(f"Failed to remove '{path}': {error}.", shift=1) - return False - return True + return False, 0 + return True, size def runs(self, id_: tuple[str, ...], keep: Optional[int]) -> bool: """ @@ -3777,7 +3785,13 @@ def runs(self, id_: tuple[str, ...], keep: Optional[int]) -> bool: # the correct one. last_run = Run(logger=self._logger, cli_invocation=self.cli_invocation) last_run.load_workdir(with_logfiles=False) - return self._clean_workdir(last_run.run_workdir) + success, size = self._clean_workdir(last_run.run_workdir) + self.verbose( + f"Summary: {'Would free' if self.is_dry_run else 'Freed'} " + f"{round(tmt.hardware.UNITS(f'{size} bytes').to_compact(), 1)} of disk space.", + shift=1, + ) + return success all_workdirs = list(tmt.utils.generate_runs(self.workdir_root, id_, all_=True)) if keep is not None: # Sort by change time of the workdirs and keep the newest workdirs @@ -3785,10 +3799,18 @@ def runs(self, id_: tuple[str, ...], keep: Optional[int]) -> bool: all_workdirs = all_workdirs[keep:] successful = True + total_size = 0 for workdir in all_workdirs: - if not self._clean_workdir(workdir): + success, size = self._clean_workdir(workdir) + if not success: successful = False + total_size += size + self.verbose( + f"Summary: {'Would free' if self.is_dry_run else 'Freed'} " + f"{round(tmt.hardware.UNITS(f'{total_size} bytes').to_compact(), 1)} of disk space.", + shift=1, + ) return successful diff --git a/tmt/steps/provision/testcloud.py b/tmt/steps/provision/testcloud.py index 4acc8ae033..b1eac1ca36 100644 --- a/tmt/steps/provision/testcloud.py +++ b/tmt/steps/provision/testcloud.py @@ -1589,16 +1589,25 @@ def clean_images(cls, clean: 'tmt.base.core.Clean', dry: bool, workdir_root: Pat clean.warn(f"Directory '{testcloud_images}' does not exist.", shift=2) return True successful = True + total_size = 0 for image in testcloud_images.iterdir(): + size = image.stat().st_size + total_size += size + formatted_size = round(tmt.hardware.UNITS(f'{size} bytes').to_compact(), 1) if dry: - clean.verbose(f"Would remove '{image}'.", shift=2) + clean.verbose(f"Would remove '{image}' ({formatted_size}).", shift=2) else: - clean.verbose(f"Removing '{image}'.", shift=2) + clean.verbose(f"Removing '{image}' ({formatted_size}).", shift=2) try: image.unlink() except OSError: clean.fail(f"Failed to remove '{image}'.", shift=2) successful = False + clean.verbose( + f"Summary: {'Would free' if dry else 'Freed'} " + f"{round(tmt.hardware.UNITS(f'{total_size} bytes').to_compact(), 1)} of disk space.", + shift=2, + ) return successful