diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c668674 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI (unit tests, stable tag) + +on: + push: + branches: ["**"] + tags: ["v*.*.*"] + pull_request: + +permissions: + contents: write + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # needed to create/move tags reliably + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install package (editable) + run: | + python -m pip install --upgrade pip + pip install -e . + + - name: Run unit tests + run: make test + + - name: Mark commit as stable (move stable tag) + if: startsWith(github.ref, 'refs/tags/v') + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Move/create 'stable' tag to the same commit as the pushed SemVer tag + git tag -f stable "${GITHUB_SHA}" + git push -f origin stable diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..473ee35 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +.ONESHELL: +SHELL := /bin/bash +.SHELLFLAGS := -euo pipefail -c + +VENV ?= .venv +PY := $(VENV)/bin/python +PIP := $(VENV)/bin/pip + +.PHONY: venv test clean + +venv: + test -x "$(PY)" || python3 -m venv "$(VENV)" + "$(PIP)" install -U pip + "$(PIP)" install -e . + +test: venv + "$(PY)" -m unittest discover -s tests/unit -p "test_*.py" -v + +clean: + rm -rf "$(VENV)" .pytest_cache .coverage + find . -type d -name "__pycache__" -print0 | xargs -0r rm -rf diff --git a/src/dockreap/__pycache__/__init__.cpython-313.pyc b/src/dockreap/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..5d270e8 Binary files /dev/null and b/src/dockreap/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/dockreap/__pycache__/__main__.cpython-313.pyc b/src/dockreap/__pycache__/__main__.cpython-313.pyc new file mode 100644 index 0000000..44329aa Binary files /dev/null and b/src/dockreap/__pycache__/__main__.cpython-313.pyc differ diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__pycache__/test_dockreap_main.cpython-313.pyc b/tests/unit/__pycache__/test_dockreap_main.cpython-313.pyc new file mode 100644 index 0000000..df0f404 Binary files /dev/null and b/tests/unit/__pycache__/test_dockreap_main.cpython-313.pyc differ diff --git a/tests/unit/test_dockreap_main.py b/tests/unit/test_dockreap_main.py new file mode 100644 index 0000000..d5ae020 --- /dev/null +++ b/tests/unit/test_dockreap_main.py @@ -0,0 +1,69 @@ +import unittest +from unittest.mock import patch, MagicMock + +import dockreap.__main__ as dockreap_main + + +class TestDockreapMain(unittest.TestCase): + def test_get_anonymous_volumes_filters_64char_hex(self): + fake_stdout = "\n".join( + [ + "short", + "nothex" * 10, + "a" * 64, # valid + "A" * 64, # invalid (uppercase not matched) + "g" * 64, # invalid (not hex) + "b" * 63, # invalid (too short) + "c" * 65, # invalid (too long) + "deadbeef" * 8, # valid 64-char hex + ] + ) + + completed = MagicMock() + completed.stdout = fake_stdout + + with patch.object(dockreap_main.subprocess, "run", return_value=completed) as run_mock: + vols = dockreap_main.get_anonymous_volumes() + + # Ensure docker command was called correctly + run_mock.assert_called_with( + ["docker", "volume", "ls", "--format", "{{.Name}}"], + stdout=dockreap_main.subprocess.PIPE, + text=True, + ) + + self.assertEqual(vols, ["a" * 64, "deadbeef" * 8]) + + def test_is_volume_used_true_when_docker_returns_container_ids(self): + completed = MagicMock() + completed.stdout = "abc123\n" + + with patch.object(dockreap_main.subprocess, "run", return_value=completed): + self.assertTrue(dockreap_main.is_volume_used("x" * 64)) + + def test_is_volume_used_false_when_no_container_ids(self): + completed = MagicMock() + completed.stdout = "\n" + + with patch.object(dockreap_main.subprocess, "run", return_value=completed): + self.assertFalse(dockreap_main.is_volume_used("x" * 64)) + + def test_cleanup_symlink_does_nothing_when_not_symlink(self): + # We don't want to touch the real filesystem, so we patch the Path object + fake_data_path = MagicMock() + fake_data_path.is_symlink.return_value = False + + fake_volume_dir = MagicMock() + fake_volume_dir.__truediv__.side_effect = lambda x: fake_data_path if x == "_data" else MagicMock() + + with patch.object(dockreap_main, "VOLUME_BASE_PATH") as base_path_mock: + base_path_mock.__truediv__.return_value = fake_volume_dir + + # Should not raise; should not try to unlink/rmtree + dockreap_main.cleanup_symlink("a" * 64) + + fake_data_path.unlink.assert_not_called() + + +if __name__ == "__main__": + unittest.main()