Skip to content

Commit f9b1b50

Browse files
authored
feat(RELEASE-2474): add cleanup-workspace Python script (#854)
Replace the inline bash logic from the cleanup-workspace Tekton task with a standalone Python module. The script reuses cleanup_internal_requests.run() for InternalRequest deletion, sleeps for a configurable delay, then removes the target subdirectory. Assisted-by: Cursor Signed-off-by: Lubomir Gallovic <lgallovi@redhat.com>
1 parent 586cbe7 commit f9b1b50

2 files changed

Lines changed: 277 additions & 0 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
#!/usr/bin/env python3
2+
"""Clean up a workspace directory and InternalRequest CRs for a PipelineRun."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import shutil
8+
import time
9+
from pathlib import Path
10+
11+
import cleanup_internal_requests
12+
from logger import logger
13+
14+
PROG = "cleanup_workspace.py"
15+
16+
17+
def cleanup_directory(workspace_path: str, subdirectory: str) -> None:
18+
"""Remove the specified subdirectory from the workspace.
19+
20+
Skip silently when the directory does not exist.
21+
"""
22+
cleanup_dir = Path(workspace_path) / subdirectory
23+
if cleanup_dir.is_dir():
24+
shutil.rmtree(cleanup_dir)
25+
logger.info("Removed directory: %s", cleanup_dir)
26+
else:
27+
logger.info("Directory does not exist, nothing to remove: %s", cleanup_dir)
28+
29+
30+
def run(
31+
subdirectory: str,
32+
delay: int,
33+
pipeline_run_uid: str,
34+
workspace_path: str,
35+
) -> None:
36+
"""Orchestrate workspace cleanup.
37+
38+
Delete matching InternalRequest CRs, wait for the configured delay,
39+
then remove the target subdirectory.
40+
"""
41+
cleanup_internal_requests.run(pipeline_run_uid)
42+
43+
if not subdirectory:
44+
logger.info("The empty string is not a valid subdirectory")
45+
return
46+
47+
logger.info("Delaying execution by %d seconds", delay)
48+
time.sleep(delay)
49+
50+
cleanup_directory(workspace_path, subdirectory)
51+
52+
53+
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
54+
"""Parse command-line arguments."""
55+
parser = argparse.ArgumentParser(description=__doc__, prog=PROG)
56+
parser.add_argument(
57+
"--subdirectory",
58+
default="",
59+
help="The directory to remove within the workspace",
60+
)
61+
parser.add_argument(
62+
"--delay",
63+
type=int,
64+
default=60,
65+
help="Time in seconds to delay the cleanup action",
66+
)
67+
parser.add_argument(
68+
"--pipeline-run-uid",
69+
default="",
70+
help=("UID of the PipelineRun whose InternalRequests should be deleted"),
71+
)
72+
parser.add_argument(
73+
"--workspace-path",
74+
required=True,
75+
help="Path to the workspace directory",
76+
)
77+
return parser.parse_args(argv)
78+
79+
80+
def main(argv: list[str] | None = None) -> int:
81+
"""Parse arguments and run cleanup."""
82+
args = _parse_args(argv)
83+
run(
84+
args.subdirectory,
85+
args.delay,
86+
args.pipeline_run_uid,
87+
args.workspace_path,
88+
)
89+
return 0
90+
91+
92+
if __name__ == "__main__": # pragma: no cover
93+
raise SystemExit(main())
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""Test cleanup of workspace directories and InternalRequest CRs."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from unittest import mock
7+
8+
import cleanup_workspace
9+
import pytest
10+
11+
12+
class TestCleanupDirectory:
13+
"""Test directory removal logic."""
14+
15+
def test_removes_existing_directory(self, tmp_path: Path) -> None:
16+
"""Existing subdirectory is removed."""
17+
target = tmp_path / "subdir"
18+
target.mkdir()
19+
(target / "file.txt").write_text("content", encoding="utf-8")
20+
21+
cleanup_workspace.cleanup_directory(str(tmp_path), "subdir")
22+
23+
assert not target.exists()
24+
25+
def test_noop_when_directory_does_not_exist(self, tmp_path: Path) -> None:
26+
"""No error when the subdirectory does not exist."""
27+
cleanup_workspace.cleanup_directory(str(tmp_path), "nonexistent")
28+
29+
def test_preserves_sibling_directories(self, tmp_path: Path) -> None:
30+
"""Only the target subdirectory is removed."""
31+
target = tmp_path / "remove-me"
32+
target.mkdir()
33+
sibling = tmp_path / "keep-me"
34+
sibling.mkdir()
35+
(sibling / "important.txt").write_text("keep", encoding="utf-8")
36+
37+
cleanup_workspace.cleanup_directory(str(tmp_path), "remove-me")
38+
39+
assert not target.exists()
40+
assert sibling.exists()
41+
assert (sibling / "important.txt").read_text(encoding="utf-8") == "keep"
42+
43+
44+
class TestRun:
45+
"""Test the run() orchestration."""
46+
47+
def test_full_flow(self, tmp_path: Path) -> None:
48+
"""Directory is removed after IR cleanup and delay."""
49+
target = tmp_path / "cleanup-dir"
50+
target.mkdir()
51+
(target / "file.txt").write_text("delete me", encoding="utf-8")
52+
53+
with (
54+
mock.patch("cleanup_workspace.cleanup_internal_requests.run") as mock_ir,
55+
mock.patch("cleanup_workspace.time.sleep") as mock_sleep,
56+
):
57+
cleanup_workspace.run("cleanup-dir", 5, "uid-123", str(tmp_path))
58+
59+
mock_ir.assert_called_once_with("uid-123")
60+
mock_sleep.assert_called_once_with(5)
61+
assert not target.exists()
62+
63+
def test_empty_subdirectory_skips_after_ir_cleanup(self) -> None:
64+
"""Empty subdirectory string skips delay and directory removal."""
65+
with (
66+
mock.patch("cleanup_workspace.cleanup_internal_requests.run") as mock_ir,
67+
mock.patch("cleanup_workspace.time.sleep") as mock_sleep,
68+
):
69+
cleanup_workspace.run("", 60, "uid-456", "/fake/path")
70+
71+
mock_ir.assert_called_once_with("uid-456")
72+
mock_sleep.assert_not_called()
73+
74+
def test_delay_is_applied(self, tmp_path: Path) -> None:
75+
"""Delay value is passed to time.sleep."""
76+
with (
77+
mock.patch("cleanup_workspace.cleanup_internal_requests.run"),
78+
mock.patch("cleanup_workspace.time.sleep") as mock_sleep,
79+
):
80+
cleanup_workspace.run("somedir", 42, "", str(tmp_path))
81+
82+
mock_sleep.assert_called_once_with(42)
83+
84+
def test_ir_cleanup_called_with_uid(self, tmp_path: Path) -> None:
85+
"""IR cleanup receives the pipeline run UID."""
86+
with (
87+
mock.patch("cleanup_workspace.cleanup_internal_requests.run") as mock_ir,
88+
mock.patch("cleanup_workspace.time.sleep"),
89+
):
90+
cleanup_workspace.run("dir", 0, "my-uid", str(tmp_path))
91+
92+
mock_ir.assert_called_once_with("my-uid")
93+
94+
def test_ir_cleanup_called_with_empty_uid(self, tmp_path: Path) -> None:
95+
"""IR cleanup is called even with empty UID (it handles the no-op)."""
96+
with (
97+
mock.patch("cleanup_workspace.cleanup_internal_requests.run") as mock_ir,
98+
mock.patch("cleanup_workspace.time.sleep"),
99+
):
100+
cleanup_workspace.run("dir", 0, "", str(tmp_path))
101+
102+
mock_ir.assert_called_once_with("")
103+
104+
def test_ir_cleanup_error_propagates(self) -> None:
105+
"""RuntimeError from IR cleanup propagates to the caller."""
106+
with mock.patch(
107+
"cleanup_workspace.cleanup_internal_requests.run",
108+
side_effect=RuntimeError("kubectl failed"),
109+
):
110+
with pytest.raises(RuntimeError, match="kubectl failed"):
111+
cleanup_workspace.run("dir", 0, "uid", "/fake")
112+
113+
114+
class TestParseArgs:
115+
"""Test argument parsing."""
116+
117+
def test_all_args(self) -> None:
118+
"""All arguments are parsed correctly."""
119+
args = cleanup_workspace._parse_args(
120+
[
121+
"--subdirectory",
122+
"mydir",
123+
"--delay",
124+
"30",
125+
"--pipeline-run-uid",
126+
"uid-789",
127+
"--workspace-path",
128+
"/workspace",
129+
]
130+
)
131+
assert args.subdirectory == "mydir"
132+
assert args.delay == 30
133+
assert args.pipeline_run_uid == "uid-789"
134+
assert args.workspace_path == "/workspace"
135+
136+
def test_defaults(self) -> None:
137+
"""Default values for optional arguments."""
138+
args = cleanup_workspace._parse_args(["--workspace-path", "/workspace"])
139+
assert args.subdirectory == ""
140+
assert args.delay == 60
141+
assert args.pipeline_run_uid == ""
142+
143+
def test_workspace_path_required(self) -> None:
144+
"""Missing --workspace-path causes a SystemExit."""
145+
with pytest.raises(SystemExit):
146+
cleanup_workspace._parse_args([])
147+
148+
149+
class TestMain:
150+
"""Test the CLI entry point."""
151+
152+
def test_success(self) -> None:
153+
"""Return 0 on successful run."""
154+
with mock.patch("cleanup_workspace.run") as mock_run:
155+
result = cleanup_workspace.main(
156+
[
157+
"--subdirectory",
158+
"dir",
159+
"--delay",
160+
"0",
161+
"--pipeline-run-uid",
162+
"uid",
163+
"--workspace-path",
164+
"/ws",
165+
]
166+
)
167+
assert result == 0
168+
mock_run.assert_called_once_with("dir", 0, "uid", "/ws")
169+
170+
def test_default_values(self) -> None:
171+
"""Default values are forwarded correctly."""
172+
with mock.patch("cleanup_workspace.run") as mock_run:
173+
result = cleanup_workspace.main(["--workspace-path", "/ws"])
174+
assert result == 0
175+
mock_run.assert_called_once_with("", 60, "", "/ws")
176+
177+
def test_runtime_error_propagates(self) -> None:
178+
"""RuntimeError from run() propagates as unhandled exception."""
179+
with mock.patch(
180+
"cleanup_workspace.run",
181+
side_effect=RuntimeError("boom"),
182+
):
183+
with pytest.raises(RuntimeError, match="boom"):
184+
cleanup_workspace.main(["--workspace-path", "/ws"])

0 commit comments

Comments
 (0)