mirror of
https://github.com/kevinveenbirkenbach/docker-volume-backup-cleanup.git
synced 2025-12-31 04:29:08 +00:00
feat: pyproject-based packaging, unified CI and Docker e2e tests
- migrate to pyproject.toml and pip installation - introduce cleanback CLI entrypoint - add unit and Docker-based end-to-end tests - unify GitHub Actions CI and stable tagging - remove legacy tests.yml and pkgmgr requirements https://chatgpt.com/share/69517d20-f850-800f-b6ff-6b983247888f
This commit is contained in:
17
tests/e2e/Dockerfile.e2e
Normal file
17
tests/e2e/Dockerfile.e2e
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /opt/app
|
||||
|
||||
# Copy project
|
||||
COPY . .
|
||||
|
||||
# Install the project (editable is fine for tests)
|
||||
RUN python -m pip install -U pip \
|
||||
&& python -m pip install -e . \
|
||||
&& python -m pip install -U unittest-xml-reporting >/dev/null 2>&1 || true
|
||||
|
||||
# Create /Backups in container (our tests will use it)
|
||||
RUN mkdir -p /Backups
|
||||
|
||||
# Run E2E unittest
|
||||
CMD ["python", "-m", "unittest", "-v", "tests.e2e.test_e2e_docker"]
|
||||
151
tests/e2e/test_e2e_docker.py
Normal file
151
tests/e2e/test_e2e_docker.py
Normal file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FAKE_TIMEOUT_SLEEP = 0.3
|
||||
SHORT_TIMEOUT = "0.1"
|
||||
|
||||
|
||||
FAKE_DIRVAL = f"""#!/usr/bin/env python3
|
||||
import sys, time, argparse, pathlib
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("path")
|
||||
p.add_argument("--validate", action="store_true")
|
||||
args = p.parse_args()
|
||||
|
||||
d = pathlib.Path(args.path)
|
||||
name = d.name.lower()
|
||||
|
||||
if "timeout" in name:
|
||||
time.sleep({FAKE_TIMEOUT_SLEEP})
|
||||
print("Simulated long run...")
|
||||
return 0
|
||||
|
||||
if (d / "VALID").exists():
|
||||
print("ok")
|
||||
return 0
|
||||
|
||||
print("failed")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
"""
|
||||
|
||||
|
||||
class CleanbackE2EDockerTests(unittest.TestCase):
|
||||
"""
|
||||
E2E test that uses real directories, but runs inside a Docker container.
|
||||
It creates /Backups structure inside the container and invokes the app
|
||||
via `python -m cleanback`.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# Create a real /Backups root inside the container
|
||||
# (safe because we are in Docker)
|
||||
self.backups_root = Path("/Backups")
|
||||
self.backups_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Use a unique run folder so repeated runs don't collide
|
||||
self.run_root = self.backups_root / f"E2E-{os.getpid()}"
|
||||
self.run_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create fake `dirval` executable on disk (real file, real chmod)
|
||||
self.bin_dir = Path(tempfile.mkdtemp(prefix="cleanback-bin-"))
|
||||
self.dirval = self.bin_dir / "dirval"
|
||||
self.dirval.write_text(FAKE_DIRVAL, encoding="utf-8")
|
||||
self.dirval.chmod(0o755)
|
||||
|
||||
# Create real backup directory structure
|
||||
# /Backups/<ID>/backup-docker-to-local/{good,bad,timeout}
|
||||
self.backup_id = "ID-E2E"
|
||||
self.base = self.run_root / self.backup_id / "backup-docker-to-local"
|
||||
self.base.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.good = self.base / "good"
|
||||
self.bad = self.base / "bad"
|
||||
self.timeout = self.base / "timeout"
|
||||
for p in (self.good, self.bad, self.timeout):
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
(self.good / "VALID").write_text("1", encoding="utf-8")
|
||||
|
||||
def tearDown(self):
|
||||
# Cleanup what we created inside /Backups
|
||||
# Keep it simple and robust (don't fail teardown)
|
||||
try:
|
||||
if self.run_root.exists():
|
||||
for p in sorted(self.run_root.rglob("*"), reverse=True):
|
||||
try:
|
||||
if p.is_dir():
|
||||
p.rmdir()
|
||||
else:
|
||||
p.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.run_root.rmdir()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Remove temp bin dir
|
||||
if self.bin_dir.exists():
|
||||
for p in sorted(self.bin_dir.rglob("*"), reverse=True):
|
||||
try:
|
||||
if p.is_dir():
|
||||
p.rmdir()
|
||||
else:
|
||||
p.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.bin_dir.rmdir()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def test_e2e_id_mode_yes_deletes_failures(self):
|
||||
env = os.environ.copy()
|
||||
|
||||
# Prepend fake dirval path for this test run
|
||||
env["PATH"] = f"{self.bin_dir}:{env.get('PATH','')}"
|
||||
|
||||
# Run: python -m cleanback --id <ID> --yes
|
||||
# We must point BACKUPS_ROOT to our run_root. Easiest: set /Backups = run_root
|
||||
# But code currently has BACKUPS_ROOT = /Backups constant.
|
||||
#
|
||||
# Therefore, we create our test tree under /Backups (done above) and pass --id
|
||||
# relative to that structure by using run_root/<ID>. To do that, we make
|
||||
# run_root the direct child under /Backups, then we pass the composite id:
|
||||
# "<run-folder>/<ID>".
|
||||
composite_id = f"{self.run_root.name}/{self.backup_id}"
|
||||
|
||||
cmd = [
|
||||
"python", "-m", "cleanback",
|
||||
"--id", composite_id,
|
||||
"--dirval-cmd", "dirval",
|
||||
"--workers", "4",
|
||||
"--timeout", SHORT_TIMEOUT,
|
||||
"--yes",
|
||||
]
|
||||
proc = subprocess.run(cmd, text=True, capture_output=True, env=env)
|
||||
|
||||
self.assertEqual(proc.returncode, 0, msg=proc.stderr or proc.stdout)
|
||||
self.assertTrue(self.good.exists(), "good should remain")
|
||||
self.assertFalse(self.bad.exists(), "bad should be deleted")
|
||||
self.assertFalse(self.timeout.exists(), "timeout should be deleted (timeout treated as failure)")
|
||||
self.assertIn("Summary:", proc.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
187
tests/unit/test_main.py
Normal file
187
tests/unit/test_main.py
Normal file
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
import io
|
||||
import sys
|
||||
import time
|
||||
import tempfile
|
||||
import unittest
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
# Import cleanback package entrypoint
|
||||
ROOT = Path(__file__).resolve().parents[2] # repo root
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
from cleanback import __main__ as main # noqa: E402
|
||||
|
||||
# Keep tests snappy but reliable:
|
||||
# - "timeout" dirs sleep 0.3s in fake dirval
|
||||
# - we pass --timeout 0.1s -> they will time out
|
||||
FAKE_TIMEOUT_SLEEP = 0.3 # 300 ms
|
||||
SHORT_TIMEOUT = "0.1" # 100 ms
|
||||
|
||||
FAKE_DIRVAL = f"""#!/usr/bin/env python3
|
||||
import sys, time, argparse, pathlib
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("path")
|
||||
p.add_argument("--validate", action="store_true")
|
||||
args = p.parse_args()
|
||||
|
||||
d = pathlib.Path(args.path)
|
||||
name = d.name.lower()
|
||||
|
||||
# Simulate a slow validation for timeout* dirs
|
||||
if "timeout" in name:
|
||||
time.sleep({FAKE_TIMEOUT_SLEEP})
|
||||
print("Simulated long run...")
|
||||
return 0
|
||||
|
||||
# VALID file -> success
|
||||
if (d / "VALID").exists():
|
||||
print("ok")
|
||||
return 0
|
||||
|
||||
# otherwise -> fail
|
||||
print("failed")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
"""
|
||||
|
||||
class CleanupBackupsUsingDirvalTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# temp /Backups root
|
||||
self.tmpdir = tempfile.TemporaryDirectory()
|
||||
self.backups_root = Path(self.tmpdir.name)
|
||||
|
||||
# fake dirval on disk
|
||||
self.dirval = self.backups_root / "dirval"
|
||||
self.dirval.write_text(FAKE_DIRVAL, encoding="utf-8")
|
||||
self.dirval.chmod(0o755)
|
||||
|
||||
# structure:
|
||||
# /Backups/ID1/backup-docker-to-local/{goodA, badB, timeoutC}
|
||||
# /Backups/ID2/backup-docker-to-local/{goodX, badY}
|
||||
self.id1 = self.backups_root / "ID1" / "backup-docker-to-local"
|
||||
self.id2 = self.backups_root / "ID2" / "backup-docker-to-local"
|
||||
for p in [self.id1, self.id2]:
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.goodA = self.id1 / "goodA"
|
||||
self.badB = self.id1 / "badB"
|
||||
self.timeoutC = self.id1 / "timeoutC"
|
||||
self.goodX = self.id2 / "goodX"
|
||||
self.badY = self.id2 / "badY"
|
||||
for p in [self.goodA, self.badB, self.timeoutC, self.goodX, self.badY]:
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# mark valids
|
||||
(self.goodA / "VALID").write_text("1", encoding="utf-8")
|
||||
(self.goodX / "VALID").write_text("1", encoding="utf-8")
|
||||
|
||||
# Capture stdout/stderr
|
||||
self._stdout = io.StringIO()
|
||||
self._stderr = io.StringIO()
|
||||
self.stdout_cm = contextlib.redirect_stdout(self._stdout)
|
||||
self.stderr_cm = contextlib.redirect_stderr(self._stderr)
|
||||
self.stdout_cm.__enter__()
|
||||
self.stderr_cm.__enter__()
|
||||
|
||||
# Patch BACKUPS_ROOT to temp root
|
||||
self.backups_patcher = patch.object(main, "BACKUPS_ROOT", self.backups_root)
|
||||
self.backups_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.backups_patcher.stop()
|
||||
self.stdout_cm.__exit__(None, None, None)
|
||||
self.stderr_cm.__exit__(None, None, None)
|
||||
self.tmpdir.cleanup()
|
||||
|
||||
def run_main(self, argv):
|
||||
start = time.time()
|
||||
rc = main.main(argv)
|
||||
out = self._stdout.getvalue()
|
||||
err = self._stderr.getvalue()
|
||||
dur = time.time() - start
|
||||
self._stdout.seek(0); self._stdout.truncate(0)
|
||||
self._stderr.seek(0); self._stderr.truncate(0)
|
||||
return rc, out, err, dur
|
||||
|
||||
def test_id_mode_yes_deletes_failures(self):
|
||||
rc, out, err, _ = self.run_main([
|
||||
"--id", "ID1",
|
||||
"--dirval-cmd", str(self.dirval),
|
||||
"--workers", "4",
|
||||
"--timeout", SHORT_TIMEOUT,
|
||||
"--yes",
|
||||
])
|
||||
self.assertEqual(rc, 0, msg=err or out)
|
||||
self.assertTrue(self.goodA.exists(), "goodA should remain")
|
||||
self.assertFalse(self.badB.exists(), "badB should be deleted")
|
||||
self.assertFalse(self.timeoutC.exists(), "timeoutC should be deleted (timeout treated as failure)")
|
||||
self.assertIn("Summary:", out)
|
||||
|
||||
def test_all_mode(self):
|
||||
rc, out, err, _ = self.run_main([
|
||||
"--all",
|
||||
"--dirval-cmd", str(self.dirval),
|
||||
"--workers", "4",
|
||||
"--timeout", SHORT_TIMEOUT,
|
||||
"--yes",
|
||||
])
|
||||
self.assertEqual(rc, 0, msg=err or out)
|
||||
self.assertTrue(self.goodA.exists())
|
||||
self.assertFalse(self.badB.exists())
|
||||
self.assertFalse(self.timeoutC.exists())
|
||||
self.assertTrue(self.goodX.exists())
|
||||
self.assertFalse(self.badY.exists())
|
||||
|
||||
def test_dirval_missing_errors(self):
|
||||
rc, out, err, _ = self.run_main([
|
||||
"--id", "ID1",
|
||||
"--dirval-cmd", str(self.backups_root / "nope-dirval"),
|
||||
"--timeout", SHORT_TIMEOUT,
|
||||
"--yes",
|
||||
])
|
||||
self.assertEqual(rc, 0, msg=err or out)
|
||||
self.assertIn("dirval not found", out + err)
|
||||
|
||||
def test_no_targets_message(self):
|
||||
empty = self.backups_root / "EMPTY" / "backup-docker-to-local"
|
||||
empty.mkdir(parents=True, exist_ok=True)
|
||||
rc, out, err, _ = self.run_main([
|
||||
"--id", "EMPTY",
|
||||
"--dirval-cmd", str(self.dirval),
|
||||
"--timeout", SHORT_TIMEOUT,
|
||||
])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("No subdirectories to validate. Nothing to do.", out)
|
||||
|
||||
def test_interactive_keeps_when_no(self):
|
||||
with patch("builtins.input", return_value=""):
|
||||
rc, out, err, _ = self.run_main([
|
||||
"--id", "ID2",
|
||||
"--dirval-cmd", str(self.dirval),
|
||||
"--workers", "1",
|
||||
"--timeout", SHORT_TIMEOUT,
|
||||
])
|
||||
self.assertEqual(rc, 0, msg=err or out)
|
||||
self.assertTrue(self.badY.exists(), "badY should be kept without confirmation")
|
||||
self.assertTrue(self.goodX.exists())
|
||||
|
||||
def test_interactive_yes_deletes(self):
|
||||
with patch("builtins.input", return_value="y"):
|
||||
rc, out, err, _ = self.run_main([
|
||||
"--id", "ID2",
|
||||
"--dirval-cmd", str(self.dirval),
|
||||
"--workers", "1",
|
||||
"--timeout", SHORT_TIMEOUT,
|
||||
])
|
||||
self.assertEqual(rc, 0, msg=err or out)
|
||||
self.assertFalse(self.badY.exists(), "badY should be deleted")
|
||||
self.assertTrue(self.goodX.exists())
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user