#!/usr/bin/env python3 # Copyright (c) 2026 Petr Balvín (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()