Files
scripts/system-optimise.py
T

1252 lines
45 KiB
Python

#!/usr/bin/env python3
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
# SPDX-License-Identifier: MIT
"""Idempotent system cleanup and optimisation for Fedora and openEuler.
Cleans old kernels, DNF caches, systemd journals, temp files, and core dumps.
Every operation checks current state before acting; running the script
repeatedly produces the same result with no errors and no duplicate work.
ostree-based (atomic) systems are refused — rpm-ostree manages those.
Usage:
system-optimise.py # full cleanup
system-optimise.py --dry-run # preview without changes
system-optimise.py --skip-dnf # skip DNF cleanup
system-optimise.py --skip-journal # skip journal cleanup
system-optimise.py --skip-tmp # skip temp file cleanup
system-optimise.py --skip-cores # skip core dump cleanup
system-optimise.py --version
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import time
from functools import cmp_to_key
from typing import Any, Callable
BOLD = "\033[1m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
DIM = "\033[2m"
RESET = "\033[0m"
VERSION = "1.3.1"
SUPPORTED_OS: dict[str, str] = {
"fedora": "Fedora",
"openeuler": "openEuler",
}
# Kernel retention: keep the newest INSTALLONLY_LIMIT kernels plus the running
# one. dnf forbids installonly_limit=1 — a bootable fallback must always remain.
INSTALLONLY_LIMIT = 2
# Temp file retention (matches systemd-tmpfiles defaults on both targets).
TMP_MAX_AGE_DAYS = 10
VARTMP_MAX_AGE_DAYS = 30
# Core dumps younger than this are kept: a crash under active analysis must
# never be swept away by a cleanup run.
COREDUMP_MAX_AGE_DAYS = 7
JOURNAL_VACUUM_SIZE_MB = 500
JOURNAL_WARN_BYTES = 1024 * 1024 * 1024 # warn when the journal stays above 1 GiB
JOURNAL_DIRS = ("/var/log/journal", "/run/log/journal")
# Unit file types audited for leftovers in /etc/systemd/system/.
UNIT_SUFFIXES = (
".service",
".socket",
".timer",
".target",
".path",
".mount",
".automount",
".slice",
)
# ---------------------------------------------------------------------------
# Progress helpers — write to stderr so stdout stays clean
# ---------------------------------------------------------------------------
def _status(msg: str) -> None:
"""Print a progress message to stderr — stays on the same line until ``_status_done``."""
print(f" {msg}...", end="", flush=True, file=sys.stderr)
def _status_done(msg: str = "done") -> None:
"""Complete the current progress line on stderr."""
print(f" {msg}", file=sys.stderr)
def _info(msg: str) -> None:
"""Print an informational line to stderr."""
print(f" {DIM}{msg}{RESET}", file=sys.stderr)
def _warn(msg: str) -> None:
"""Print a warning to stderr."""
print(f" {YELLOW}{msg}{RESET}", file=sys.stderr)
def _ok(msg: str) -> None:
"""Print a success message to stderr."""
print(f" {GREEN}{msg}{RESET}", file=sys.stderr)
def _fail(msg: str) -> None:
"""Print a failure message to stderr."""
print(f" {RED}{msg}{RESET}", file=sys.stderr)
# ---------------------------------------------------------------------------
# Low-level command runner
# ---------------------------------------------------------------------------
def run(cmd: list[str], timeout: int = 120) -> subprocess.CompletedProcess[str]:
"""Execute a command and return the CompletedProcess.
LC_ALL=C is forced for reliable English-language output parsing.
Missing binaries, timeouts, and exec failures are converted into a
synthetic failed CompletedProcess (rc 127 / 124 / 126) so callers only
ever need to check returncode — exceptions never escape this function.
"""
env = {**os.environ, "LANG": "C", "LC_ALL": "C"}
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env)
except FileNotFoundError:
return subprocess.CompletedProcess(cmd, 127, "", f"command not found: {cmd[0]}")
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(cmd, 124, "", f"timed out after {timeout}s")
except OSError as exc:
return subprocess.CompletedProcess(cmd, 126, "", f"cannot execute {cmd[0]}: {exc}")
# ---------------------------------------------------------------------------
# OS detection
# ---------------------------------------------------------------------------
def parse_os_release() -> dict[str, str]:
"""Parse /etc/os-release into a dict of KEY=VALUE pairs."""
result: dict[str, str] = {}
try:
with open("/etc/os-release", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
value = value.strip().strip('"').strip("'")
result[key] = value
except OSError:
pass
return result
def detect_os() -> tuple[str, str, str]:
"""Detect the operating system.
Returns a tuple of (os_id, display_name, version_id).
Exits with an error message when the OS is not supported.
"""
release = parse_os_release()
os_id = release.get("ID", "").lower()
version_id = release.get("VERSION_ID", "unknown")
# ostree-based (atomic) systems report a normal ID (e.g. Silverblue is
# ID=fedora) but have no dnf and manage kernels via rpm-ostree — refuse.
if os.path.exists("/run/ostree-booted"):
print(
f"{RED}{BOLD}Error:{RESET} ostree-based (atomic) system detected "
"(/run/ostree-booted present).",
file=sys.stderr,
)
print(
" Package and kernel management there belongs to rpm-ostree; "
"this script only supports traditional dnf/rpm systems.",
file=sys.stderr,
)
sys.exit(1)
if os_id not in SUPPORTED_OS:
print(
f"{RED}{BOLD}Error:{RESET} Unsupported operating system: "
f"'{release.get('ID', 'unknown')}' (detected from /etc/os-release).",
file=sys.stderr,
)
print(
f" Supported systems: {', '.join(SUPPORTED_OS.values())}",
file=sys.stderr,
)
sys.exit(1)
return os_id, SUPPORTED_OS[os_id], version_id
# ---------------------------------------------------------------------------
# Root check
# ---------------------------------------------------------------------------
def check_root() -> None:
"""Exit with a clear message if not running as root (UID 0)."""
if os.geteuid() != 0:
print(
f"{RED}{BOLD}Error:{RESET} This script must be run as root (UID 0).",
file=sys.stderr,
)
print(
f" Current UID: {os.geteuid()}. Try: sudo python3 system-optimise.py",
file=sys.stderr,
)
sys.exit(1)
# ---------------------------------------------------------------------------
# Utility helpers
# ---------------------------------------------------------------------------
def _format_size(size_bytes: int) -> str:
"""Format a byte count into a human-readable string (MB or GB)."""
if size_bytes < 0:
return "0 B"
if size_bytes >= 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
if size_bytes >= 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
if size_bytes >= 1024:
return f"{size_bytes / 1024:.1f} KB"
return f"{size_bytes} B"
def _du(path: str) -> int:
"""Return the total size (in bytes) of all files under *path*.
Returns 0 if the path does not exist.
"""
if not os.path.exists(path):
return 0
total: int = 0
for dirpath, _dirnames, filenames in os.walk(path):
for fname in filenames:
fpath = os.path.join(dirpath, fname)
try:
total += os.path.getsize(fpath)
except OSError:
pass
return total
def _last_lines(text: str, count: int = 3) -> str:
"""Return the last *count* non-empty lines of *text*, joined for display."""
lines = [line.strip() for line in text.strip().split("\n") if line.strip()]
return " | ".join(lines[-count:]) if lines else "no error output"
# ---------------------------------------------------------------------------
# Section 1 — DNF Cleanup
# ---------------------------------------------------------------------------
def _kernel_pkg_name(os_id: str) -> str:
"""Return the installonly kernel package name for the detected OS."""
return "kernel" if os_id == "openeuler" else "kernel-core"
def _rpmvercmp(ver_a: str, ver_b: str) -> int:
"""Compare two version-release strings using RPM's rpmvercmp algorithm.
Segments alternate between digits and letters; numeric segments compare
numerically and always beat alphabetic ones; "~" sorts before everything
(including end-of-string). Returns <0, 0 or >0 like strcmp.
"""
pos_a = pos_b = 0
while pos_a < len(ver_a) or pos_b < len(ver_b):
tilde_a = pos_a < len(ver_a) and ver_a[pos_a] == "~"
tilde_b = pos_b < len(ver_b) and ver_b[pos_b] == "~"
if tilde_a or tilde_b:
if tilde_a and tilde_b:
pos_a += 1
pos_b += 1
continue
return -1 if tilde_a else 1
# skip separators (anything not alphanumeric)
while pos_a < len(ver_a) and not ver_a[pos_a].isalnum():
pos_a += 1
while pos_b < len(ver_b) and not ver_b[pos_b].isalnum():
pos_b += 1
if pos_a >= len(ver_a) or pos_b >= len(ver_b):
break
if ver_a[pos_a].isdigit() != ver_b[pos_b].isdigit():
# numeric segments always beat alphabetic ones
return 1 if ver_a[pos_a].isdigit() else -1
start_a, start_b = pos_a, pos_b
if ver_a[pos_a].isdigit():
while pos_a < len(ver_a) and ver_a[pos_a].isdigit():
pos_a += 1
while pos_b < len(ver_b) and ver_b[pos_b].isdigit():
pos_b += 1
seg_a = ver_a[start_a:pos_a].lstrip("0") or "0"
seg_b = ver_b[start_b:pos_b].lstrip("0") or "0"
if len(seg_a) != len(seg_b):
return 1 if len(seg_a) > len(seg_b) else -1
else:
while pos_a < len(ver_a) and ver_a[pos_a].isalpha():
pos_a += 1
while pos_b < len(ver_b) and ver_b[pos_b].isalpha():
pos_b += 1
seg_a = ver_a[start_a:pos_a]
seg_b = ver_b[start_b:pos_b]
if seg_a != seg_b:
return 1 if seg_a > seg_b else -1
# whichever side still has segments left over is the newer version
if pos_a < len(ver_a):
return 1
if pos_b < len(ver_b):
return -1
return 0
def _rpm_evr_cmp(evr_a: str, evr_b: str) -> int:
"""Compare two EVR (epoch:version-release) strings, epoch first."""
epoch_a, _, rest_a = evr_a.partition(":")
epoch_b, _, rest_b = evr_b.partition(":")
if epoch_a.isdigit() and epoch_b.isdigit() and epoch_a != epoch_b:
return 1 if int(epoch_a) > int(epoch_b) else -1
return _rpmvercmp(rest_a, rest_b)
def _list_kernel_packages(os_id: str) -> list[str] | None:
"""List installed kernel package EVRs (epoch:version-release.arch), newest first.
Returns None when the rpm query itself fails — callers must distinguish
that from an empty list (no kernel packages installed). Sorting uses RPM
version comparison: rpm -q output order is install order and must never
be trusted as version order.
"""
result = run(
[
"rpm",
"-q",
_kernel_pkg_name(os_id),
"--qf",
"%{EPOCHNUM}:%{VERSION}-%{RELEASE}.%{ARCH}\n",
],
timeout=30,
)
if result.returncode != 0:
return None
evrs = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
evrs.sort(key=cmp_to_key(_rpm_evr_cmp), reverse=True)
return evrs
def _select_kernels_to_keep(kernels: list[str], running: str) -> tuple[list[str], list[str]]:
"""Split EVRs (newest first) into (kept, would_remove).
Mirrors dnf's --oldinstallonly behaviour with INSTALLONLY_LIMIT: keep the
newest INSTALLONLY_LIMIT versions and always keep the running kernel,
even when it is not among the newest (updated but not yet rebooted).
"""
kept = list(kernels[:INSTALLONLY_LIMIT])
running_evr = next((e for e in kernels if e.split(":", 1)[1] == running), None)
if running_evr is not None and running_evr not in kept:
kept.append(running_evr)
would_remove = [e for e in kernels if e not in kept]
return kept, would_remove
def _dnf_generation() -> int:
"""Return the dnf major version (e.g. 4 or 5); defaults to 4 when undetectable."""
p = run(["dnf", "--version"], timeout=15)
if p.returncode == 0 and p.stdout.strip():
match = re.search(r"(\d+)\.\d+", p.stdout.strip().splitlines()[0])
if match:
return int(match.group(1))
return 4
def _dnf_cache_dirs(dnf_major: int) -> tuple[list[str], list[str]]:
"""Return (active, stale) dnf cache directories for the dnf generation.
dnf5 caches in /var/cache/libdnf5; dnf4 in /var/cache/dnf (+ legacy yum).
Stale dirs belong to the other generation — reported, never auto-cleaned.
"""
if dnf_major >= 5:
return ["/var/cache/libdnf5"], ["/var/cache/dnf", "/var/cache/yum"]
return ["/var/cache/dnf", "/var/cache/yum"], []
def _parse_removal_count(text: str) -> int | None:
"""Extract the removed-package count from dnf transaction output.
Handles dnf4 ("Remove 12 Packages") and dnf5 ("Removing: 12 packages")
summary wording. Returns 0 for "Nothing to do", None when unparseable.
"""
match = re.search(r"^Remove\s+(\d+)\s+Packages?\b", text, re.MULTILINE)
if match is None:
match = re.search(r"^\s*Removing:\s+(\d+)\s+packages?\b", text, re.MULTILINE)
if match:
return int(match.group(1))
return 0 if "Nothing to do" in text else None
def dnf_cleanup(
os_id: str, dnf_major: int, *, dry_run: bool, warnings: list[str]
) -> dict[str, Any]:
"""Prune old kernels via dnf's own mechanism, autoremove, and clean the cache.
Kernel pruning delegates to ``dnf remove --oldinstallonly``, which keeps
the newest INSTALLONLY_LIMIT versions and never touches the running
kernel. Returns a dict with keys: autoremoved, autoremoved_count,
kernels_removed (real mode) / kernels_planned (dry-run), kernels_kept,
space_saved, cache_cleaned, errors.
"""
pkg_name = _kernel_pkg_name(os_id)
result: dict[str, Any] = {
"autoremoved": False,
"autoremoved_count": 0,
"kernels_removed": 0,
"kernels_planned": 0,
"kernels_kept": "",
"space_saved": 0,
"cache_cleaned": False,
"errors": [],
}
# 1. Autoremove unneeded packages
_status("Removing unneeded packages (dnf autoremove)")
if dry_run:
# --assumeno makes dnf print the transaction and answer "no"; the
# exit code may then be non-zero, so parse stdout before checking it.
p = run(["dnf", "autoremove", "--assumeno"], timeout=300)
count = _parse_removal_count(p.stdout)
if count is not None:
result["autoremoved_count"] = count
_status_done(f"would remove {count} package(s)" if count else "nothing to remove")
elif p.returncode == 0:
_status_done("would run (package count unavailable)")
else:
_status_done("failed")
_warn(f"dnf autoremove preview failed: {_last_lines(p.stderr)}")
result["errors"].append("dnf autoremove preview failed")
else:
p = run(["dnf", "autoremove", "-y"], timeout=300)
if p.returncode == 0:
count = _parse_removal_count(p.stdout)
result["autoremoved_count"] = count or 0
result["autoremoved"] = bool(count)
_status_done(f"removed {count} package(s)" if count else "nothing to do")
else:
_status_done("failed")
_warn(f"dnf autoremove failed: {_last_lines(p.stderr)}")
result["errors"].append("dnf autoremove failed")
# 2. Prune old kernels — dnf decides what to remove, never the script
kernels = _list_kernel_packages(os_id)
if kernels is None:
_warn("Could not query installed kernels (rpm) — skipping kernel cleanup")
warnings.append("Kernel query via rpm failed — kernel cleanup skipped")
result["errors"].append("rpm kernel query failed")
result["kernels_kept"] = "unknown"
elif not kernels:
_info("No kernel packages found")
result["kernels_kept"] = "none"
else:
running = os.uname().release
kept, would_remove = _select_kernels_to_keep(kernels, running)
result["kernels_kept"] = ", ".join(f"{pkg_name}-{evr}" for evr in kept)
if not would_remove:
_info(f"Old kernels: none to remove ({len(kernels)} installed, keeping {len(kept)})")
elif dry_run:
_status(f"Pruning old kernels (keeping newest {INSTALLONLY_LIMIT} + running)")
result["kernels_planned"] = len(would_remove)
_status_done(f"would remove {len(would_remove)}")
for evr in would_remove:
_info(f"would remove {pkg_name}-{evr}")
else:
_status(f"Pruning {len(would_remove)} old kernel(s) via dnf (keeping {len(kept)})")
cmd = ["dnf", "remove", "-y", "--oldinstallonly"]
if dnf_major >= 5:
cmd.append(f"--limit={INSTALLONLY_LIMIT}")
else:
cmd += ["--setopt", f"installonly_limit={INSTALLONLY_LIMIT}"]
cmd.append(pkg_name)
p = run(cmd, timeout=300)
if p.returncode == 0:
result["kernels_removed"] = len(would_remove)
_status_done(f"removed {len(would_remove)} (running: {running})")
else:
_status_done("failed")
_warn(f"Kernel removal failed: {_last_lines(p.stderr)}")
result["errors"].append("old kernel removal failed")
# 3. Clean DNF cache — measure only what the active dnf generation cleans
active_dirs, stale_dirs = _dnf_cache_dirs(dnf_major)
for stale in stale_dirs:
stale_size = _du(stale)
if stale_size > 0:
msg = f"Stale dnf4 cache at {stale} ({_format_size(stale_size)}) — unused by dnf5"
_info(msg)
warnings.append(msg)
cache_before = sum(_du(d) for d in active_dirs)
_status("Cleaning DNF cache")
if dry_run:
result["space_saved"] += cache_before
_status_done(f"would free ~{_format_size(cache_before)}")
else:
p = run(["dnf", "clean", "all"], timeout=60)
if p.returncode == 0:
saved = max(0, cache_before - sum(_du(d) for d in active_dirs))
result["space_saved"] += saved
result["cache_cleaned"] = True
_status_done(_format_size(saved))
else:
_status_done("failed")
_warn(f"dnf clean all failed: {_last_lines(p.stderr)}")
result["errors"].append("dnf clean all failed")
return result
# ---------------------------------------------------------------------------
# Section 2 — Journal Cleanup
# ---------------------------------------------------------------------------
def journal_cleanup(*, dry_run: bool, warnings: list[str]) -> dict[str, Any]:
"""Vacuum the systemd journal to JOURNAL_VACUUM_SIZE_MB and report space freed.
Measures both the persistent (/var/log/journal) and volatile
(/run/log/journal) locations. Returns a dict with keys: vacuumed,
space_saved, journal_size_after, persistent_warning, errors.
"""
result: dict[str, Any] = {
"vacuumed": False,
"space_saved": 0,
"journal_size_after": 0,
"persistent_warning": False,
"errors": [],
}
def journal_size() -> int:
return sum(_du(path) for path in JOURNAL_DIRS if os.path.exists(path))
vacuum_arg = f"--vacuum-size={JOURNAL_VACUUM_SIZE_MB}M"
size_before = journal_size()
_status(f"Vacuuming journal ({vacuum_arg})")
if dry_run:
estimated = max(0, size_before - JOURNAL_VACUUM_SIZE_MB * 1024 * 1024)
result["space_saved"] = estimated
_status_done(f"would free ~{_format_size(estimated)}")
size_check = size_before - estimated
else:
p = run(["journalctl", vacuum_arg], timeout=120)
if p.returncode == 0:
result["vacuumed"] = True
result["journal_size_after"] = journal_size()
result["space_saved"] = max(0, size_before - result["journal_size_after"])
_status_done(_format_size(result["space_saved"]))
else:
_status_done("failed")
_warn(f"journalctl vacuum failed: {_last_lines(p.stderr)}")
result["errors"].append("journal vacuum failed")
size_check = journal_size()
# Warn when the journal stays large despite vacuuming (post-vacuum size)
if size_check > JOURNAL_WARN_BYTES:
msg = (
f"Journal still large ({_format_size(size_check)}) after vacuum. "
"Consider setting SystemMaxUse= in /etc/systemd/journald.conf"
)
_warn(msg)
warnings.append(msg)
result["persistent_warning"] = True
return result
# ---------------------------------------------------------------------------
# Section 3 — systemd Audit
# ---------------------------------------------------------------------------
def systemd_audit(*, warnings: list[str]) -> dict[str, Any]:
"""Audit systemd for failed units, disabled unit files, and leftover
files in /etc/systemd/system/.
This section is purely informational — no changes are made.
Returns a dict with keys: failed_units, disabled_count, leftover_files,
errors.
"""
result: dict[str, Any] = {
"failed_units": [],
"disabled_count": 0,
"leftover_files": [],
"errors": [],
}
# 1. Failed units
_status("Checking for failed systemd units")
p = run(["systemctl", "--failed", "--no-legend"], timeout=30)
if p.returncode != 0 and not p.stdout.strip():
_status_done("unavailable")
msg = f"systemctl --failed unavailable: {_last_lines(p.stderr)}"
_warn(msg)
warnings.append(msg)
else:
failed_lines = [line.strip() for line in p.stdout.strip().split("\n") if line.strip()]
result["failed_units"] = failed_lines
_status_done(f"{len(failed_lines)} failed")
for line in failed_lines:
_warn(f"Failed unit: {line}")
if failed_lines:
warnings.append(f"{len(failed_lines)} failed systemd unit(s) — see above")
# 2. Disabled unit files (informational)
_status("Counting disabled unit files")
p = run(
["systemctl", "list-unit-files", "--state=disabled", "--no-legend"],
timeout=30,
)
disabled_lines = [line.strip() for line in p.stdout.strip().split("\n") if line.strip()]
result["disabled_count"] = len(disabled_lines)
_status_done(f"{result['disabled_count']} disabled (informational only)")
# 3. Leftover unit files: regular files systemd does not know about.
# A single list-unit-files call yields every unit name systemd
# recognises — static, disabled and indirect units are legitimate and
# must NOT be flagged; only names missing entirely are leftovers.
etc_systemd = "/etc/systemd/system"
if os.path.isdir(etc_systemd):
_status("Checking for leftover unit files")
p = run(["systemctl", "list-unit-files", "--no-legend"], timeout=30)
if p.returncode != 0:
_status_done("unavailable")
msg = f"systemctl list-unit-files unavailable: {_last_lines(p.stderr)}"
_warn(msg)
warnings.append(msg)
else:
known = {line.split()[0] for line in p.stdout.splitlines() if line.split()}
leftovers = []
for entry in sorted(os.listdir(etc_systemd)):
full_path = os.path.join(etc_systemd, entry)
if os.path.islink(full_path) or not os.path.isfile(full_path):
continue
if not entry.endswith(UNIT_SUFFIXES):
continue
if entry not in known:
leftovers.append(entry)
result["leftover_files"] = leftovers
if leftovers:
_status_done(f"{len(leftovers)} leftover(s)")
for leftover in leftovers:
_warn(f"Leftover unit file: {etc_systemd}/{leftover}")
warnings.append(f"{len(leftovers)} leftover unit file(s) in {etc_systemd}/")
else:
_status_done("none")
return result
# ---------------------------------------------------------------------------
# Section 4 — Temp File Cleanup
# ---------------------------------------------------------------------------
def _find_old_files(directory: str, days: int) -> list[str] | None:
"""List regular files under *directory* older than *days* days.
Stays on one filesystem (-xdev) and never matches the start directory
itself (-mindepth 1). Returns None when the find command fails.
"""
p = run(
[
"find",
directory,
"-mindepth",
"1",
"-xdev",
"-type",
"f",
"-mtime",
f"+{days}",
"-print0",
],
timeout=120,
)
if p.returncode != 0:
return None
return [path for path in p.stdout.split("\0") if path]
def _paths_size(paths: list[str]) -> int:
"""Return the total size of *paths*, ignoring files that vanished."""
total = 0
for path in paths:
try:
total += os.path.getsize(path)
except OSError:
pass
return total
def _clean_temp_dir(
directory: str,
days: int,
*,
dry_run: bool,
warnings: list[str],
errors: list[str],
) -> tuple[int, int]:
"""Clean one temp directory; return (files affected, bytes freed).
Files are counted and measured BEFORE anything is deleted, so real-mode
reporting reflects what was actually removed. Dry-run deletes nothing and
reports the exact size of the matching files only.
"""
old_files = _find_old_files(directory, days)
if old_files is None:
_status_done("failed")
_warn(f"find {directory} failed — cannot list old files")
warnings.append(f"Temp cleanup of {directory} failed (find error)")
errors.append(f"find failed in {directory}")
return 0, 0
old_size = _paths_size(old_files)
if dry_run:
_status_done(f"would remove {len(old_files)} file(s) (~{_format_size(old_size)})")
return len(old_files), old_size
for extra_args in (["-type", "f", "-mtime", f"+{days}"], ["-type", "d", "-empty"]):
p = run(
["find", directory, "-mindepth", "1", "-xdev", *extra_args, "-delete"],
timeout=120,
)
if p.returncode != 0:
_warn(f"find {directory} -delete failed: {_last_lines(p.stderr)}")
warnings.append(f"Temp cleanup of {directory} hit find errors")
errors.append(f"find -delete failed in {directory}")
remaining = _find_old_files(directory, days) or []
removed = len(old_files) - len(remaining)
freed = max(0, old_size - _paths_size(remaining))
_status_done(f"{removed} file(s), freed {_format_size(freed)}")
return removed, freed
def temp_cleanup(*, dry_run: bool, warnings: list[str]) -> dict[str, Any]:
"""Remove old files from /tmp and /var/tmp.
/tmp: files older than TMP_MAX_AGE_DAYS. /var/tmp: files older than
VARTMP_MAX_AGE_DAYS. Returns a dict with keys: tmp_files_removed,
vartmp_files_removed (real mode) / *_planned (dry-run), space_saved,
errors.
"""
result: dict[str, Any] = {
"tmp_files_removed": 0,
"vartmp_files_removed": 0,
"tmp_files_planned": 0,
"vartmp_files_planned": 0,
"space_saved": 0,
"errors": [],
}
_status(f"Cleaning /tmp (files older than {TMP_MAX_AGE_DAYS} days)")
removed, freed = _clean_temp_dir(
"/tmp", TMP_MAX_AGE_DAYS, dry_run=dry_run, warnings=warnings, errors=result["errors"]
)
result["tmp_files_planned" if dry_run else "tmp_files_removed"] = removed
result["space_saved"] += freed
if os.path.isdir("/var/tmp"):
_status(f"Cleaning /var/tmp (files older than {VARTMP_MAX_AGE_DAYS} days)")
removed, freed = _clean_temp_dir(
"/var/tmp",
VARTMP_MAX_AGE_DAYS,
dry_run=dry_run,
warnings=warnings,
errors=result["errors"],
)
result["vartmp_files_planned" if dry_run else "vartmp_files_removed"] = removed
result["space_saved"] += freed
else:
_info("/var/tmp: directory not found, skipping")
return result
# ---------------------------------------------------------------------------
# Section 5 — Core Dump Cleanup
# ---------------------------------------------------------------------------
def coredump_cleanup(*, dry_run: bool, warnings: list[str]) -> dict[str, Any]:
"""Remove core dumps older than COREDUMP_MAX_AGE_DAYS.
Operates on /var/lib/systemd/coredump/. Recent dumps are always kept —
a crash under active analysis must never be deleted. Returns a dict with
keys: cores_removed (real mode) / cores_planned (dry-run),
cores_kept_recent, space_saved, errors.
"""
result: dict[str, Any] = {
"cores_removed": 0,
"cores_planned": 0,
"cores_kept_recent": 0,
"space_saved": 0,
"errors": [],
}
coredump_dir = "/var/lib/systemd/coredump"
if not os.path.isdir(coredump_dir):
_info("No coredump directory found, skipping")
return result
cutoff = time.time() - COREDUMP_MAX_AGE_DAYS * 86400
old_dumps: list[tuple[str, float, int]] = [] # (name, mtime, size)
recent = 0
try:
entries = sorted(os.listdir(coredump_dir))
except OSError as exc:
_warn(f"Cannot list {coredump_dir}: {exc}")
warnings.append(f"Core dump cleanup skipped — cannot read {coredump_dir}")
result["errors"].append("coredump listdir failed")
return result
for entry in entries:
full_path = os.path.join(coredump_dir, entry)
try:
stat = os.stat(full_path)
except OSError:
continue
if not os.path.isfile(full_path):
continue
if stat.st_mtime < cutoff:
old_dumps.append((entry, stat.st_mtime, stat.st_size))
else:
recent += 1
result["cores_kept_recent"] = recent
if not old_dumps:
_info(f"No core dumps older than {COREDUMP_MAX_AGE_DAYS} days found")
if recent:
_info(f"Keeping {recent} recent core dump(s) (< {COREDUMP_MAX_AGE_DAYS} days old)")
return result
# List every dump with its date and size before any deletion happens.
total_size = sum(size for _, _, size in old_dumps)
heading = "Would remove" if dry_run else "Removing"
_info(
f"{heading} {len(old_dumps)} core dump(s) older than "
f"{COREDUMP_MAX_AGE_DAYS} days ({_format_size(total_size)}):"
)
for name, mtime, size in old_dumps:
date = time.strftime("%Y-%m-%d %H:%M", time.localtime(mtime))
prefix = "would remove" if dry_run else "remove"
_info(f" {prefix}: {name} ({date}, {_format_size(size)})")
if dry_run:
result["cores_planned"] = len(old_dumps)
result["space_saved"] = total_size
return result
_status(f"Deleting {len(old_dumps)} core dump(s)")
removed = 0
freed = 0
failed = 0
for name, _, size in old_dumps:
try:
os.unlink(os.path.join(coredump_dir, name))
removed += 1
freed += size
except OSError as exc:
failed += 1
_warn(f"Could not remove {name}: {exc}")
result["cores_removed"] = removed
result["space_saved"] = max(0, freed)
if failed:
msg = f"{failed} core dump(s) could not be removed — see above"
warnings.append(msg)
result["errors"].append("coredump removal incomplete")
_status_done(f"removed {removed}, freed {_format_size(result['space_saved'])}")
if recent:
_info(f"Keeping {recent} recent core dump(s) (< {COREDUMP_MAX_AGE_DAYS} days old)")
return result
# ---------------------------------------------------------------------------
# Section 6 — Package Audit (informational only)
# ---------------------------------------------------------------------------
def package_audit(*, dnf_major: int, warnings: list[str]) -> dict[str, Any]:
"""Audit dnf for packages not present in any repository.
Informational only — no packages are removed. Uses the ``--extras``
option form, which works on both dnf4 and dnf5 (the positional
"dnf list extras" is dnf4-only); on dnf5, JSON output avoids fragile
text parsing. Returns a dict with keys: extras_count, extras_packages,
errors.
"""
result: dict[str, Any] = {
"extras_count": 0,
"extras_packages": [],
"errors": [],
}
_status("Checking for packages not in any repo (dnf list --extras)")
names: list[str] | None = None
if dnf_major >= 5:
p = run(["dnf", "list", "--extras", "--json"], timeout=60)
if p.returncode == 0:
try:
data = json.loads(p.stdout)
names = sorted(
f"{pkg['name']}.{pkg['arch']}" if pkg.get("arch") else pkg["name"]
for section in data.values()
if isinstance(section, list)
for pkg in section
if isinstance(pkg, dict) and pkg.get("name")
)
except (json.JSONDecodeError, AttributeError, TypeError):
names = None
else:
p = run(["dnf", "list", "--extras", "--quiet"], timeout=60)
if p.returncode == 0:
headers = (
"last metadata",
"extra packages",
"available packages",
"installed packages",
)
names = [
line.split()[0]
for line in p.stdout.strip().split("\n")
if line.strip() and not line.strip().lower().startswith(headers)
]
if names is None:
_status_done("unavailable")
detail = _last_lines(p.stderr) if p.returncode != 0 else "unparseable dnf output"
msg = f"dnf list --extras unavailable: {detail}"
_warn(msg)
warnings.append(msg)
return result
result["extras_count"] = len(names)
result["extras_packages"] = names
if names:
_status_done(f"{len(names)} package(s)")
for name in names[:10]:
_warn(f"Extra package: {name}")
if len(names) > 10:
_warn(f" ... and {len(names) - 10} more")
warnings.append(f"{len(names)} installed package(s) not found in any repo")
else:
_status_done("none")
return result
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
def print_summary(
os_display: str,
version_id: str,
results: dict[str, Any],
warnings: list[str],
elapsed: float,
*,
dry_run: bool,
) -> None:
"""Print a final summary of all sections to stderr.
In dry-run mode everything is reported as "would …" — planned actions
are never stated as accomplished, and sizes are estimates marked "~".
"""
print(f"\n{BOLD}── Optimisation Summary ──{RESET}", file=sys.stderr)
print(f" OS: {os_display} {version_id}", file=sys.stderr)
if dry_run:
print(
f' {YELLOW}DRY RUN — nothing was changed; all actions are "would …"{RESET}',
file=sys.stderr,
)
# DNF
rd = results.get("dnf", {})
if rd:
count = rd.get("autoremoved_count") or 0
if dry_run:
if count > 0:
_info(f"DNF autoremove: would remove {count} package(s)")
else:
_info("DNF autoremove: nothing to remove")
elif rd.get("autoremoved"):
_ok(f"DNF autoremove: removed {count} package(s)")
else:
_info("DNF autoremove: nothing to remove")
kernels = rd.get("kernels_planned", 0) if dry_run else rd.get("kernels_removed", 0)
if kernels > 0:
if dry_run:
_info(f"Old kernels: would remove {kernels}")
else:
_ok(f"Old kernels removed: {kernels}")
else:
_info("Old kernels: none to remove")
_info(f"Kernels kept: {rd.get('kernels_kept', '?')}")
cache_saved = _format_size(rd.get("space_saved", 0))
if dry_run:
_info(f"DNF cache: would clean (~{cache_saved})")
elif rd.get("cache_cleaned"):
_ok(f"DNF cache: cleaned (freed {cache_saved})")
# Journal
rj = results.get("journal", {})
if rj:
if dry_run:
_info(
f"Journal: would vacuum to {JOURNAL_VACUUM_SIZE_MB}M "
f"(est. freed ~{_format_size(rj.get('space_saved', 0))})"
)
elif rj.get("vacuumed"):
_ok(f"Journal vacuumed: freed {_format_size(rj.get('space_saved', 0))}")
if rj.get("persistent_warning"):
_warn("Journal storage is still large — check SystemMaxUse= in journald.conf")
# systemd audit (informational in both modes)
rs = results.get("systemd", {})
if rs:
failed = len(rs.get("failed_units", []))
if failed > 0:
_warn(f"Failed systemd units: {failed}")
else:
_ok("Failed systemd units: none")
_info(f"Disabled unit files: {rs.get('disabled_count', 0)} (informational)")
leftovers = len(rs.get("leftover_files", []))
if leftovers > 0:
_warn(f"Leftover unit files in /etc/systemd/system/: {leftovers}")
else:
_info("Leftover unit files: none")
# Temp cleanup
rt = results.get("tmp", {})
if rt:
if dry_run:
tmp_count = rt.get("tmp_files_planned", 0)
vartmp_count = rt.get("vartmp_files_planned", 0)
else:
tmp_count = rt.get("tmp_files_removed", 0)
vartmp_count = rt.get("vartmp_files_removed", 0)
if tmp_count > 0 or vartmp_count > 0:
verb = "would remove" if dry_run else "removed"
msg = f"Temp files: {verb} {tmp_count} from /tmp, {vartmp_count} from /var/tmp"
(_info if dry_run else _ok)(msg)
else:
_info("Temp files: none to remove")
# Core dumps
rc = results.get("cores", {})
if rc:
cores = rc.get("cores_planned", 0) if dry_run else rc.get("cores_removed", 0)
if cores > 0:
verb = "would remove" if dry_run else "removed"
msg = f"Core dumps: {verb} {cores} (older than {COREDUMP_MAX_AGE_DAYS} days)"
(_info if dry_run else _ok)(msg)
else:
_info("Core dumps: none to remove")
if rc.get("cores_kept_recent", 0) > 0:
_info(f"Core dumps kept (recent): {rc['cores_kept_recent']}")
# Package audit (informational in both modes)
rp = results.get("packages", {})
if rp:
extras = rp.get("extras_count", 0)
if extras > 0:
_warn(f"Packages not in any repo: {extras}")
else:
_ok("Package audit: all packages from known repos")
# Total space (dry-run: consistent estimates across all sections)
total_space: int = 0
for section in ["dnf", "journal", "tmp", "cores"]:
total_space += results.get(section, {}).get("space_saved", 0)
if dry_run:
_info(f"Estimated space to free: ~{_format_size(total_space)}")
elif total_space > 0:
_ok(f"Total space freed: {_format_size(total_space)}")
else:
_info("Total space freed: 0 B")
if warnings:
print(file=sys.stderr)
for warning in warnings:
_warn(warning)
print(f"\n{BOLD}{'═' * 60}{RESET}", file=sys.stderr)
print(f" {BOLD}Total time: {elapsed:.1f}s{RESET}", file=sys.stderr)
print(f"{BOLD}{'═' * 60}{RESET}", file=sys.stderr)
print(file=sys.stderr)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Idempotent system cleanup and optimisation for Fedora and openEuler",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview without making changes",
)
parser.add_argument(
"--skip-dnf",
action="store_true",
help="Skip DNF cleanup (autoremove, old kernels, cache)",
)
parser.add_argument(
"--skip-journal",
action="store_true",
help="Skip journal vacuum",
)
parser.add_argument(
"--skip-tmp",
action="store_true",
help="Skip temp file cleanup",
)
parser.add_argument(
"--skip-cores",
action="store_true",
help="Skip core dump cleanup",
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {VERSION}",
)
return parser.parse_args(argv)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
"""Entry point — detect OS, check root, then run enabled sections.
Every section is isolated: a crashing or failing section is recorded and
reported, but never aborts the remaining sections or the summary. The
process exits non-zero when any operation failed.
"""
start_time = time.monotonic()
warnings: list[str] = []
failures: list[str] = []
results: dict[str, Any] = {}
args = parse_args()
check_root()
os_id, os_display, version_id = detect_os()
dnf_major = _dnf_generation()
# Header
print(f"\n{BOLD}{'═' * 60}{RESET}", file=sys.stderr)
print(
f"{BOLD} System Optimise v{VERSION}{os_display} {version_id}{RESET}",
file=sys.stderr,
)
print(f"{BOLD}{'═' * 60}{RESET}", file=sys.stderr)
if args.dry_run:
print(
f"\n {YELLOW}{BOLD}DRY RUN — no changes will be made{RESET}",
file=sys.stderr,
)
print(file=sys.stderr)
sections: list[tuple[str, str, Callable[[], dict[str, Any]], str | None]] = [
(
"dnf",
"DNF Cleanup",
lambda: dnf_cleanup(os_id, dnf_major, dry_run=args.dry_run, warnings=warnings),
"--skip-dnf" if args.skip_dnf else None,
),
(
"journal",
"Journal Cleanup",
lambda: journal_cleanup(dry_run=args.dry_run, warnings=warnings),
"--skip-journal" if args.skip_journal else None,
),
(
"systemd",
"systemd Audit",
lambda: systemd_audit(warnings=warnings),
None,
),
(
"tmp",
"Temp File Cleanup",
lambda: temp_cleanup(dry_run=args.dry_run, warnings=warnings),
"--skip-tmp" if args.skip_tmp else None,
),
(
"cores",
"Core Dump Cleanup",
lambda: coredump_cleanup(dry_run=args.dry_run, warnings=warnings),
"--skip-cores" if args.skip_cores else None,
),
(
"packages",
"Package Audit",
lambda: package_audit(dnf_major=dnf_major, warnings=warnings),
None,
),
]
for key, title, section_func, skip_flag in sections:
print(f"\n{BOLD}── {title} ──{RESET}", file=sys.stderr)
if skip_flag is not None:
_info(f"{title}: skipped ({skip_flag})")
continue
try:
results[key] = section_func()
except Exception as exc: # isolation: one section must not kill the rest
_fail(f"{title} crashed: {exc}")
failures.append(f"{title}: crashed ({exc})")
continue
for error in results[key].get("errors", []):
failures.append(f"{title}: {error}")
elapsed = time.monotonic() - start_time
print_summary(os_display, version_id, results, warnings, elapsed, dry_run=args.dry_run)
if failures:
print(f"{RED}{BOLD}Failed operations ({len(failures)}):{RESET}", file=sys.stderr)
for failure in failures:
_fail(failure)
print(file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()