Compare commits
12 Commits
10a0a7dea5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fae3b4a386 | ||
|
|
6675c8bdbb | ||
|
|
d76e064556 | ||
|
|
5b31214e34 | ||
|
|
04850a8f20 | ||
|
|
33cac347ea | ||
|
|
57087c1bd7 | ||
|
|
092965d1ca | ||
|
|
2f722824e6 | ||
|
|
8f8ec75aee | ||
|
|
76cfa5312d | ||
|
|
39f8ccb376 |
7
.github/FUNDING.yml
vendored
Normal file
7
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
github: kevinveenbirkenbach
|
||||
|
||||
patreon: kevinveenbirkenbach
|
||||
|
||||
buy_me_a_coffee: kevinveenbirkenbach
|
||||
|
||||
custom: https://s.veen.world/paypaldonate
|
||||
43
.github/workflows/ci.yml
vendored
Normal file
43
.github/workflows/ci.yml
vendored
Normal file
@@ -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
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
*.egg-info
|
||||
dist/
|
||||
__pycache__
|
||||
*.pyc
|
||||
9
CHANGELOG.md
Normal file
9
CHANGELOG.md
Normal file
@@ -0,0 +1,9 @@
|
||||
## [1.0.1] - 2025-12-28
|
||||
|
||||
* Added CI with unit tests and stable tag automation; packaged project as Python CLI
|
||||
|
||||
|
||||
## [1.0.0] - 2025-12-28
|
||||
|
||||
* Official Release 🥳
|
||||
|
||||
4
MIRRORS
Normal file
4
MIRRORS
Normal file
@@ -0,0 +1,4 @@
|
||||
git@github.com:kevinveenbirkenbach/docker-volume-cleaner.git
|
||||
ssh://git@code.infinito.nexus:2201/kevinveenbirkenbach/dockreap.git
|
||||
ssh://git@git.veen.world:2201/kevinveenbirkenbach/dockreap.git
|
||||
https://pypi.org/project/dockreap/
|
||||
21
Makefile
Normal file
21
Makefile
Normal file
@@ -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
|
||||
96
README.md
96
README.md
@@ -1,2 +1,94 @@
|
||||
# docker-volume-cleaner
|
||||
A Python tool to safely detect and remove unused anonymous Docker volumes. Supports whitelisting, symlink cleanup, and optional confirmation prompts. Ideal for keeping your Docker environment clean and efficient.
|
||||
# 🧹 Docker Volume Cleaner (dockreap)
|
||||
|
||||
[](https://github.com/sponsors/kevinveenbirkenbach)
|
||||
[](https://www.patreon.com/c/kevinveenbirkenbach)
|
||||
[](https://buymeacoffee.com/kevinveenbirkenbach)
|
||||
[](https://s.veen.world/paypaldonate)
|
||||
|
||||
**dockreap** is a lightweight Python CLI tool that helps you identify and remove unused **anonymous Docker volumes** — including symlinks and their targets 🗑️
|
||||
|
||||
Keep your Docker environment tidy, automated, and efficient 🚀
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Features
|
||||
|
||||
- Detects anonymous Docker volumes (64-character hash names)
|
||||
- Skips whitelisted volumes
|
||||
- Skips bootstrap mounts (`/var/www/bootstrap`)
|
||||
- Cleans up symlinks **and** their target directories
|
||||
- Optional confirmation prompt via `--no-confirmation`
|
||||
- Pure Python — **no external dependencies**
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Install from PyPI (recommended)
|
||||
|
||||
```bash
|
||||
pip install dockreap
|
||||
```
|
||||
|
||||
or with an isolated environment:
|
||||
|
||||
```bash
|
||||
pipx install dockreap
|
||||
```
|
||||
|
||||
### Install from source (development)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kevinveenbirkenbach/dockreap.git
|
||||
cd dockreap
|
||||
pip install .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Usage
|
||||
|
||||
```bash
|
||||
# Basic usage (with confirmation prompt)
|
||||
dockreap
|
||||
|
||||
# Skip confirmation
|
||||
dockreap --no-confirmation
|
||||
|
||||
# Skip specific volumes by adding them to the whitelist
|
||||
dockreap "volumeid1 volumeid2"
|
||||
|
||||
# Skip confirmation + whitelist
|
||||
dockreap "volumeid1 volumeid2" --no-confirmation
|
||||
```
|
||||
|
||||
📝 Notes:
|
||||
|
||||
* Only volumes with **64-character hash names** (anonymous volumes) are considered.
|
||||
* Volumes mounted at `/var/www/bootstrap` are automatically excluded.
|
||||
* If a volume’s `_data` directory is a **symlink**, both the symlink **and its target directory** are removed.
|
||||
* Volumes referenced by **any container (running or stopped)** are not deleted.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Requirements
|
||||
|
||||
* Python ≥ 3.9
|
||||
* Docker CLI available and configured
|
||||
* Sufficient permissions to remove Docker volumes
|
||||
(usually requires `root` or membership in the `docker` group)
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
|
||||
This project is licensed under the **MIT License**.
|
||||
|
||||
---
|
||||
|
||||
## 👤 Author
|
||||
|
||||
**Kevin Veen-Birkenbach**
|
||||
🌍 [https://www.veen.world/](https://www.veen.world/)
|
||||
|
||||
```
|
||||
|
||||
36
pyproject.toml
Normal file
36
pyproject.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "dockreap"
|
||||
version = "1.0.1"
|
||||
description = "Remove unused anonymous Docker volumes (with symlink cleanup)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Kevin Veen-Birkenbach" }]
|
||||
keywords = ["docker", "volumes", "cleanup", "devops"]
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Environment :: Console",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Topic :: System :: Systems Administration",
|
||||
]
|
||||
|
||||
dependencies = []
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://www.veen.world/"
|
||||
Repository = "https://github.com/kevinveenbirkenbach/dockreap"
|
||||
|
||||
[project.scripts]
|
||||
dockreap = "dockreap.__main__:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = { "" = "src" }
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
0
src/dockreap/__init__.py
Normal file
0
src/dockreap/__init__.py
Normal file
17
main.py → src/dockreap/__main__.py
Normal file → Executable file
17
main.py → src/dockreap/__main__.py
Normal file → Executable file
@@ -4,7 +4,6 @@ import argparse
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
@@ -45,18 +44,20 @@ def is_volume_used(volume):
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
def cleanup_symlink(volume):
|
||||
volume_path = VOLUME_BASE_PATH / volume
|
||||
if volume_path.is_symlink():
|
||||
target_path = volume_path.resolve()
|
||||
print(f"Volume directory {volume_path} is a symlink to {target_path}.")
|
||||
volume_dir = VOLUME_BASE_PATH / volume
|
||||
data_path = volume_dir / "_data"
|
||||
|
||||
if data_path.is_symlink():
|
||||
target_path = data_path.resolve()
|
||||
print(f"_data of volume {volume} is a symlink to {target_path}.")
|
||||
try:
|
||||
print(f"Removing symlink: {volume_path}")
|
||||
volume_path.unlink()
|
||||
print(f"Removing symlink: {data_path}")
|
||||
data_path.unlink()
|
||||
if target_path.exists():
|
||||
print(f"Removing symlink target directory: {target_path}")
|
||||
shutil.rmtree(target_path)
|
||||
except Exception as e:
|
||||
print(f"Failed to clean up symlink or target for {volume}: {e}")
|
||||
print(f"Failed to clean up _data symlink or its target for {volume}: {e}")
|
||||
|
||||
def delete_volume(volume):
|
||||
cleanup_symlink(volume)
|
||||
BIN
src/dockreap/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/dockreap/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/dockreap/__pycache__/__main__.cpython-313.pyc
Normal file
BIN
src/dockreap/__pycache__/__main__.cpython-313.pyc
Normal file
Binary file not shown.
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
69
tests/unit/test_dockreap_main.py
Normal file
69
tests/unit/test_dockreap_main.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user