whyfull 0.1.0__py3-none-any.whl

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.
whyfull/__init__.py ADDED
@@ -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"
whyfull/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from whyfull.cli import main
4
+
5
+ sys.exit(main())
whyfull/analyze.py ADDED
@@ -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)
@@ -0,0 +1,94 @@
1
+ """Docker images, build cache, stopped containers, volumes and container logs."""
2
+
3
+ import json
4
+ import os
5
+
6
+ from whyfull.checks import check
7
+ from whyfull.model import CAUTION, REVIEW, SAFE, Finding
8
+ from whyfull.units import human, parse_size
9
+
10
+ MIN_RECLAIM = 200 * 10 ** 6
11
+ BIG_LOG = 500 * 10 ** 6
12
+
13
+ # docker system df row type -> (risk, title, fix, detail)
14
+ DF_ROWS = {
15
+ "Build Cache": (
16
+ SAFE, "Docker build cache", ["docker builder prune -f"],
17
+ "Layers cached by docker build. They are rebuilt on the next build that needs them.",
18
+ ),
19
+ "Images": (
20
+ REVIEW, "Docker images not used by any container", ["docker image prune -a -f"],
21
+ "Images with no container using them. They are pulled again if something needs them later.",
22
+ ),
23
+ "Containers": (
24
+ REVIEW, "Stopped Docker containers", ["docker container prune -f"],
25
+ "Writable layers of containers that have exited. Check nothing needs to be restarted from them.",
26
+ ),
27
+ "Local Volumes": (
28
+ CAUTION, "Docker volumes not used by any container", ["docker volume ls -f dangling=true # look first",
29
+ "docker volume prune -f"],
30
+ "Volumes hold data such as databases. Unused ones may still matter: inspect before pruning.",
31
+ ),
32
+ }
33
+
34
+
35
+ def docker_root(ctx):
36
+ out = ctx.system.run(["docker", "info", "--format", "{{.DockerRootDir}}"])
37
+ root = (out or "").strip()
38
+ return root or "/var/lib/docker"
39
+
40
+
41
+ @check("docker", "Docker images, build cache, containers and volumes")
42
+ def docker(ctx):
43
+ root = docker_root(ctx)
44
+ if not ctx.system.exists(root) or not ctx.on_target(root):
45
+ return
46
+ dev = ctx.dev_of(root)
47
+ out = ctx.system.run(["docker", "system", "df", "--format", "{{json .}}"], timeout=60)
48
+ if out is None:
49
+ ctx.note("Docker data lives on this disk, but `docker system df` didn't run; "
50
+ "run whyfull with sudo or as a member of the docker group to include it.")
51
+ else:
52
+ for line in out.splitlines():
53
+ try:
54
+ row = json.loads(line)
55
+ except ValueError:
56
+ continue
57
+ spec = DF_ROWS.get(row.get("Type"))
58
+ if spec is None:
59
+ continue
60
+ reclaim = parse_size(str(row.get("Reclaimable", "")).split(" (")[0])
61
+ if not reclaim or reclaim < MIN_RECLAIM:
62
+ continue
63
+ risk, title, fix, detail = spec
64
+ yield Finding("docker", row["Type"].lower().replace(" ", "-"), title, reclaim, risk, dev,
65
+ detail=detail, fix=fix,
66
+ evidence=["%s total, %s in use" % (row.get("TotalCount", "?"), row.get("Active", "?"))])
67
+
68
+ # Container logs are not in `docker system df`; read them from disk.
69
+ logs = []
70
+ containers = os.path.join(root, "containers")
71
+ for cid in ctx.system.listdir(containers):
72
+ log = os.path.join(containers, cid, cid + "-json.log")
73
+ st = ctx.system.stat(log)
74
+ if st is None or st.st_size < BIG_LOG:
75
+ continue
76
+ name = cid[:12]
77
+ cfg = ctx.system.read_text(os.path.join(containers, cid, "config.v2.json"))
78
+ if cfg:
79
+ try:
80
+ name = json.loads(cfg).get("Name", "").lstrip("/") or name
81
+ except ValueError:
82
+ pass
83
+ logs.append((st.st_blocks * 512, name, log))
84
+ logs.sort(reverse=True)
85
+ for size, name, _log in logs[:5]:
86
+ yield Finding(
87
+ "docker", "log:" + name, "Docker log of container %s is %s" % (name, human(size)),
88
+ size, CAUTION, dev,
89
+ detail=("The json-file log driver keeps everything a container prints unless "
90
+ "max-size is set. Truncating loses that history; set rotation so it doesn't grow back."),
91
+ fix=["sudo truncate -s 0 \"$(docker inspect --format '{{.LogPath}}' %s)\"" % name,
92
+ "# rotate future logs (applies to new containers): add to /etc/docker/daemon.json",
93
+ '# {"log-driver": "json-file", "log-opts": {"max-size": "50m", "max-file": "3"}}'],
94
+ )
@@ -0,0 +1,31 @@
1
+ """Crash reports and core dumps."""
2
+
3
+ from whyfull.checks import check
4
+ from whyfull.model import REVIEW, Finding
5
+
6
+ MIN_BYTES = 100 * 10 ** 6
7
+
8
+ PLACES = [
9
+ ("/var/lib/systemd/coredump", "systemd-coredump core dumps",
10
+ ["sudo find /var/lib/systemd/coredump -type f -delete",
11
+ "# cap them: set MaxUse=1G in /etc/systemd/coredump.conf"]),
12
+ ("/var/crash", "crash reports in /var/crash", ["sudo find /var/crash -type f -delete"]),
13
+ ("/var/lib/apport/coredump", "apport core dumps", ["sudo find /var/lib/apport/coredump -type f -delete"]),
14
+ ("/var/spool/abrt", "ABRT crash reports", ["sudo abrt-cli remove /var/spool/abrt/*"]),
15
+ ]
16
+
17
+
18
+ @check("crashes", "Core dumps and crash reports")
19
+ def run(ctx):
20
+ for path, title, fix in PLACES:
21
+ if not ctx.system.exists(path) or not ctx.on_target(path):
22
+ continue
23
+ size = ctx.size(path)
24
+ if size < MIN_BYTES:
25
+ continue
26
+ yield Finding(
27
+ "crashes", path, title, size, REVIEW, ctx.dev_of(path),
28
+ detail=("Memory dumps from crashed programs. Useful for debugging a recent crash; "
29
+ "otherwise safe to delete."),
30
+ fix=fix,
31
+ )
@@ -0,0 +1,125 @@
1
+ """Files that were deleted while a process still had them open.
2
+
3
+ The classic "df says full, du says empty" problem: deleting a log file that a
4
+ service is writing to removes its name, but the space is only released when
5
+ the last process closes it.
6
+ """
7
+
8
+ import os
9
+ import re
10
+
11
+ from whyfull.checks import check
12
+ from whyfull.model import CAUTION, Finding
13
+ from whyfull.walk import allocated
14
+
15
+ MIN_BYTES = 1 << 20 # ignore groups smaller than 1 MiB
16
+
17
+ _UNIT_RE = re.compile(r"/([^/]+\.service)(?:/|$)")
18
+ _DOCKER_RE = re.compile(r"docker[-/]([0-9a-f]{12,64})")
19
+
20
+
21
+ def _read(path):
22
+ try:
23
+ with open(path) as f:
24
+ return f.read()
25
+ except OSError:
26
+ return ""
27
+
28
+
29
+ def owner_of(proc, pid):
30
+ """('unit', 'nginx.service') / ('container', 'abc123...') / ('process', 'name')."""
31
+ cgroup = _read(os.path.join(proc, pid, "cgroup"))
32
+ m = _DOCKER_RE.search(cgroup)
33
+ if m:
34
+ return "container", m.group(1)[:12]
35
+ units = _UNIT_RE.findall(cgroup)
36
+ # Skip per-user manager units; user@1000.service is not what holds the file.
37
+ units = [u for u in units if not u.startswith("user@")]
38
+ if units:
39
+ return "unit", units[-1]
40
+ comm = _read(os.path.join(proc, pid, "comm")).strip() or "pid " + pid
41
+ return "process", comm
42
+
43
+
44
+ def scan_proc(proc):
45
+ """Yield (pid, fd, target, stat) for every open file that has been deleted."""
46
+ try:
47
+ pids = [p for p in os.listdir(proc) if p.isdigit()]
48
+ except OSError:
49
+ return
50
+ for pid in pids:
51
+ fd_dir = os.path.join(proc, pid, "fd")
52
+ try:
53
+ fds = os.listdir(fd_dir)
54
+ except OSError:
55
+ continue
56
+ for fd in fds:
57
+ fd_path = os.path.join(fd_dir, fd)
58
+ try:
59
+ target = os.readlink(fd_path)
60
+ except OSError:
61
+ continue
62
+ if not target.endswith(" (deleted)") or not target.startswith("/"):
63
+ continue
64
+ if target.startswith("/memfd:"):
65
+ continue
66
+ try:
67
+ st = os.stat(fd_path)
68
+ except OSError:
69
+ continue
70
+ if st.st_nlink != 0:
71
+ continue
72
+ yield pid, fd, target[: -len(" (deleted)")], st
73
+
74
+
75
+ @check("deleted-open", "Deleted files still held open by a process")
76
+ def run(ctx):
77
+ groups = {} # (dev, kind, name) -> dict
78
+ seen = set()
79
+ for pid, fd, path, st in scan_proc(ctx.system.proc):
80
+ if st.st_dev not in ctx.target_devs:
81
+ continue
82
+ kind, name = owner_of(ctx.system.proc, pid)
83
+ g = groups.setdefault((st.st_dev, kind, name), {"bytes": 0, "files": {}, "fds": []})
84
+ g["fds"].append((pid, fd))
85
+ key = (st.st_dev, st.st_ino)
86
+ if key in seen:
87
+ continue
88
+ seen.add(key)
89
+ size = allocated(st)
90
+ g["bytes"] += size
91
+ g["files"][path] = g["files"].get(path, 0) + size
92
+
93
+ if not ctx.system.is_root:
94
+ ctx.note("Run with sudo to see deleted files held open by other users' processes.")
95
+
96
+ for (dev, kind, name), g in groups.items():
97
+ ctx.deleted_open_bytes[dev] = ctx.deleted_open_bytes.get(dev, 0) + g["bytes"]
98
+ if g["bytes"] < MIN_BYTES:
99
+ continue
100
+ nfiles = len(g["files"])
101
+ noun = "file" if nfiles == 1 else "files"
102
+ if kind == "unit":
103
+ short = name[: -len(".service")]
104
+ title = "%d deleted %s still open by %s" % (nfiles, noun, name)
105
+ fix = ["sudo systemctl restart %s" % short]
106
+ elif kind == "container":
107
+ title = "%d deleted %s still open in container %s" % (nfiles, noun, name)
108
+ fix = ["docker restart %s" % name]
109
+ else:
110
+ title = "%d deleted %s still open by %s" % (nfiles, noun, name)
111
+ fix = ["# restart %s, or free the space without a restart:" % name]
112
+ # Truncating through /proc frees the space immediately and keeps the process running.
113
+ truncate = ["sudo truncate -s 0 /proc/%s/fd/%s" % (p, f) for p, f in sorted(set(g["fds"]))[:5]]
114
+ if kind != "process":
115
+ fix.append("# or free the space without a restart:")
116
+ fix.extend(truncate)
117
+ biggest = sorted(g["files"].items(), key=lambda kv: -kv[1])[:5]
118
+ evidence = ["%s (deleted)" % (ctx.system.display(p) if p.startswith(ctx.system.root + "/") else p)
119
+ for p, _ in biggest]
120
+ yield Finding(
121
+ "deleted-open", "%s:%s" % (kind, name), title, g["bytes"], CAUTION, dev,
122
+ detail=("These files were deleted, but the process writing to them never closed "
123
+ "them, so the space is still in use. du can't see it; df can."),
124
+ fix=fix, evidence=evidence,
125
+ )
@@ -0,0 +1,33 @@
1
+ """The systemd journal."""
2
+
3
+ from whyfull.checks import check
4
+ from whyfull.model import REVIEW, Finding
5
+ from whyfull.units import human
6
+
7
+ KEEP = 200 * 10 ** 6 # what we suggest vacuuming down to
8
+ MIN_RECLAIM = 100 * 10 ** 6
9
+
10
+
11
+ @check("journald", "systemd journal")
12
+ def run(ctx):
13
+ for path in ("/var/log/journal", "/run/log/journal"):
14
+ if not ctx.system.exists(path) or not ctx.on_target(path):
15
+ continue
16
+ size = ctx.size(path)
17
+ reclaim = size - KEEP
18
+ if reclaim < MIN_RECLAIM:
19
+ continue
20
+ yield Finding(
21
+ "journald", path, "systemd journal in %s is %s" % (path, human(size)),
22
+ reclaim, REVIEW, ctx.dev_of(path),
23
+ detail=("journald keeps old logs until it reaches its size cap (by default 10%% of "
24
+ "the filesystem). Vacuuming keeps the newest %s; capping it stops it "
25
+ "growing back." % human(KEEP)),
26
+ fix=[
27
+ "sudo journalctl --vacuum-size=200M",
28
+ "# keep it capped:",
29
+ "sudo mkdir -p /etc/systemd/journald.conf.d",
30
+ "printf '[Journal]\\nSystemMaxUse=200M\\n' | sudo tee /etc/systemd/journald.conf.d/size.conf",
31
+ "sudo systemctl restart systemd-journald",
32
+ ],
33
+ )
@@ -0,0 +1,82 @@
1
+ """Old kernels: their modules under /lib/modules and their images in /boot."""
2
+
3
+ import os
4
+ import re
5
+
6
+ from whyfull.checks import check
7
+ from whyfull.model import REVIEW, Finding
8
+ from whyfull.units import human
9
+
10
+ MIN_BYTES = 50 * 10 ** 6
11
+ BOOT_PATTERNS = ["vmlinuz-%s", "initrd.img-%s", "initramfs-%s.img", "initramfs-%skdump.img",
12
+ "System.map-%s", "config-%s", "abi-%s", "retpoline-%s"]
13
+
14
+ FIXES = {
15
+ "debian": ["sudo apt autoremove --purge",
16
+ "# if old kernels remain, purge them by name, e.g.: sudo apt purge linux-image-%(v)s"],
17
+ "rhel": ["sudo dnf remove --oldinstallonly --setopt installonly_limit=2 kernel"],
18
+ "suse": ["sudo zypper purge-kernels"],
19
+ }
20
+
21
+
22
+ def version_key(v):
23
+ return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", v)]
24
+
25
+
26
+ def installed(ctx):
27
+ """Kernel versions with a modules directory, and where each directory is."""
28
+ found = {}
29
+ for base in ("/lib/modules", "/usr/lib/modules"):
30
+ for v in ctx.system.listdir(base):
31
+ path = os.path.join(base, v)
32
+ local = ctx.system.path(path)
33
+ if not os.path.isdir(local):
34
+ continue
35
+ real = os.path.realpath(local)
36
+ if any(os.path.realpath(ctx.system.path(p)) == real for p in found.values()):
37
+ continue
38
+ found.setdefault(v, path)
39
+ return found
40
+
41
+
42
+ @check("kernels", "Old kernels")
43
+ def run(ctx):
44
+ kernels = installed(ctx)
45
+ if len(kernels) < 2:
46
+ return
47
+ running = ctx.system.kernel_release()
48
+ newest = max(kernels, key=version_key)
49
+ old = sorted((v for v in kernels if v not in (running, newest)), key=version_key)
50
+ if not old:
51
+ return
52
+
53
+ per_dev = {} # dev -> [bytes, versions]
54
+ for v in old:
55
+ parts = [kernels[v]] + ["/boot/" + p % v for p in BOOT_PATTERNS]
56
+ for path in parts:
57
+ if not ctx.system.exists(path) or not ctx.on_target(path):
58
+ continue
59
+ entry = per_dev.setdefault(ctx.dev_of(path), [0, set()])
60
+ entry[0] += ctx.size(path)
61
+ entry[1].add(v)
62
+
63
+ family = ctx.system.distro_family()
64
+ for dev, (nbytes, versions) in per_dev.items():
65
+ if nbytes < MIN_BYTES:
66
+ continue
67
+ versions = sorted(versions, key=version_key)
68
+ fs = ctx.filesystem(dev)
69
+ where = " in /boot" if fs is not None and fs.mountpoint == "/boot" else ""
70
+ fix = [f % {"v": versions[0]} for f in FIXES.get(family, [])] or \
71
+ ["# remove the kernel packages for: " + ", ".join(versions)]
72
+ yield Finding(
73
+ "kernels", str(dev), "%d old kernel%s%s" % (len(versions), "" if len(versions) == 1 else "s", where),
74
+ nbytes, REVIEW, dev,
75
+ detail=("Kernels other than the running one (%s) and the newest (%s). Package managers keep "
76
+ "them as fallbacks; one spare is plenty. A full /boot can make kernel updates fail."
77
+ % (running, newest)),
78
+ fix=fix,
79
+ evidence=["%s" % v for v in versions[:8]],
80
+ )
81
+ if per_dev and not any(b >= MIN_BYTES for b, _ in per_dev.values()):
82
+ ctx.note("Old kernels found but they are small (%s)." % human(sum(b for b, _ in per_dev.values())))