whyfull 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .venv/
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release.
6
+
7
+ - Checks: deleted-but-open files, Docker (build cache, images, containers, volumes, container logs), systemd journal, rotated and oversized logs, old kernels, package manager caches, snap revisions, core dumps, old temp files, user build and package caches, inode exhaustion, and the ext root reserve.
8
+ - Full-filesystem scan with a time limit, largest directories, crowded directories, and a warning when `df` and the files on disk don't add up.
9
+ - Text, JSON and Markdown output; `--fail-above` for cron and monitoring.
10
+ - Runs on Python 3.6+ with no dependencies; also shipped as a single `whyfull.pyz` file.
whyfull-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ahmed Laban
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
whyfull-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.5
2
+ Name: whyfull
3
+ Version: 0.1.0
4
+ Summary: Find out why your Linux disk is full: deleted-but-open files, Docker, journald, old kernels, caches and more, each with the command that fixes it.
5
+ Project-URL: Homepage, https://github.com/amlaban65/whyfull
6
+ Project-URL: Issues, https://github.com/amlaban65/whyfull/issues
7
+ Project-URL: Changelog, https://github.com/amlaban65/whyfull/blob/main/CHANGELOG.md
8
+ Author: Ahmed Laban
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: cleanup,deleted files,devops,df,disk full,disk space,docker,du,inodes,journald,linux,lsof,no space left on device,sre,sysadmin
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.6
21
+ Classifier: Programming Language :: Python :: 3.7
22
+ Classifier: Programming Language :: Python :: 3.8
23
+ Classifier: Programming Language :: Python :: 3.9
24
+ Classifier: Programming Language :: Python :: 3.10
25
+ Classifier: Programming Language :: Python :: 3.11
26
+ Classifier: Programming Language :: Python :: 3.12
27
+ Classifier: Programming Language :: Python :: 3.13
28
+ Classifier: Topic :: System :: Filesystems
29
+ Classifier: Topic :: System :: Monitoring
30
+ Classifier: Topic :: System :: Systems Administration
31
+ Classifier: Topic :: Utilities
32
+ Requires-Python: >=3.6
33
+ Description-Content-Type: text/markdown
34
+
35
+ # whyfull
36
+
37
+ **Find out why your Linux disk is full, and get the command that fixes it.**
38
+
39
+ [![CI](https://github.com/amlaban65/whyfull/actions/workflows/ci.yml/badge.svg)](https://github.com/amlaban65/whyfull/actions/workflows/ci.yml)
40
+ [![PyPI](https://img.shields.io/pypi/v/whyfull)](https://pypi.org/project/whyfull/)
41
+ [![Python 3.6+](https://img.shields.io/badge/python-3.6%2B-blue)](https://pypi.org/project/whyfull/)
42
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](https://github.com/amlaban65/whyfull/blob/main/LICENSE)
43
+
44
+ `df` says 100%. `du` doesn't add up. Something is failing with `No space left on device`.
45
+
46
+ whyfull checks the places where disk space usually goes on a Linux server: deleted files that a process still holds open, the systemd journal, Docker images and container logs, old kernels, package caches, core dumps, runaway logs and inode exhaustion. For each thing it finds, it prints how much space it takes, how risky it is to remove, and the exact command to get the space back.
47
+
48
+ It never deletes anything itself.
49
+
50
+ <p align="center">
51
+ <img src="https://raw.githubusercontent.com/amlaban65/whyfull/main/docs/demo.svg" alt="whyfull showing a full root filesystem: a 700 MB deleted nginx log still held open, a 560 MB Docker container log, unused images, old kernels and the systemd journal, each with the command that frees it" width="100%">
52
+ </p>
53
+
54
+ ## Run it
55
+
56
+ A full disk may leave no room to install anything, so whyfull is a single 30 KB file with no dependencies. It runs on the Python 3 that already ships with nearly every Linux server (3.6 or newer). Download it to `/dev/shm`, which lives in memory:
57
+
58
+ ```sh
59
+ curl -fsSL -o /dev/shm/whyfull https://github.com/amlaban65/whyfull/releases/latest/download/whyfull.pyz
60
+ sudo python3 /dev/shm/whyfull
61
+ ```
62
+
63
+ Or install it from PyPI:
64
+
65
+ ```sh
66
+ pipx install whyfull # or: uv tool install whyfull, pip install --user whyfull
67
+ sudo "$(command -v whyfull)"
68
+ ```
69
+
70
+ `uvx whyfull` and `pipx run whyfull` work for a one-off run.
71
+
72
+ Run it with `sudo`. Without root it can't measure files owned by other users and services, and it will tell you what it couldn't read.
73
+
74
+ ## What it looks for
75
+
76
+ | Check | What it finds |
77
+ |---|---|
78
+ | `deleted-open` | Files that were deleted while a process still had them open. This is the classic "df says full but du says otherwise". whyfull names the systemd service or Docker container holding them and shows how to release the space with or without a restart. |
79
+ | `docker` | Build cache, unused images, stopped containers, dangling volumes, and container logs with no size limit. |
80
+ | `journald` | A systemd journal that has grown past a sensible size, with the commands to vacuum it and cap it. |
81
+ | `logs` | Rotated and compressed logs in `/var/log`, and single log files over 1 GB. |
82
+ | `kernels` | Installed kernels other than the running one and the newest one, with the right removal command for apt, dnf or zypper. |
83
+ | `packages` | apt, dnf, yum, pacman, zypper and apk caches. |
84
+ | `snaps` | Disabled snap revisions that snapd keeps around. |
85
+ | `crashes` | Core dumps from systemd-coredump, apport and ABRT, and `/var/crash`. |
86
+ | `tmp` | Files in `/tmp` and `/var/tmp` nobody has touched in a week. |
87
+ | `user-caches` | pip, uv, poetry, npm, yarn, pnpm, Go, Cargo, Maven, Gradle, Hugging Face, PyTorch and conda caches, and the Trash, for every user. |
88
+ | `inodes` | A filesystem that ran out of inodes (too many files) rather than bytes, and the directories with the most entries. |
89
+ | `reserved` | ext filesystems that look full only because of the 5% reserved for root. |
90
+
91
+ whyfull also scans the whole filesystem (with a time limit) and lists the largest directories. When the files it can see add up to much less than `df` reports, it says so, and points to where the rest usually is. Common causes are files hidden underneath a mount point and btrfs or ZFS snapshots.
92
+
93
+ `whyfull --list-checks` prints the list from your installed version.
94
+
95
+ ### Risk levels
96
+
97
+ Every finding is labelled so you know what you're agreeing to:
98
+
99
+ - **safe**: caches that are rebuilt on demand. Removing them costs a slower next build or download.
100
+ - **review**: almost always fine to remove (old kernels, rotated logs, unused images), but take a look first.
101
+ - **caution**: can lose data or affect a running service, such as truncating a live log or pruning Docker volumes.
102
+ - **info**: explains what's going on, like inode exhaustion or the root reserve, without a direct cleanup.
103
+
104
+ ## Usage
105
+
106
+ ```sh
107
+ sudo whyfull # every filesystem that is 85% full or more
108
+ sudo whyfull /var # only the filesystem holding /var
109
+ sudo whyfull -t 70 # lower the "full" threshold to 70%
110
+ sudo whyfull -v # explain every finding, list all evidence
111
+ sudo whyfull --no-scan # skip the full scan, run only the checks
112
+ sudo whyfull --only docker,logs # run some checks; see --list-checks
113
+ ```
114
+
115
+ If nothing is above the threshold, whyfull analyses `/` anyway and tells you the disks are fine.
116
+
117
+ ### For incident tickets, scripts and monitoring
118
+
119
+ ```sh
120
+ sudo whyfull --format markdown > disk.md # paste into a ticket or chat
121
+ sudo whyfull --json | jq '.targets[].findings[] | {title, bytes, risk}'
122
+ whyfull --fail-above 90 --no-scan # exits 1 when any filesystem is 90%+ full
123
+ ```
124
+
125
+ Exit codes: `0` when the run finished, `1` when `--fail-above` was exceeded, `2` for usage errors.
126
+
127
+ ## How is this different from ncdu, du, dust or gdu?
128
+
129
+ Those are great for browsing a directory tree, and you should keep using them. They answer "which folder is big". whyfull answers "what is this space, and how do I get it back without breaking anything". It finds space that no directory browser can see (deleted files held open by a process). It knows that `/var/lib/docker/overlay2` is cleaned with `docker image prune`, not `rm`. It knows which kernels are safe to remove, and that a 2 GB `syslog.2.gz` can go while a live `syslog` should be truncated.
130
+
131
+ Run whyfull first. If something is still unexplained, open ncdu on the directory it points to.
132
+
133
+ ## Safety
134
+
135
+ - whyfull only reads. It does not delete, truncate, prune or restart anything.
136
+ - The only commands it runs are read-only: `docker system df`, `docker info` and `snap list --all`.
137
+ - Every fix is printed for you to read and run yourself. Commands that can lose data are marked **caution** and explained.
138
+ - Scans stay on one filesystem (like `du -x`), count hard links once, and stop at a time limit, so they won't hang on a huge disk or wander into network mounts.
139
+
140
+ ## Contributing
141
+
142
+ The most useful contribution is a new check: *"my disk filled up because of X, and here is how to spot it and clean it up."* Each check is one short file in [`src/whyfull/checks/`](https://github.com/amlaban65/whyfull/tree/main/src/whyfull/checks), and tests run against a fake filesystem, so you don't need a full disk to write one. [CONTRIBUTING.md](https://github.com/amlaban65/whyfull/blob/main/CONTRIBUTING.md) walks through it.
143
+
144
+ Some checks that would help a lot of people: containerd and CRI-O images (`crictl`), Podman, Kubernetes pod logs, PostgreSQL WAL, MySQL binlogs, Elasticsearch, LXD, Flatpak, Jenkins workspaces and GitLab Runner caches. If you have a war story about a disk that filled up in a strange way, [open an issue](https://github.com/amlaban65/whyfull/issues/new?template=new-check.yml) even if you don't plan to write the code.
145
+
146
+ ## License
147
+
148
+ [MIT](https://github.com/amlaban65/whyfull/blob/main/LICENSE)
@@ -0,0 +1,114 @@
1
+ # whyfull
2
+
3
+ **Find out why your Linux disk is full, and get the command that fixes it.**
4
+
5
+ [![CI](https://github.com/amlaban65/whyfull/actions/workflows/ci.yml/badge.svg)](https://github.com/amlaban65/whyfull/actions/workflows/ci.yml)
6
+ [![PyPI](https://img.shields.io/pypi/v/whyfull)](https://pypi.org/project/whyfull/)
7
+ [![Python 3.6+](https://img.shields.io/badge/python-3.6%2B-blue)](https://pypi.org/project/whyfull/)
8
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](https://github.com/amlaban65/whyfull/blob/main/LICENSE)
9
+
10
+ `df` says 100%. `du` doesn't add up. Something is failing with `No space left on device`.
11
+
12
+ whyfull checks the places where disk space usually goes on a Linux server: deleted files that a process still holds open, the systemd journal, Docker images and container logs, old kernels, package caches, core dumps, runaway logs and inode exhaustion. For each thing it finds, it prints how much space it takes, how risky it is to remove, and the exact command to get the space back.
13
+
14
+ It never deletes anything itself.
15
+
16
+ <p align="center">
17
+ <img src="https://raw.githubusercontent.com/amlaban65/whyfull/main/docs/demo.svg" alt="whyfull showing a full root filesystem: a 700 MB deleted nginx log still held open, a 560 MB Docker container log, unused images, old kernels and the systemd journal, each with the command that frees it" width="100%">
18
+ </p>
19
+
20
+ ## Run it
21
+
22
+ A full disk may leave no room to install anything, so whyfull is a single 30 KB file with no dependencies. It runs on the Python 3 that already ships with nearly every Linux server (3.6 or newer). Download it to `/dev/shm`, which lives in memory:
23
+
24
+ ```sh
25
+ curl -fsSL -o /dev/shm/whyfull https://github.com/amlaban65/whyfull/releases/latest/download/whyfull.pyz
26
+ sudo python3 /dev/shm/whyfull
27
+ ```
28
+
29
+ Or install it from PyPI:
30
+
31
+ ```sh
32
+ pipx install whyfull # or: uv tool install whyfull, pip install --user whyfull
33
+ sudo "$(command -v whyfull)"
34
+ ```
35
+
36
+ `uvx whyfull` and `pipx run whyfull` work for a one-off run.
37
+
38
+ Run it with `sudo`. Without root it can't measure files owned by other users and services, and it will tell you what it couldn't read.
39
+
40
+ ## What it looks for
41
+
42
+ | Check | What it finds |
43
+ |---|---|
44
+ | `deleted-open` | Files that were deleted while a process still had them open. This is the classic "df says full but du says otherwise". whyfull names the systemd service or Docker container holding them and shows how to release the space with or without a restart. |
45
+ | `docker` | Build cache, unused images, stopped containers, dangling volumes, and container logs with no size limit. |
46
+ | `journald` | A systemd journal that has grown past a sensible size, with the commands to vacuum it and cap it. |
47
+ | `logs` | Rotated and compressed logs in `/var/log`, and single log files over 1 GB. |
48
+ | `kernels` | Installed kernels other than the running one and the newest one, with the right removal command for apt, dnf or zypper. |
49
+ | `packages` | apt, dnf, yum, pacman, zypper and apk caches. |
50
+ | `snaps` | Disabled snap revisions that snapd keeps around. |
51
+ | `crashes` | Core dumps from systemd-coredump, apport and ABRT, and `/var/crash`. |
52
+ | `tmp` | Files in `/tmp` and `/var/tmp` nobody has touched in a week. |
53
+ | `user-caches` | pip, uv, poetry, npm, yarn, pnpm, Go, Cargo, Maven, Gradle, Hugging Face, PyTorch and conda caches, and the Trash, for every user. |
54
+ | `inodes` | A filesystem that ran out of inodes (too many files) rather than bytes, and the directories with the most entries. |
55
+ | `reserved` | ext filesystems that look full only because of the 5% reserved for root. |
56
+
57
+ whyfull also scans the whole filesystem (with a time limit) and lists the largest directories. When the files it can see add up to much less than `df` reports, it says so, and points to where the rest usually is. Common causes are files hidden underneath a mount point and btrfs or ZFS snapshots.
58
+
59
+ `whyfull --list-checks` prints the list from your installed version.
60
+
61
+ ### Risk levels
62
+
63
+ Every finding is labelled so you know what you're agreeing to:
64
+
65
+ - **safe**: caches that are rebuilt on demand. Removing them costs a slower next build or download.
66
+ - **review**: almost always fine to remove (old kernels, rotated logs, unused images), but take a look first.
67
+ - **caution**: can lose data or affect a running service, such as truncating a live log or pruning Docker volumes.
68
+ - **info**: explains what's going on, like inode exhaustion or the root reserve, without a direct cleanup.
69
+
70
+ ## Usage
71
+
72
+ ```sh
73
+ sudo whyfull # every filesystem that is 85% full or more
74
+ sudo whyfull /var # only the filesystem holding /var
75
+ sudo whyfull -t 70 # lower the "full" threshold to 70%
76
+ sudo whyfull -v # explain every finding, list all evidence
77
+ sudo whyfull --no-scan # skip the full scan, run only the checks
78
+ sudo whyfull --only docker,logs # run some checks; see --list-checks
79
+ ```
80
+
81
+ If nothing is above the threshold, whyfull analyses `/` anyway and tells you the disks are fine.
82
+
83
+ ### For incident tickets, scripts and monitoring
84
+
85
+ ```sh
86
+ sudo whyfull --format markdown > disk.md # paste into a ticket or chat
87
+ sudo whyfull --json | jq '.targets[].findings[] | {title, bytes, risk}'
88
+ whyfull --fail-above 90 --no-scan # exits 1 when any filesystem is 90%+ full
89
+ ```
90
+
91
+ Exit codes: `0` when the run finished, `1` when `--fail-above` was exceeded, `2` for usage errors.
92
+
93
+ ## How is this different from ncdu, du, dust or gdu?
94
+
95
+ Those are great for browsing a directory tree, and you should keep using them. They answer "which folder is big". whyfull answers "what is this space, and how do I get it back without breaking anything". It finds space that no directory browser can see (deleted files held open by a process). It knows that `/var/lib/docker/overlay2` is cleaned with `docker image prune`, not `rm`. It knows which kernels are safe to remove, and that a 2 GB `syslog.2.gz` can go while a live `syslog` should be truncated.
96
+
97
+ Run whyfull first. If something is still unexplained, open ncdu on the directory it points to.
98
+
99
+ ## Safety
100
+
101
+ - whyfull only reads. It does not delete, truncate, prune or restart anything.
102
+ - The only commands it runs are read-only: `docker system df`, `docker info` and `snap list --all`.
103
+ - Every fix is printed for you to read and run yourself. Commands that can lose data are marked **caution** and explained.
104
+ - Scans stay on one filesystem (like `du -x`), count hard links once, and stop at a time limit, so they won't hang on a huge disk or wander into network mounts.
105
+
106
+ ## Contributing
107
+
108
+ The most useful contribution is a new check: *"my disk filled up because of X, and here is how to spot it and clean it up."* Each check is one short file in [`src/whyfull/checks/`](https://github.com/amlaban65/whyfull/tree/main/src/whyfull/checks), and tests run against a fake filesystem, so you don't need a full disk to write one. [CONTRIBUTING.md](https://github.com/amlaban65/whyfull/blob/main/CONTRIBUTING.md) walks through it.
109
+
110
+ Some checks that would help a lot of people: containerd and CRI-O images (`crictl`), Podman, Kubernetes pod logs, PostgreSQL WAL, MySQL binlogs, Elasticsearch, LXD, Flatpak, Jenkins workspaces and GitLab Runner caches. If you have a war story about a disk that filled up in a strange way, [open an issue](https://github.com/amlaban65/whyfull/issues/new?template=new-check.yml) even if you don't plan to write the code.
111
+
112
+ ## License
113
+
114
+ [MIT](https://github.com/amlaban65/whyfull/blob/main/LICENSE)
@@ -0,0 +1,68 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "whyfull"
7
+ dynamic = ["version"]
8
+ description = "Find out why your Linux disk is full: deleted-but-open files, Docker, journald, old kernels, caches and more, each with the command that fixes it."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Ahmed Laban" }]
12
+ requires-python = ">=3.6"
13
+ dependencies = []
14
+ keywords = [
15
+ "disk full", "disk space", "no space left on device", "df", "du", "linux", "sysadmin",
16
+ "devops", "sre", "docker", "journald", "inodes", "deleted files", "lsof", "cleanup",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Environment :: Console",
21
+ "Intended Audience :: System Administrators",
22
+ "Intended Audience :: Developers",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Operating System :: POSIX :: Linux",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3 :: Only",
27
+ "Programming Language :: Python :: 3.6",
28
+ "Programming Language :: Python :: 3.7",
29
+ "Programming Language :: Python :: 3.8",
30
+ "Programming Language :: Python :: 3.9",
31
+ "Programming Language :: Python :: 3.10",
32
+ "Programming Language :: Python :: 3.11",
33
+ "Programming Language :: Python :: 3.12",
34
+ "Programming Language :: Python :: 3.13",
35
+ "Topic :: System :: Filesystems",
36
+ "Topic :: System :: Monitoring",
37
+ "Topic :: System :: Systems Administration",
38
+ "Topic :: Utilities",
39
+ ]
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/amlaban65/whyfull"
43
+ Issues = "https://github.com/amlaban65/whyfull/issues"
44
+ Changelog = "https://github.com/amlaban65/whyfull/blob/main/CHANGELOG.md"
45
+
46
+ [project.scripts]
47
+ whyfull = "whyfull.cli:main"
48
+
49
+ [tool.hatch.version]
50
+ path = "src/whyfull/__init__.py"
51
+
52
+ [tool.hatch.build.targets.sdist]
53
+ include = ["src", "tests", "tools", "README.md", "LICENSE", "CHANGELOG.md"]
54
+
55
+ [tool.hatch.build.targets.wheel]
56
+ packages = ["src/whyfull"]
57
+
58
+ [tool.pytest.ini_options]
59
+ testpaths = ["tests"]
60
+
61
+ [tool.ruff]
62
+ line-length = 120
63
+ target-version = "py37" # ruff's oldest target; vermin in CI checks 3.6
64
+
65
+ [tool.ruff.lint]
66
+ select = ["E", "F", "W", "B", "UP"]
67
+ # Keep the code runnable on Python 3.6: no f-strings, no newer syntax.
68
+ ignore = ["UP021", "UP022", "UP004", "UP008", "UP009", "UP010", "UP015", "UP025", "UP031", "UP032", "B904"]
@@ -0,0 +1,3 @@
1
+ """whyfull: find out why a Linux disk is full, and how to safely free space."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from whyfull.cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,167 @@
1
+ """Run the checks against the chosen filesystems and assemble a report."""
2
+
3
+ import os
4
+
5
+ from whyfull import __version__
6
+ from whyfull.checks import Context, load
7
+ from whyfull.model import CAUTION, INFO, REVIEW, SAFE, Finding
8
+ from whyfull.mounts import containing, filesystems
9
+ from whyfull.units import human
10
+ from whyfull.walk import Budget, scan
11
+
12
+ DEFAULT_THRESHOLD = 85.0
13
+
14
+
15
+ class Target(object):
16
+ """One filesystem under analysis and everything found about it."""
17
+
18
+ def __init__(self, fs):
19
+ self.fs = fs
20
+ self.findings = []
21
+ self.tree = None
22
+ self.largest = [] # [(display path, bytes, [(child path, bytes)])]
23
+ self.crowded = [] # [(entries, display path)]
24
+
25
+ def reclaimable(self):
26
+ out = {SAFE: 0, REVIEW: 0, CAUTION: 0}
27
+ for f in self.findings:
28
+ if f.risk in out:
29
+ out[f.risk] += f.bytes
30
+ out["total"] = out[SAFE] + out[REVIEW] + out[CAUTION]
31
+ return out
32
+
33
+ def largest_dirs(self, system, top=6, children=3, min_share=0.01):
34
+ """[(display path, bytes, [(child display path, bytes)])] from the scan."""
35
+ if self.tree is None:
36
+ return []
37
+ root = system.path(self.fs.mountpoint).rstrip("/") or "/"
38
+ floor = self.fs.used * min_share
39
+
40
+ def kids(parent):
41
+ out = [(p, b) for p, b in self.tree.sizes.items()
42
+ if os.path.dirname(p) == parent and p != parent and b >= floor]
43
+ return sorted(out, key=lambda pb: -pb[1])
44
+
45
+ result = []
46
+ for path, nbytes in kids(root)[:top]:
47
+ sub = [(system.display(p), b) for p, b in kids(path)[:children]]
48
+ result.append((system.display(path), nbytes, sub))
49
+ return result
50
+
51
+
52
+ class Report(object):
53
+ def __init__(self):
54
+ self.version = __version__
55
+ self.filesystems = []
56
+ self.targets = []
57
+ self.threshold = DEFAULT_THRESHOLD
58
+ self.all_clear = False # no filesystem above the threshold
59
+ self.notes = []
60
+ self.errors = [] # (check id, message)
61
+
62
+
63
+ def choose_targets(system, all_fs, paths, threshold):
64
+ if paths:
65
+ chosen = []
66
+ for p in paths:
67
+ fs = containing(system, os.path.abspath(p) if system.root == "/" else p)
68
+ if fs is None:
69
+ raise ValueError("can't find the filesystem for %s" % p)
70
+ if fs.dev not in [t.dev for t in chosen]:
71
+ chosen.append(fs)
72
+ return chosen, False
73
+ full = [fs for fs in all_fs if fs.percent >= threshold]
74
+ if full:
75
+ return sorted(full, key=lambda f: -f.percent), False
76
+ root = containing(system, "/", all_fs) or containing(system, "/")
77
+ return ([root] if root else []), True
78
+
79
+
80
+ def analyze(system, paths=None, threshold=DEFAULT_THRESHOLD, scan_seconds=30, check_seconds=60,
81
+ only=None, skip=None, do_scan=True):
82
+ report = Report()
83
+ report.threshold = threshold
84
+ all_fs = filesystems(system)
85
+ report.filesystems = all_fs
86
+ targets, report.all_clear = choose_targets(system, all_fs, paths, threshold)
87
+ report.targets = [Target(fs) for fs in targets]
88
+ if not targets:
89
+ return report
90
+
91
+ known = {fs.dev: fs for fs in all_fs}
92
+ for fs in targets:
93
+ known.setdefault(fs.dev, fs)
94
+ ctx = Context(system, targets, Budget(check_seconds), list(known.values()))
95
+
96
+ for chk in load():
97
+ if only and chk.id not in only:
98
+ continue
99
+ if skip and chk.id in skip:
100
+ continue
101
+ try:
102
+ found = list(chk.fn(ctx) or [])
103
+ except Exception as e: # a broken check must never hide the others
104
+ report.errors.append((chk.id, "%s: %s" % (type(e).__name__, e)))
105
+ continue
106
+ by_dev = {t.fs.dev: t for t in report.targets}
107
+ for f in found:
108
+ t = by_dev.get(f.dev)
109
+ if t is not None and (f.bytes > 0 or f.risk == INFO):
110
+ t.findings.append(f)
111
+ if ctx.budget.hit:
112
+ ctx.note("Some checks hit their time limit; sizes may be understated.")
113
+
114
+ for t in report.targets:
115
+ if do_scan:
116
+ t.tree = scan(system.path(t.fs.mountpoint), t.fs.dev, Budget(scan_seconds))
117
+ if not t.tree.complete:
118
+ ctx.note("The scan of %s stopped at its time limit; raise it with --scan-time." % t.fs.mountpoint)
119
+ if t.tree.denied and not system.is_root:
120
+ ctx.note("%d directories on %s couldn't be read; run with sudo for a complete picture."
121
+ % (t.tree.denied, t.fs.mountpoint))
122
+ t.largest = t.largest_dirs(system)
123
+ t.crowded = [(n, system.display(p)) for n, p in t.tree.crowded]
124
+ gap = unexplained(system, t, ctx.deleted_open_bytes.get(t.fs.dev, 0), all_fs)
125
+ if gap is not None:
126
+ t.findings.append(gap)
127
+ t.findings.sort(key=lambda f: (-f.priority, -f.bytes))
128
+
129
+ report.notes = ctx.notes
130
+ return report
131
+
132
+
133
+ def unexplained(system, target, deleted_open, all_fs):
134
+ """An INFO finding when df's 'used' is far above what the files add up to."""
135
+ tree, fs = target.tree, target.fs
136
+ if tree is None or not tree.complete or tree.denied:
137
+ return None
138
+ gap = fs.used - tree.total - deleted_open
139
+ if gap < max(512 * 2 ** 20, 0.05 * fs.used):
140
+ return None
141
+ root = fs.mountpoint.rstrip("/")
142
+ covering = sorted(m.mountpoint for m in all_fs
143
+ if m.dev != fs.dev and m.mountpoint != fs.mountpoint
144
+ and (m.mountpoint + "/").startswith(root + "/"))
145
+ fix = []
146
+ if covering:
147
+ fix = ["sudo mkdir -p /mnt/whyfull-under && sudo mount --bind %s /mnt/whyfull-under" % fs.mountpoint]
148
+ fix += ["sudo du -sh /mnt/whyfull-under%s" % m[len(root):] for m in covering[:6]]
149
+ fix += ["sudo umount /mnt/whyfull-under"]
150
+ if fs.fstype == "btrfs":
151
+ fix += ["sudo btrfs subvolume list -s %s # snapshots keep deleted data" % fs.mountpoint]
152
+ if fs.fstype == "zfs":
153
+ fix += ["zfs list -t snapshot -o name,used -s used"]
154
+ causes = []
155
+ if covering:
156
+ causes.append("files written into a directory before another disk was mounted over it "
157
+ "(%s): they still use space but are hidden" % ", ".join(covering[:4]))
158
+ if fs.fstype in ("btrfs", "zfs"):
159
+ causes.append("snapshots holding on to deleted data")
160
+ causes.append("filesystem metadata and journal")
161
+ return Finding(
162
+ "unexplained", fs.mountpoint,
163
+ "%s in use on %s isn't in any file whyfull can see" % (human(gap), fs.mountpoint),
164
+ gap, INFO, fs.dev, priority=10,
165
+ detail="Likely causes: " + "; ".join(causes) + ".",
166
+ fix=fix,
167
+ )
@@ -0,0 +1,92 @@
1
+ """Checks find specific, explainable causes of disk usage.
2
+
3
+ A check is a function decorated with @check. It receives a Context and
4
+ returns (or yields) Finding objects. Checks must never modify the system:
5
+ they read files and run read-only commands only.
6
+
7
+ To add one, create a module in this package, decorate a function with
8
+ @check("my-id", "What it looks for"), and add the module name to MODULES
9
+ below. See CONTRIBUTING.md for the full walkthrough.
10
+ """
11
+
12
+ import importlib
13
+
14
+ from whyfull.walk import du
15
+
16
+ REGISTRY = []
17
+
18
+ MODULES = [
19
+ "deleted_open",
20
+ "space",
21
+ "journald",
22
+ "logs",
23
+ "containers",
24
+ "packages",
25
+ "kernels",
26
+ "snaps",
27
+ "crashes",
28
+ "tmpfiles",
29
+ "usercaches",
30
+ ]
31
+
32
+
33
+ class Check(object):
34
+ def __init__(self, id, title, fn):
35
+ self.id = id
36
+ self.title = title
37
+ self.fn = fn
38
+
39
+
40
+ def check(id, title):
41
+ def register(fn):
42
+ REGISTRY.append(Check(id, title, fn))
43
+ return fn
44
+ return register
45
+
46
+
47
+ def load():
48
+ for name in MODULES:
49
+ importlib.import_module("whyfull.checks." + name)
50
+ return list(REGISTRY)
51
+
52
+
53
+ class Context(object):
54
+ """What a check gets to work with.
55
+
56
+ system -- whyfull.system.System; use it for every file and command access
57
+ targets -- the Filesystem objects being analysed
58
+ budget -- shared time budget for size measurements
59
+ """
60
+
61
+ def __init__(self, system, targets, budget, all_filesystems=None):
62
+ self.system = system
63
+ self.targets = targets
64
+ self.budget = budget
65
+ self.all_filesystems = all_filesystems or targets
66
+ self.target_devs = set(fs.dev for fs in targets)
67
+ self.notes = []
68
+ self.deleted_open_bytes = {} # dev -> bytes, filled by the deleted_open check
69
+ self._seen = set()
70
+
71
+ def dev_of(self, path):
72
+ st = self.system.lstat(path)
73
+ return None if st is None else st.st_dev
74
+
75
+ def on_target(self, path):
76
+ return self.dev_of(path) in self.target_devs
77
+
78
+ def size(self, path):
79
+ """Allocated bytes under path (on its own filesystem), or 0 if missing."""
80
+ local = self.system.path(path)
81
+ usage = du(local, budget=self.budget)
82
+ return usage.bytes
83
+
84
+ def filesystem(self, dev):
85
+ for fs in self.all_filesystems:
86
+ if fs.dev == dev:
87
+ return fs
88
+ return None
89
+
90
+ def note(self, text):
91
+ if text not in self.notes:
92
+ self.notes.append(text)