#!/usr/bin/env python3 # Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) # SPDX-License-Identifier: MIT """Idempotent system cleanup and optimisation for Fedora, CentOS Stream 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.4.0" SUPPORTED_OS: dict[str, str] = { "fedora": "Fedora", "centos": "CentOS Stream", "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, CentOS Stream 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()