Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/releases/pending/4593.fmf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
description: |
The ``tmt clean`` command now shows the size of each workdir
and a summary of total disk space freed.
3 changes: 3 additions & 0 deletions tests/clean/runs/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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
Expand All @@ -29,12 +30,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

Expand Down
54 changes: 37 additions & 17 deletions tmt/base/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import tmt.export
import tmt.frameworks
import tmt.guest
import tmt.hardware
import tmt.identifier
import tmt.lint
import tmt.log
Expand Down Expand Up @@ -74,6 +75,8 @@
from tmt.utils.themes import style

if TYPE_CHECKING:
from pint import Quantity

import tmt.cli
from tmt.base.plan import Plan
from tmt.base.run import Run
Expand Down Expand Up @@ -3007,6 +3010,11 @@ def show(self) -> None:
CleanCallback = Callable[[], bool]


def _dir_size(path: Path) -> 'Quantity':
"""Return the total size in bytes of all files under path."""
return tmt.hardware.UNITS(f'{sum(f.lstat().st_size for f in path.rglob("*"))} bytes')


class Clean(tmt.utils.Common):
"""
A class for cleaning up workdirs, guests or images
Expand Down Expand Up @@ -3154,20 +3162,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, 'Quantity']:
"""
Remove a workdir (unless in dry mode)
"""
size = _dir_size(path)
formatted_size = tmt.hardware.format_compact(size)
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, tmt.hardware.UNITS('0 bytes')
return True, size

def runs(self, id_: tuple[str, ...], keep: Optional[int]) -> bool:
"""
Expand All @@ -3184,18 +3194,28 @@ 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)
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
all_workdirs.sort(key=lambda workdir: workdir.stat().st_ctime, reverse=True)
all_workdirs = all_workdirs[keep:]

successful = True
for workdir in all_workdirs:
if not self._clean_workdir(workdir):
successful = False

successful, total_size = self._clean_workdir(last_run.run_workdir)
else:
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
all_workdirs.sort(key=lambda workdir: workdir.stat().st_ctime, reverse=True)
all_workdirs = all_workdirs[keep:]

successful = True
total_size = tmt.hardware.UNITS('0 bytes')
for workdir in all_workdirs:
success, size = self._clean_workdir(workdir)
if not success:
successful = False
total_size += size # type: ignore[misc]

self.info(
f"Summary: {'Would free' if self.is_dry_run else 'Freed'} "
f"{tmt.hardware.format_compact(total_size)} "
f"of disk space.",
shift=1,
)
return successful


Expand Down
11 changes: 11 additions & 0 deletions tmt/hardware/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
[1] https://tmt.readthedocs.io/en/stable/spec/hardware.html
"""

from typing import TYPE_CHECKING

from tmt.hardware.constraints import (
UNITS,
Constraint,
Expand All @@ -38,6 +40,14 @@
)
from tmt.hardware.requirements import Hardware

if TYPE_CHECKING:
from pint import Quantity


def format_compact(quantity: 'Quantity', digits: int = 1) -> str:
return str(round(quantity.to_compact(), digits)) # pyright: ignore[reportUnknownArgumentType]


__all__ = [
'UNITS',
'Constraint',
Expand All @@ -48,4 +58,5 @@
'Operator',
'SizeConstraint',
'TextConstraint',
'format_compact',
]
16 changes: 14 additions & 2 deletions tmt/steps/provision/testcloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -1590,16 +1590,28 @@ 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 = tmt.hardware.UNITS('0 bytes')
for image in testcloud_images.iterdir():
size = tmt.hardware.UNITS(f'{image.stat().st_size} bytes')
formatted_size = tmt.hardware.format_compact(size)
if dry:
clean.verbose(f"Would remove '{image}'.", shift=2)
clean.verbose(f"Would remove '{image}' ({formatted_size}).", shift=2)
total_size += size # type: ignore[misc]
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
else:
total_size += size # type: ignore[misc]
clean.info(
f"Summary: {'Would free' if dry else 'Freed'} "
f"{tmt.hardware.format_compact(total_size)} "
f"of disk space.",
shift=2,
)
return successful


Expand Down
Loading