1253 lines
45 KiB
Python
1253 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, 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()
|