Files
scripts/system-optimise.pl
petrbalvin 788cf0571f
Deploy / deploy (push) Successful in 17s
Test / test (push) Successful in 53s
feat: initial release of the scripts collection
Assisted-by: GLM 5.3 Flash
2026-09-10 04:00:00 +00:00

1728 lines
61 KiB
Perl

#!/usr/bin/env perl
# 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 the current state before acting, so running it again
# produces the same result with no errors and no repeated work. ostree-based
# (atomic) systems are refused, because package and kernel management there
# belongs to rpm-ostree.
#
# Perl builtins only: no module has to be installed. Three pieces of work that
# Perl does not carry as builtins are therefore written out here:
#
# * the command runner, which forks and keeps stdout and stderr apart in the
# scratch directory, because the failure messages quote stderr;
# * RPM's version comparison (rpmvercmp), which decides which kernels are the
# newest and so must not be approximated;
# * a JSON decoder, which reads what dnf5 writes for --json.
#
# The dnf generation is detected once, and both are driven in their own spelling,
# because both are current on the systems these scripts run on: Fedora 41 and newer
# ship dnf 5, while CentOS Stream 10 and openEuler ship dnf 4 (measured on the
# Stream 10 image: dnf 4.20.0, no dnf5 binary and no libdnf5). Needing dnf 4
# because that is what the system has is not legacy, so nothing is refused over it.
# An unreadable version is driven as dnf 4 and said out loud, since the choice
# decides which cache directory gets measured.
#
# External binaries used: dnf, rpm, journalctl, systemctl and find.
#
# One optional module, never assumed: Time::HiRes, used only for the elapsed
# time in the summary. Without it that figure falls back to whole seconds.
#
# Usage:
# system-optimise.pl # full cleanup
# system-optimise.pl --dry-run # preview without changes
# system-optimise.pl --skip-dnf # skip DNF cleanup
# system-optimise.pl --skip-journal # skip journal cleanup
# system-optimise.pl --skip-tmp # skip temp file cleanup
# system-optimise.pl --skip-cores # skip core dump cleanup
# system-optimise.pl --version
use strict;
use warnings;
my $VERSION = '2.0.0';
my $BOLD = "\033[1m";
my $RED = "\033[31m";
my $GREEN = "\033[32m";
my $YELLOW = "\033[33m";
my $DIM = "\033[2m";
my $RESET = "\033[0m";
# Supported systems, in the order the messages list them.
my @SUPPORTED_ORDER = ('fedora', 'centos', 'openeuler');
my %SUPPORTED_OS = (
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.
my $INSTALLONLY_LIMIT = 2;
# Temp file retention, matching systemd-tmpfiles defaults on both targets.
my $TMP_MAX_AGE_DAYS = 10;
my $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.
my $COREDUMP_MAX_AGE_DAYS = 7;
my $JOURNAL_VACUUM_SIZE_MB = 500;
my $JOURNAL_WARN_BYTES = 1024 * 1024 * 1024;
my @JOURNAL_DIRS = ('/var/log/journal', '/run/log/journal');
# The dnf generation, probed once and then reused, and whether the unreadable case
# has already been reported.
my $DNF_MAJOR;
my $DNF_MAJOR_WARNED = 0;
# Unit file types audited for leftovers in /etc/systemd/system/.
my @UNIT_SUFFIXES = qw(.service .socket .timer .target .path .mount .automount .slice);
# The optional clock, loaded once and guarded where it is used.
my $HAVE_HIRES = eval { require Time::HiRes; 1 } ? 1 : 0;
my $TMP_DIR; # private scratch directory, created only when needed
my $PARENT_PID = $$; # a forked child must never clean up for the parent
my $RUN_SEQ = 0; # per-call suffix for the runner's files
# ---------------------------------------------------------------------------
# Progress helpers, all on stderr so stdout stays clean
# ---------------------------------------------------------------------------
sub status {
my ($msg) = @_;
print STDERR " $msg...";
return;
}
sub status_done {
my ($msg) = @_;
$msg = 'done' unless defined $msg;
print STDERR " $msg\n";
return;
}
sub _info {
my ($msg) = @_;
print STDERR " ${DIM}$msg$RESET\n";
return;
}
sub _warn {
my ($msg) = @_;
print STDERR " ${YELLOW}⚠ $msg$RESET\n";
return;
}
sub _ok {
my ($msg) = @_;
print STDERR " ${GREEN}✓ $msg$RESET\n";
return;
}
sub _fail {
my ($msg) = @_;
print STDERR " ${RED}✗ $msg$RESET\n";
return;
}
# ---------------------------------------------------------------------------
# Support: the clock, path lookup, scratch files and the command runner
# ---------------------------------------------------------------------------
sub now {
return $HAVE_HIRES ? Time::HiRes::time() : time();
}
# A hand-rolled which(1), so that the lookup itself needs no external binary.
sub find_exe {
my ($name) = @_;
return undef unless defined $name && length $name;
if (index($name, '/') >= 0) {
return (-f $name && -x _) ? $name : undef;
}
for my $dir (split /:/, ($ENV{PATH} // '')) {
next unless length $dir;
my $path = "$dir/$name";
return $path if -f $path && -x _;
}
return undef;
}
# mkdir is atomic and refuses to follow a symlink, so a hostile entry in a
# shared /tmp cannot redirect where the runner writes.
sub scratch_dir {
return $TMP_DIR if defined $TMP_DIR;
# an empty TMPDIR would place the scratch directory at the filesystem root
my $base = defined $ENV{TMPDIR} && length $ENV{TMPDIR} ? $ENV{TMPDIR} : '/tmp';
for my $attempt (0 .. 9) {
my $dir = "$base/system-optimise.$$" . ($attempt ? ".$attempt" : '');
if (mkdir($dir, 0700)) {
$TMP_DIR = $dir;
return $dir;
}
}
die "cannot create a scratch directory under $base\n";
}
sub remove_scratch {
return unless defined $TMP_DIR;
# The runner forks, and a forked child inherits the END block: only the
# process that created the directory may remove it.
return unless $$ == $PARENT_PID;
if (opendir(my $dh, $TMP_DIR)) {
for my $entry (readdir($dh)) {
next if $entry eq '.' || $entry eq '..';
unlink("$TMP_DIR/$entry");
}
closedir($dh);
}
rmdir($TMP_DIR);
undef $TMP_DIR;
return;
}
sub slurp {
my ($path) = @_;
open(my $fh, '<', $path) or return '';
my $text = do { local $/ = undef; <$fh> };
close($fh);
return defined $text ? $text : '';
}
# Run a command and return { rc, out, err }.
#
# The child is forked with its two streams sent to separate files in the scratch
# directory, because the messages quote stderr while the parsers read stdout, and
# the builtin pipe forms cannot keep them apart. The locale is forced to C so
# that tool output is parsed in English. Nothing raises: a missing binary, a
# timeout and a failed exec become rc 127, 124 and 126, which is the contract the
# callers below were written against.
sub run {
my ($cmd, $timeout) = @_;
$timeout = 120 unless defined $timeout;
my $exe = find_exe($cmd->[0]);
return { rc => 127, out => '', err => "command not found: $cmd->[0]" } unless defined $exe;
my $dir = scratch_dir();
$RUN_SEQ++;
my $out_file = "$dir/out.$$.$RUN_SEQ";
my $err_file = "$dir/err.$$.$RUN_SEQ";
my $pid = fork();
die "cannot fork: $!\n" unless defined $pid;
if ($pid == 0) {
if (open(STDOUT, '>', $out_file) && open(STDERR, '>', $err_file)) {
$ENV{LANG} = 'C';
$ENV{LC_ALL} = 'C';
# a failed exec falls through to the or branch, and stderr is
# already the file the parent quotes, so the reason travels with it
exec { $exe } @$cmd
or print STDERR "cannot execute $exe: $!\n";
}
exit 126; # reached only when the redirection or the exec failed
}
my $timed_out = 0;
eval {
local $SIG{ALRM} = sub { die "alarm\n" };
alarm($timeout);
waitpid($pid, 0);
alarm(0);
1;
} or do { $timed_out = 1; alarm(0) };
my $rc;
if ($timed_out) {
kill('TERM', $pid);
select(undef, undef, undef, 0.1);
kill('KILL', $pid);
waitpid($pid, 0);
$rc = 124;
}
else {
$rc = $? >> 8;
}
my $out = slurp($out_file);
my $err = slurp($err_file);
unlink($out_file, $err_file);
return {
rc => $rc,
out => $out,
err => $timed_out ? "timed out after ${timeout}s" : $err,
};
}
# ---------------------------------------------------------------------------
# OS detection and the root check
# ---------------------------------------------------------------------------
sub parse_os_release {
my %release;
open(my $fh, '<', '/etc/os-release') or return \%release;
while (my $line = <$fh>) {
$line =~ s/^\s+//;
$line =~ s/\s+$//;
next unless length $line;
next if index($line, '#') == 0;
next unless index($line, '=') >= 0;
my ($key, $value) = split /=/, $line, 2;
$value = '' unless defined $value;
$value =~ s/^\s+//;
$value =~ s/\s+$//;
for my $quote ('"', "'") {
$value =~ s/^\Q$quote\E+//;
$value =~ s/\Q$quote\E+$//;
}
$release{$key} = $value;
}
close($fh);
return \%release;
}
sub detect_os {
my $release = parse_os_release();
my $os_id = lc($release->{ID} // '');
my $version_id = $release->{VERSION_ID} // 'unknown';
# An ostree-based (atomic) system reports a normal ID, Silverblue reports
# ID=fedora, but has no dnf and manages kernels through rpm-ostree.
if (-e '/run/ostree-booted') {
print STDERR "${RED}${BOLD}Error:$RESET ostree-based (atomic) system detected "
. "(/run/ostree-booted present).\n";
print STDERR " Package and kernel management there belongs to rpm-ostree; "
. "this script only supports traditional dnf/rpm systems.\n";
exit 1;
}
if (!exists $SUPPORTED_OS{$os_id}) {
print STDERR "${RED}${BOLD}Error:$RESET Unsupported operating system: "
. "'" . ($release->{ID} // 'unknown') . "' (detected from /etc/os-release).\n";
print STDERR " Supported systems: "
. join(', ', map { $SUPPORTED_OS{$_} } @SUPPORTED_ORDER) . "\n";
exit 1;
}
return ($os_id, $SUPPORTED_OS{$os_id}, $version_id);
}
sub check_root {
return if $> == 0;
print STDERR "${RED}${BOLD}Error:$RESET This script must be run as root (UID 0).\n";
print STDERR " Current UID: $>. Try: sudo perl system-optimise.pl\n";
exit 1;
}
# ---------------------------------------------------------------------------
# Sizes and error text
# ---------------------------------------------------------------------------
sub format_size {
my ($bytes) = @_;
$bytes = 0 unless defined $bytes;
return '0 B' if $bytes < 0;
return sprintf('%.2f GB', $bytes / (1024 * 1024 * 1024)) if $bytes >= 1024 * 1024 * 1024;
return sprintf('%.1f MB', $bytes / (1024 * 1024)) if $bytes >= 1024 * 1024;
return sprintf('%.1f KB', $bytes / 1024) if $bytes >= 1024;
return "$bytes B";
}
# Total size of the files under a directory. Symbolic links to files count,
# through stat; symbolic links to directories are not followed, and unreadable
# directories are skipped rather than fatal.
sub du {
my ($path) = @_;
return 0 unless -d $path;
my $total = 0;
my @pending = ($path);
while (defined(my $dir = pop @pending)) {
opendir(my $dh, $dir) or next;
for my $entry (readdir($dh)) {
next if $entry eq '.' || $entry eq '..';
my $full = "$dir/$entry";
if (-l $full) {
my $size = -s $full;
$total += $size if defined $size && -f $full;
next;
}
if (-d $full) { push @pending, $full; next }
if (-f $full) {
my $size = -s $full;
$total += $size if defined $size;
}
}
closedir($dh);
}
return $total;
}
sub last_lines {
my ($text, $count) = @_;
$count = 3 unless defined $count;
$text = '' unless defined $text;
my @lines;
for my $line (split /\n/, $text) {
$line =~ s/^\s+//;
$line =~ s/\s+$//;
push @lines, $line if length $line;
}
return 'no error output' unless @lines;
my @tail = @lines > $count ? @lines[-$count .. -1] : @lines;
return join(' | ', @tail);
}
# ---------------------------------------------------------------------------
# RPM version comparison
# ---------------------------------------------------------------------------
# rpmvercmp, as rpm itself defines it: segments alternate between digits and
# letters, numeric segments compare numerically and always beat alphabetic
# ones, "~" sorts before everything including the end of the string, and "^"
# sorts after the end of the string but before any segment. Comparison is
# byte oriented, which is what RPM does with ASCII version strings.
sub rpmvercmp {
my ($ver_a, $ver_b) = @_;
return 0 if $ver_a eq $ver_b;
my $len_a = length $ver_a;
my $len_b = length $ver_b;
my ($pos_a, $pos_b) = (0, 0);
while ($pos_a < $len_a || $pos_b < $len_b) {
# separators are skipped, but never a tilde or a caret: both are
# ordering operators in their own right and stop the skip
$pos_a++ while $pos_a < $len_a && substr($ver_a, $pos_a, 1) =~ /[^A-Za-z0-9~^]/;
$pos_b++ while $pos_b < $len_b && substr($ver_b, $pos_b, 1) =~ /[^A-Za-z0-9~^]/;
my $char_a = $pos_a < $len_a ? substr($ver_a, $pos_a, 1) : '';
my $char_b = $pos_b < $len_b ? substr($ver_b, $pos_b, 1) : '';
# a tilde sorts before everything, including the end of the string
if ($char_a eq '~' || $char_b eq '~') {
return 1 if $char_a ne '~';
return -1 if $char_b ne '~';
$pos_a++;
$pos_b++;
next;
}
# a caret sorts after the end of the string but before any segment
if ($char_a eq '^' || $char_b eq '^') {
return -1 if $char_a eq '';
return 1 if $char_b eq '';
return 1 if $char_a ne '^';
return -1 if $char_b ne '^';
$pos_a++;
$pos_b++;
next;
}
last unless length $char_a && length $char_b;
my $numeric_a = $char_a =~ /[0-9]/ ? 1 : 0;
my $numeric_b = $char_b =~ /[0-9]/ ? 1 : 0;
if ($numeric_a != $numeric_b) {
return $numeric_a ? 1 : -1;
}
my ($start_a, $start_b) = ($pos_a, $pos_b);
my ($seg_a, $seg_b);
if ($numeric_a) {
$pos_a++ while $pos_a < $len_a && substr($ver_a, $pos_a, 1) =~ /[0-9]/;
$pos_b++ while $pos_b < $len_b && substr($ver_b, $pos_b, 1) =~ /[0-9]/;
$seg_a = substr($ver_a, $start_a, $pos_a - $start_a);
$seg_b = substr($ver_b, $start_b, $pos_b - $start_b);
$seg_a =~ s/^0+//;
$seg_b =~ s/^0+//;
$seg_a = '0' unless length $seg_a;
$seg_b = '0' unless length $seg_b;
if (length $seg_a != length $seg_b) {
return length $seg_a > length $seg_b ? 1 : -1;
}
}
else {
$pos_a++ while $pos_a < $len_a && substr($ver_a, $pos_a, 1) =~ /[A-Za-z]/;
$pos_b++ while $pos_b < $len_b && substr($ver_b, $pos_b, 1) =~ /[A-Za-z]/;
$seg_a = substr($ver_a, $start_a, $pos_a - $start_a);
$seg_b = substr($ver_b, $start_b, $pos_b - $start_b);
}
if ($seg_a ne $seg_b) {
return $seg_a gt $seg_b ? 1 : -1;
}
}
# whichever side still has segments left over is the newer version
return 1 if $pos_a < $len_a;
return -1 if $pos_b < $len_b;
return 0;
}
# Split an EVR the way rpm's own parser splits it: an epoch is the leading
# digits before a colon, and the release is everything after the last dash of
# what remains. A missing epoch counts as zero; a missing release is undef,
# which the comparison below keeps apart from an empty one.
sub parse_evr {
my ($evr) = @_;
$evr = '' unless defined $evr;
my $epoch;
my $version = $evr;
if ($version =~ /^([0-9]*):/) {
$epoch = length $1 ? $1 : '0';
$version = substr($version, length($1) + 1);
}
my $release;
my $cut = rindex($version, '-');
if ($cut >= 0) {
$release = substr($version, $cut + 1);
$version = substr($version, 0, $cut);
}
return ($epoch, $version, $release);
}
# Epoch, then version, then release, each with rpmvercmp: the field by field
# comparison rpm itself makes, where a version with a release outranks one
# without. Comparing the whole remainder as one string instead would let a
# version bleed into a release, so "1.0-2" would wrongly outrank "1.0.1-1".
sub rpm_evr_cmp {
my ($evr_a, $evr_b) = @_;
my ($epoch_a, $version_a, $release_a) = parse_evr($evr_a);
my ($epoch_b, $version_b, $release_b) = parse_evr($evr_b);
my $rc = rpmvercmp($epoch_a // '0', $epoch_b // '0');
return $rc if $rc;
$rc = rpmvercmp($version_a, $version_b);
return $rc if $rc;
return 1 if defined $release_a && !defined $release_b;
return -1 if !defined $release_a && defined $release_b;
return 0 unless defined $release_a; # neither side carries a release
return rpmvercmp($release_a, $release_b);
}
# ---------------------------------------------------------------------------
# Kernels
# ---------------------------------------------------------------------------
sub kernel_pkg_name {
my ($os_id) = @_;
return $os_id eq 'openeuler' ? 'kernel' : 'kernel-core';
}
# Installed kernel EVRs (epoch:version-release.arch), newest first, or undef when
# the rpm query itself failed: an empty list and a failed query are different
# answers. The sort is a version sort because rpm -q lists in install order,
# which is not version order.
sub list_kernel_packages {
my ($os_id) = @_;
my $p = run([
'rpm', '-q', kernel_pkg_name($os_id),
'--qf', "%{EPOCHNUM}:%{VERSION}-%{RELEASE}.%{ARCH}\n",
], 30);
return undef if $p->{rc} != 0;
my @evrs;
for my $line (split /\n/, $p->{out}) {
$line =~ s/^\s+//;
$line =~ s/\s+$//;
push @evrs, $line if length $line;
}
@evrs = sort { rpm_evr_cmp($b, $a) } @evrs;
return \@evrs;
}
sub running_kernel {
if (open(my $fh, '<', '/proc/sys/kernel/osrelease')) {
my $line = <$fh>;
close($fh);
if (defined $line) {
$line =~ s/\s+$//;
return $line;
}
}
return '';
}
# Split the EVRs (newest first) into what is kept and what would go, mirroring
# dnf's --oldinstallonly: the newest INSTALLONLY_LIMIT versions, and always the
# running one even when it is not among them, which is the state after an update
# that has not been rebooted into yet.
sub select_kernels_to_keep {
my ($kernels, $running) = @_;
my $limit = @$kernels < $INSTALLONLY_LIMIT ? scalar @$kernels : $INSTALLONLY_LIMIT;
my @kept = $limit > 0 ? @$kernels[0 .. $limit - 1] : ();
my ($running_evr) = grep {
my (undef, $rest) = split /:/, $_, 2;
defined $rest && $rest eq $running;
} @$kernels;
if (defined $running_evr && !grep { $_ eq $running_evr } @kept) {
push @kept, $running_evr;
}
my %kept = map { $_ => 1 } @kept;
my @would_remove = grep { !$kept{$_} } @$kernels;
return (\@kept, \@would_remove);
}
# The major version of the installed dnf, probed once and reused, or 0 when it
# cannot be read at all.
sub dnf_major {
return $DNF_MAJOR if defined $DNF_MAJOR;
$DNF_MAJOR = 0;
my $p = run(['dnf', '--version'], 15);
if ($p->{rc} == 0 && length $p->{out}) {
my ($first) = split /\n/, $p->{out};
$DNF_MAJOR = $1 + 0 if defined $first && $first =~ /(\d+)\.\d+/;
}
return $DNF_MAJOR;
}
# The generation to drive, in the spellings that generation understands. Both are
# current on the systems these scripts run on, so both are driven: Fedora 41 and
# newer ship dnf 5, while CentOS Stream 10 and openEuler ship dnf 4 (measured on
# the Stream 10 image: dnf 4.20.0, no dnf5 binary and no libdnf5). Needing dnf 4
# because that is what the system has is not legacy: the script follows the system
# rather than the other way round.
#
# An unreadable version is driven as dnf 4, and the choice is said out loud,
# because the choice is not cosmetic: it decides which cache directory gets
# measured, and a wrong guess would report a saving of zero as though it were true.
sub dnf_generation {
my $major = dnf_major();
return $major if $major > 0;
if (!$DNF_MAJOR_WARNED) {
$DNF_MAJOR_WARNED = 1;
_warn('Could not read the dnf version, driving dnf as version 4');
}
return 4;
}
# The cache directories the given generation owns, and those left behind by the
# other: the first set is measured and cleaned, the second is named and left alone.
sub dnf_cache_dirs {
my ($major) = @_;
return (['/var/cache/libdnf5'], ['/var/cache/dnf', '/var/cache/yum']) if $major >= 5;
return (['/var/cache/dnf', '/var/cache/yum'], []);
}
# ---------------------------------------------------------------------------
# Section 1: DNF cleanup
# ---------------------------------------------------------------------------
# The removed-package count from a dnf transaction preview. dnf 4 writes
# "Remove 12 Packages" in its summary and dnf 5 "Removing: 12 packages"; both
# generations are current, so both are read. An unparseable preview returns undef
# rather than zero, so that a failure is never reported as "nothing to remove".
sub parse_removal_count {
my ($text) = @_;
$text = '' unless defined $text;
return $1 + 0 if $text =~ /^Remove\s+(\d+)\s+Packages?\b/m;
return $1 + 0 if $text =~ /^\s*Removing:\s+(\d+)\s+packages?\b/m;
return 0 if index($text, 'Nothing to do') >= 0;
return undef;
}
sub dnf_cleanup {
my ($os_id, $dnf_major, %opt) = @_;
my $dry_run = $opt{dry_run};
my $warnings = $opt{warnings};
my $pkg_name = kernel_pkg_name($os_id);
my %result = (
autoremoved => 0,
autoremoved_count => 0,
kernels_removed => 0,
kernels_planned => 0,
kernels_kept => '',
space_saved => 0,
cache_cleaned => 0,
errors => [],
);
# 1. Autoremove packages nothing needs any more.
status('Removing unneeded packages (dnf autoremove)');
if ($dry_run) {
# --assumeno makes dnf print the transaction and then answer no, so the
# exit code can be non-zero while stdout still carries the answer.
my $p = run(['dnf', 'autoremove', '--assumeno'], 300);
my $count = parse_removal_count($p->{out});
if (defined $count) {
$result{autoremoved_count} = $count;
status_done($count ? "would remove $count package(s)" : 'nothing to remove');
}
elsif ($p->{rc} == 0) {
status_done('would run (package count unavailable)');
}
else {
status_done('failed');
_warn('dnf autoremove preview failed: ' . last_lines($p->{err}));
push @{ $result{errors} }, 'dnf autoremove preview failed';
}
}
else {
my $p = run(['dnf', 'autoremove', '-y'], 300);
if ($p->{rc} == 0) {
my $count = parse_removal_count($p->{out});
$count = 0 unless defined $count;
$result{autoremoved_count} = $count;
$result{autoremoved} = $count ? 1 : 0;
status_done($count ? "removed $count package(s)" : 'nothing to do');
}
else {
status_done('failed');
_warn('dnf autoremove failed: ' . last_lines($p->{err}));
push @{ $result{errors} }, 'dnf autoremove failed';
}
}
# 2. Prune old kernels. dnf decides what to remove, never this script.
my $kernels = list_kernel_packages($os_id);
if (!defined $kernels) {
_warn('Could not query installed kernels (rpm), skipping kernel cleanup');
push @$warnings, 'Kernel query via rpm failed, kernel cleanup skipped';
push @{ $result{errors} }, 'rpm kernel query failed';
$result{kernels_kept} = 'unknown';
}
elsif (!@$kernels) {
_info('No kernel packages found');
$result{kernels_kept} = 'none';
}
else {
my $running = running_kernel();
my ($kept, $would_remove) = select_kernels_to_keep($kernels, $running);
$result{kernels_kept} = join(', ', map { "$pkg_name-$_" } @$kept);
if (!@$would_remove) {
_info('Old kernels: none to remove (' . scalar(@$kernels)
. ' installed, keeping ' . scalar(@$kept) . ')');
}
elsif ($dry_run) {
status("Pruning old kernels (keeping newest $INSTALLONLY_LIMIT + running)");
$result{kernels_planned} = scalar @$would_remove;
status_done('would remove ' . scalar @$would_remove);
_info("would remove $pkg_name-$_") for @$would_remove;
}
else {
status('Pruning ' . scalar(@$would_remove) . ' old kernel(s) via dnf (keeping '
. scalar(@$kept) . ')');
my @cmd = ('dnf', 'remove', '-y', '--oldinstallonly');
if ($dnf_major >= 5) {
push @cmd, "--limit=$INSTALLONLY_LIMIT";
}
else {
push @cmd, '--setopt', "installonly_limit=$INSTALLONLY_LIMIT";
}
push @cmd, $pkg_name;
my $p = run(\@cmd, 300);
if ($p->{rc} == 0) {
# dnf may keep more than the model allows for, and it protects
# the running kernel itself, so re-query and report what happened
# rather than what was planned.
my $after = list_kernel_packages($os_id);
if (!defined $after) {
$result{kernels_removed} = 0;
status_done('done (post-check failed)');
push @$warnings, 'Could not verify kernel removal (rpm), removed count unknown';
}
else {
my %still_there = map { $_ => 1 } @$after;
my @removed_now = grep { !$still_there{$_} } @$kernels;
my $running_present = grep {
my (undef, $rest) = split /:/, $_, 2;
defined $rest && $rest eq $running;
} @$after;
if (!$running_present) {
_fail("Running kernel $running is no longer installed, do not reboot");
push @{ $result{errors} }, 'running kernel missing after kernel prune';
}
$result{kernels_removed} = scalar @removed_now;
status_done('removed ' . scalar(@removed_now)
. ' (planned ' . scalar(@$would_remove) . ", running: $running)");
}
}
else {
status_done('failed');
_warn('Kernel removal failed: ' . last_lines($p->{err}));
push @{ $result{errors} }, 'old kernel removal failed';
}
}
}
# 3. Clean the cache, measuring only what this generation owns, and name what
# the other generation left behind without touching it.
my ($active_caches, $stale_caches) = dnf_cache_dirs($dnf_major);
for my $stale (@$stale_caches) {
my $stale_size = du($stale);
next unless $stale_size > 0;
my $msg = "Stale dnf4 cache at $stale (" . format_size($stale_size)
. '), unused by dnf5';
_info($msg);
push @$warnings, $msg;
}
my $cache_before = 0;
$cache_before += du($_) for @$active_caches;
status('Cleaning DNF cache');
if ($dry_run) {
$result{space_saved} += $cache_before;
status_done('would free ~' . format_size($cache_before));
}
else {
my $p = run(['dnf', 'clean', 'all'], 60);
if ($p->{rc} == 0) {
my $cache_after = 0;
$cache_after += du($_) for @$active_caches;
my $saved = $cache_before - $cache_after;
$saved = 0 if $saved < 0;
$result{space_saved} += $saved;
$result{cache_cleaned} = 1;
status_done(format_size($saved));
}
else {
status_done('failed');
_warn('dnf clean all failed: ' . last_lines($p->{err}));
push @{ $result{errors} }, 'dnf clean all failed';
}
}
return \%result;
}
# ---------------------------------------------------------------------------
# Section 2: journal cleanup
# ---------------------------------------------------------------------------
sub journal_size {
my $total = 0;
for my $path (@JOURNAL_DIRS) {
$total += du($path) if -e $path;
}
return $total;
}
sub journal_cleanup {
my (%opt) = @_;
my $dry_run = $opt{dry_run};
my $warnings = $opt{warnings};
my %result = (
vacuumed => 0,
space_saved => 0,
journal_size_after => 0,
persistent_warning => 0,
errors => [],
);
my $vacuum_arg = "--vacuum-size=${JOURNAL_VACUUM_SIZE_MB}M";
my $size_before = journal_size();
status("Vacuuming journal ($vacuum_arg)");
my $size_check;
if ($dry_run) {
my $estimated = $size_before - $JOURNAL_VACUUM_SIZE_MB * 1024 * 1024;
$estimated = 0 if $estimated < 0;
$result{space_saved} = $estimated;
status_done('would free ~' . format_size($estimated));
$size_check = $size_before - $estimated;
}
else {
my $p = run(['journalctl', $vacuum_arg], 120);
if ($p->{rc} == 0) {
$result{vacuumed} = 1;
$result{journal_size_after} = journal_size();
my $saved = $size_before - $result{journal_size_after};
$result{space_saved} = $saved > 0 ? $saved : 0;
status_done(format_size($result{space_saved}));
}
else {
status_done('failed');
_warn('journalctl vacuum failed: ' . last_lines($p->{err}));
push @{ $result{errors} }, 'journal vacuum failed';
}
$size_check = journal_size();
}
# A journal that stays large after a vacuum needs SystemMaxUse rather than
# another vacuum, so it is reported.
if ($size_check > $JOURNAL_WARN_BYTES) {
my $msg = 'Journal still large (' . format_size($size_check)
. ') after vacuum. Consider setting SystemMaxUse= in /etc/systemd/journald.conf';
_warn($msg);
push @$warnings, $msg;
$result{persistent_warning} = 1;
}
return \%result;
}
# ---------------------------------------------------------------------------
# Section 3: systemd audit, informational only
# ---------------------------------------------------------------------------
sub systemd_audit {
my (%opt) = @_;
my $warnings = $opt{warnings};
my %result = (
failed_units => [],
disabled_count => 0,
leftover_files => [],
errors => [],
);
# 1. Failed units
status('Checking for failed systemd units');
my $p = run(['systemctl', '--failed', '--no-legend'], 30);
if ($p->{rc} != 0 && !length $p->{out}) {
status_done('unavailable');
my $msg = 'systemctl --failed unavailable: ' . last_lines($p->{err});
_warn($msg);
push @$warnings, $msg;
}
else {
my @failed;
for my $line (split /\n/, $p->{out}) {
$line =~ s/^\s+//;
$line =~ s/\s+$//;
push @failed, $line if length $line;
}
$result{failed_units} = \@failed;
status_done(scalar(@failed) . ' failed');
_warn("Failed unit: $_") for @failed;
push @$warnings, scalar(@failed) . ' failed systemd unit(s), see above' if @failed;
}
# 2. Disabled unit files, counted for information only.
status('Counting disabled unit files');
my $disabled = run(['systemctl', 'list-unit-files', '--state=disabled', '--no-legend'], 30);
if ($disabled->{rc} != 0 && !length $disabled->{out}) {
status_done('unavailable');
my $msg = 'systemctl list-unit-files --state=disabled unavailable: '
. last_lines($disabled->{err});
_warn($msg);
push @$warnings, $msg;
}
else {
my @disabled_lines;
for my $line (split /\n/, $disabled->{out}) {
$line =~ s/^\s+//;
$line =~ s/\s+$//;
push @disabled_lines, $line if length $line;
}
$result{disabled_count} = scalar @disabled_lines;
status_done($result{disabled_count} . ' disabled (informational only)');
}
# 3. Leftover unit files: regular files systemd does not know about. One
# listing yields every unit name systemd recognises, so static, disabled and
# indirect units are all legitimate and only unknown names are leftovers.
my $etc_systemd = '/etc/systemd/system';
if (-d $etc_systemd) {
status('Checking for leftover unit files');
my $units = run(['systemctl', 'list-unit-files', '--no-legend'], 30);
if ($units->{rc} != 0) {
status_done('unavailable');
my $msg = 'systemctl list-unit-files unavailable: ' . last_lines($units->{err});
_warn($msg);
push @$warnings, $msg;
}
else {
my %known;
for my $line (split /\n/, $units->{out}) {
my @fields = split ' ', $line;
$known{ $fields[0] } = 1 if @fields;
}
my @leftovers;
if (opendir(my $dh, $etc_systemd)) {
for my $entry (sort readdir($dh)) {
next if $entry eq '.' || $entry eq '..';
my $full = "$etc_systemd/$entry";
next if -l $full || !-f $full;
next unless grep { substr($entry, -length $_) eq $_ } @UNIT_SUFFIXES;
push @leftovers, $entry unless $known{$entry};
}
closedir($dh);
}
$result{leftover_files} = \@leftovers;
if (@leftovers) {
status_done(scalar(@leftovers) . ' leftover(s)');
_warn("Leftover unit file: $etc_systemd/$_") for @leftovers;
push @$warnings, scalar(@leftovers) . " leftover unit file(s) in $etc_systemd/";
}
else {
status_done('none');
}
}
}
return \%result;
}
# ---------------------------------------------------------------------------
# Section 4: temp file cleanup
# ---------------------------------------------------------------------------
# Regular files under a directory older than the given number of days. The walk
# stays on one filesystem (-xdev) and never matches the start directory itself
# (-mindepth 1). undef means the find command failed, which callers must keep
# apart from an empty result.
sub find_old_files {
my ($directory, $days) = @_;
my $p = run([
'find', $directory, '-mindepth', '1', '-xdev',
'-type', 'f', '-mtime', "+$days", '-print0',
], 120);
return undef if $p->{rc} != 0;
return [ grep { length } split /\0/, $p->{out} ];
}
sub paths_size {
my ($paths) = @_;
my $total = 0;
for my $path (@$paths) {
my $size = -s $path;
$total += $size if defined $size;
}
return $total;
}
# Split the paths listed before deletion into those the verification pass no
# longer sees (gone) and those it still sees (left). Only the first group is
# what this run took away: a file that merely ages into the window between the
# two passes shows up in the second listing without ever having been listed
# before, and a count difference would wrongly charge it against the deletions.
sub split_by_presence {
my ($before, $after) = @_;
my %after = map { $_ => 1 } @$after;
return (
[ grep { !$after{$_} } @$before ],
[ grep { $after{$_} } @$before ],
);
}
# Clean one temp directory, returning (files affected, bytes freed). Files are
# counted and measured before anything is removed, so the real-mode numbers
# describe what was actually taken away, while a dry run reports the size of the
# matching files and removes nothing.
sub clean_temp_dir {
my ($directory, $days, %opt) = @_;
my $dry_run = $opt{dry_run};
my $warnings = $opt{warnings};
my $errors = $opt{errors};
my $old_files = find_old_files($directory, $days);
if (!defined $old_files) {
status_done('failed');
_warn("find $directory failed, cannot list old files");
push @$warnings, "Temp cleanup of $directory failed (find error)";
push @$errors, "find failed in $directory";
return (0, 0);
}
my $old_size = paths_size($old_files);
if ($dry_run) {
status_done('would remove ' . scalar(@$old_files) . ' file(s) (~'
. format_size($old_size) . ')');
return (scalar @$old_files, $old_size);
}
for my $extra (
['-type', 'f', '-mtime', "+$days"],
# The same age filter for directories: without it, freshly created empty
# directories belonging to running applications would be removed.
['-type', 'd', '-empty', '-mtime', "+$days"],
) {
my $p = run(['find', $directory, '-mindepth', '1', '-xdev', @$extra, '-delete'], 120);
if ($p->{rc} != 0) {
_warn("find $directory -delete failed: " . last_lines($p->{err}));
push @$warnings, "Temp cleanup of $directory hit find errors";
push @$errors, "find -delete failed in $directory";
}
}
my $remaining = find_old_files($directory, $days);
if (!defined $remaining) {
# Success numbers are never fabricated when the verification pass failed.
_warn("Could not verify deletions in $directory, counts unknown");
push @$warnings, "Temp cleanup of $directory: post-delete verification failed";
push @$errors, "find verification failed in $directory";
return (0, 0);
}
my ($gone, $left) = split_by_presence($old_files, $remaining);
my $removed = scalar @$gone;
my $freed = $old_size - paths_size($left);
$freed = 0 if $freed < 0;
status_done("$removed file(s), freed " . format_size($freed));
return ($removed, $freed);
}
sub temp_cleanup {
my (%opt) = @_;
my $dry_run = $opt{dry_run};
my $warnings = $opt{warnings};
my %result = (
tmp_files_removed => 0,
vartmp_files_removed => 0,
tmp_files_planned => 0,
vartmp_files_planned => 0,
space_saved => 0,
errors => [],
);
status("Cleaning /tmp (files older than $TMP_MAX_AGE_DAYS days)");
my ($removed, $freed) = clean_temp_dir('/tmp', $TMP_MAX_AGE_DAYS,
dry_run => $dry_run, warnings => $warnings, errors => $result{errors});
$result{ $dry_run ? 'tmp_files_planned' : 'tmp_files_removed' } = $removed;
$result{space_saved} += $freed;
if (-d '/var/tmp') {
status("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{ $dry_run ? 'vartmp_files_planned' : 'vartmp_files_removed' } = $removed;
$result{space_saved} += $freed;
}
else {
_info('/var/tmp: directory not found, skipping');
}
return \%result;
}
# ---------------------------------------------------------------------------
# Section 5: core dump cleanup
# ---------------------------------------------------------------------------
sub coredump_cleanup {
my (%opt) = @_;
my $dry_run = $opt{dry_run};
my $warnings = $opt{warnings};
my %result = (
cores_removed => 0,
cores_planned => 0,
cores_kept_recent => 0,
space_saved => 0,
errors => [],
);
my $coredump_dir = '/var/lib/systemd/coredump';
if (!-d $coredump_dir) {
_info('No coredump directory found, skipping');
return \%result;
}
my $cutoff = time() - $COREDUMP_MAX_AGE_DAYS * 86400;
my @old_dumps; # [name, mtime, size]
my $recent = 0;
my $dh;
if (!opendir($dh, $coredump_dir)) {
_warn("Cannot list $coredump_dir: $!");
push @$warnings, "Core dump cleanup skipped, cannot read $coredump_dir";
push @{ $result{errors} }, 'coredump listdir failed';
return \%result;
}
my @entries = sort readdir($dh);
closedir($dh);
for my $entry (@entries) {
next if $entry eq '.' || $entry eq '..';
my $full = "$coredump_dir/$entry";
my @st = stat($full);
next unless @st;
next unless -f $full;
if ($st[9] < $cutoff) {
push @old_dumps, [$entry, $st[9], $st[7]];
}
else {
$recent++;
}
}
$result{cores_kept_recent} = $recent;
if (!@old_dumps) {
_info("No core dumps older than $COREDUMP_MAX_AGE_DAYS days found");
_info("Keeping $recent recent core dump(s) (< $COREDUMP_MAX_AGE_DAYS days old)")
if $recent;
return \%result;
}
# Every dump is listed with its date and size before anything is removed.
my $total_size = 0;
$total_size += $_->[2] for @old_dumps;
my $heading = $dry_run ? 'Would remove' : 'Removing';
_info("$heading " . scalar(@old_dumps) . ' core dump(s) older than '
. "$COREDUMP_MAX_AGE_DAYS days (" . format_size($total_size) . '):');
for my $dump (@old_dumps) {
my ($name, $mtime, $size) = @$dump;
my @t = localtime($mtime);
my $date = sprintf('%04d-%02d-%02d %02d:%02d',
$t[5] + 1900, $t[4] + 1, $t[3], $t[2], $t[1]);
my $prefix = $dry_run ? 'would remove' : 'remove';
_info(" $prefix: $name ($date, " . format_size($size) . ')');
}
if ($dry_run) {
$result{cores_planned} = scalar @old_dumps;
$result{space_saved} = $total_size;
return \%result;
}
status('Deleting ' . scalar(@old_dumps) . ' core dump(s)');
my ($removed, $freed, $failed) = (0, 0, 0);
for my $dump (@old_dumps) {
my ($name, undef, $size) = @$dump;
if (unlink("$coredump_dir/$name")) {
$removed++;
$freed += $size;
}
else {
$failed++;
_warn("Could not remove $name: $!");
}
}
$result{cores_removed} = $removed;
$result{space_saved} = $freed > 0 ? $freed : 0;
if ($failed) {
push @$warnings, "$failed core dump(s) could not be removed, see above";
push @{ $result{errors} }, 'coredump removal incomplete';
}
status_done('removed ' . $removed . ', freed ' . format_size($result{space_saved}));
_info("Keeping $recent recent core dump(s) (< $COREDUMP_MAX_AGE_DAYS days old)")
if $recent;
return \%result;
}
# ---------------------------------------------------------------------------
# JSON decoding, hand-rolled
# ---------------------------------------------------------------------------
#
# dnf5 can describe a package list as JSON, which is steadier to read than its
# table output, and no JSON module may be assumed to exist. The decoder below
# covers the grammar the format allows, so another script that has to read JSON
# can lift it unchanged.
sub json_decode {
my ($text) = @_;
$text = '' unless defined $text;
my $pos = 0;
my $value = json_parse_value($text, \$pos);
json_skip_space($text, \$pos);
die "trailing data at offset $pos\n" if $pos < length $text;
return $value;
}
sub json_skip_space {
my ($text, $pos_ref) = @_;
my $length = length $text;
while ($$pos_ref < $length && substr($text, $$pos_ref, 1) =~ /[ \t\r\n]/) {
$$pos_ref++;
}
return;
}
sub json_parse_value {
my ($text, $pos_ref) = @_;
json_skip_space($text, $pos_ref);
die "unexpected end of input at offset $$pos_ref\n" if $$pos_ref >= length $text;
my $char = substr($text, $$pos_ref, 1);
return json_parse_object($text, $pos_ref) if $char eq '{';
return json_parse_array($text, $pos_ref) if $char eq '[';
return json_parse_string($text, $pos_ref) if $char eq '"';
return json_parse_number($text, $pos_ref) if $char =~ /[-0-9]/;
for my $literal (['true', 1], ['false', 0], ['null', undef]) {
my ($word, $value) = @$literal;
if (substr($text, $$pos_ref, length $word) eq $word) {
$$pos_ref += length $word;
return $value;
}
}
die "unrecognised token at offset $$pos_ref\n";
}
sub json_parse_object {
my ($text, $pos_ref) = @_;
my %object;
$$pos_ref++; # the opening brace
json_skip_space($text, $pos_ref);
if (substr($text, $$pos_ref, 1) eq '}') {
$$pos_ref++;
return \%object;
}
while (1) {
json_skip_space($text, $pos_ref);
die "expected a key at offset $$pos_ref\n" unless substr($text, $$pos_ref, 1) eq '"';
my $key = json_parse_string($text, $pos_ref);
json_skip_space($text, $pos_ref);
die "expected a colon at offset $$pos_ref\n" unless substr($text, $$pos_ref, 1) eq ':';
$$pos_ref++;
$object{$key} = json_parse_value($text, $pos_ref);
json_skip_space($text, $pos_ref);
my $next = substr($text, $$pos_ref, 1);
if ($next eq ',') { $$pos_ref++; next }
if ($next eq '}') { $$pos_ref++; last }
die "expected a comma or a closing brace at offset $$pos_ref\n";
}
return \%object;
}
sub json_parse_array {
my ($text, $pos_ref) = @_;
my @items;
$$pos_ref++; # the opening bracket
json_skip_space($text, $pos_ref);
if (substr($text, $$pos_ref, 1) eq ']') {
$$pos_ref++;
return \@items;
}
while (1) {
push @items, json_parse_value($text, $pos_ref);
json_skip_space($text, $pos_ref);
my $next = substr($text, $$pos_ref, 1);
if ($next eq ',') { $$pos_ref++; next }
if ($next eq ']') { $$pos_ref++; last }
die "expected a comma or a closing bracket at offset $$pos_ref\n";
}
return \@items;
}
sub json_parse_string {
my ($text, $pos_ref) = @_;
$$pos_ref++; # the opening quote
my $out = '';
my $length = length $text;
my %simple = (
'"' => '"',
'\\' => '\\',
'/' => '/',
'b' => "\b",
'f' => "\f",
'n' => "\n",
'r' => "\r",
't' => "\t",
);
while ($$pos_ref < $length) {
my $char = substr($text, $$pos_ref, 1);
if ($char eq '"') {
$$pos_ref++;
return $out;
}
if ($char ne '\\') {
$out .= $char;
$$pos_ref++;
next;
}
$$pos_ref++;
my $escape = substr($text, $$pos_ref, 1);
if (exists $simple{$escape}) {
$out .= $simple{$escape};
$$pos_ref++;
next;
}
die "unrecognised escape at offset $$pos_ref\n" unless $escape eq 'u';
my $hex = substr($text, $$pos_ref + 1, 4);
die "malformed \\u escape at offset $$pos_ref\n" unless $hex =~ /^[0-9a-fA-F]{4}$/;
my $code = hex $hex;
$$pos_ref += 5;
# a high surrogate is followed by the low half of the pair
if ($code >= 0xd800 && $code <= 0xdbff && substr($text, $$pos_ref, 2) eq '\\u') {
my $low_hex = substr($text, $$pos_ref + 2, 4);
if ($low_hex =~ /^[0-9a-fA-F]{4}$/) {
my $low = hex $low_hex;
if ($low >= 0xdc00 && $low <= 0xdfff) {
$code = 0x10000 + (($code - 0xd800) << 10) + ($low - 0xdc00);
$$pos_ref += 6;
}
}
}
my $bytes = pack('U', $code);
utf8::encode($bytes);
$out .= $bytes;
}
die "unterminated string\n";
}
sub json_parse_number {
my ($text, $pos_ref) = @_;
my $length = length $text;
my $start = $$pos_ref;
$$pos_ref++ if substr($text, $$pos_ref, 1) eq '-';
$$pos_ref++ while $$pos_ref < $length && substr($text, $$pos_ref, 1) =~ /[0-9]/;
if ($$pos_ref < $length && substr($text, $$pos_ref, 1) eq '.') {
$$pos_ref++;
$$pos_ref++ while $$pos_ref < $length && substr($text, $$pos_ref, 1) =~ /[0-9]/;
}
if ($$pos_ref < $length && substr($text, $$pos_ref, 1) =~ /[eE]/) {
$$pos_ref++;
$$pos_ref++ if substr($text, $$pos_ref, 1) =~ /[-+]/;
$$pos_ref++ while $$pos_ref < $length && substr($text, $$pos_ref, 1) =~ /[0-9]/;
}
my $literal = substr($text, $start, $$pos_ref - $start);
unless ($literal =~ /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?$/) {
die "malformed number at offset $start\n";
}
return $literal + 0;
}
# ---------------------------------------------------------------------------
# Section 6: package audit, informational only
# ---------------------------------------------------------------------------
# The package names in a dnf5 --json answer, as name.arch where an architecture
# is given, sorted. Returns undef when the text is not JSON this can read, which
# the caller reports as unavailable rather than as an empty list.
sub extras_names {
my ($text) = @_;
my $data = eval { json_decode($text) };
return undef unless defined $data && ref $data eq 'HASH';
my @found;
for my $section (values %$data) {
next unless ref $section eq 'ARRAY';
for my $package (@$section) {
next unless ref $package eq 'HASH' && $package->{name};
push @found, $package->{arch}
? "$package->{name}.$package->{arch}"
: $package->{name};
}
}
return [ sort @found ];
}
# The package names in a dnf 4 --quiet listing: one per line, above which dnf
# writes header lines naming cache and section state rather than packages. The
# order dnf printed them in is kept.
sub extras_names_text {
my ($text) = @_;
my @headers = ('last metadata', 'extra packages', 'available packages', 'installed packages');
my @names;
for my $line (split /\n/, ($text // '')) {
$line =~ s/^\s+//;
$line =~ s/\s+$//;
next unless length $line;
my $lower = lc $line;
next if grep { index($lower, $_) == 0 } @headers;
my @fields = split ' ', $line;
push @names, $fields[0] if @fields;
}
return \@names;
}
sub package_audit {
my ($dnf_major, %opt) = @_;
my $warnings = $opt{warnings};
my %result = (
extras_count => 0,
extras_packages => [],
errors => [],
);
status('Checking for packages not in any repo (dnf list --extras)');
my ($p, $names);
if ($dnf_major >= 5) {
# dnf 5 describes the list as JSON, which does not have to be read out of
# a table whose columns move between releases.
$p = run(['dnf', 'list', '--extras', '--json'], 60);
$names = $p->{rc} == 0 ? extras_names($p->{out}) : undef;
}
else {
# dnf 4 has no --json option, so its quiet listing is read instead.
$p = run(['dnf', 'list', '--extras', '--quiet'], 60);
$names = $p->{rc} == 0 ? extras_names_text($p->{out}) : undef;
}
if (!defined $names) {
status_done('unavailable');
my $detail = $p->{rc} != 0 ? last_lines($p->{err}) : 'unparseable dnf output';
my $msg = "dnf list --extras unavailable: $detail";
_warn($msg);
push @$warnings, $msg;
return \%result;
}
$result{extras_count} = scalar @$names;
$result{extras_packages} = $names;
if (@$names) {
status_done(scalar(@$names) . ' package(s)');
_warn("Extra package: $_") for @$names[0 .. (@$names > 10 ? 9 : $#$names)];
_warn(' ... and ' . (scalar(@$names) - 10) . ' more') if @$names > 10;
push @$warnings, scalar(@$names) . ' installed package(s) not found in any repo';
}
else {
status_done('none');
}
return \%result;
}
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
sub print_summary {
my ($os_display, $version_id, $results, $warnings, $elapsed, %opt) = @_;
my $dry_run = $opt{dry_run};
my $bar = "═" x 60;
print STDERR "\n${BOLD}── Optimisation Summary ──$RESET\n";
print STDERR " OS: $os_display $version_id\n";
if ($dry_run) {
print STDERR " ${YELLOW}DRY RUN: nothing was changed, every action below is a would-be action$RESET\n";
}
my $dnf = $results->{dnf} // {};
if (%$dnf) {
my $count = $dnf->{autoremoved_count} // 0;
if ($dry_run) {
if ($count > 0) { _info("DNF autoremove: would remove $count package(s)") }
else { _info('DNF autoremove: nothing to remove') }
}
elsif ($dnf->{autoremoved}) { _ok("DNF autoremove: removed $count package(s)") }
else { _info('DNF autoremove: nothing to remove') }
my $kernels = $dry_run ? ($dnf->{kernels_planned} // 0) : ($dnf->{kernels_removed} // 0);
if ($kernels > 0) {
if ($dry_run) { _info("Old kernels: would remove $kernels") }
else { _ok("Old kernels removed: $kernels") }
}
else {
_info('Old kernels: none to remove');
}
_info('Kernels kept: ' . ($dnf->{kernels_kept} // '?'));
my $cache_saved = format_size($dnf->{space_saved} // 0);
if ($dry_run) { _info("DNF cache: would clean (~$cache_saved)") }
elsif ($dnf->{cache_cleaned}) { _ok("DNF cache: cleaned (freed $cache_saved)") }
}
my $journal = $results->{journal} // {};
if (%$journal) {
if ($dry_run) {
_info("Journal: would vacuum to ${JOURNAL_VACUUM_SIZE_MB}M (est. freed ~"
. format_size($journal->{space_saved} // 0) . ')');
}
elsif ($journal->{vacuumed}) {
_ok('Journal vacuumed: freed ' . format_size($journal->{space_saved} // 0));
}
if ($journal->{persistent_warning}) {
_warn('Journal storage is still large, check SystemMaxUse= in journald.conf');
}
}
my $systemd = $results->{systemd} // {};
if (%$systemd) {
my $failed = scalar @{ $systemd->{failed_units} // [] };
if ($failed > 0) { _warn("Failed systemd units: $failed") }
else { _ok('Failed systemd units: none') }
_info('Disabled unit files: ' . ($systemd->{disabled_count} // 0) . ' (informational)');
my $leftovers = scalar @{ $systemd->{leftover_files} // [] };
if ($leftovers > 0) { _warn("Leftover unit files in /etc/systemd/system/: $leftovers") }
else { _info('Leftover unit files: none') }
}
my $tmp = $results->{tmp} // {};
if (%$tmp) {
my ($tmp_count, $vartmp_count) = $dry_run
? ($tmp->{tmp_files_planned} // 0, $tmp->{vartmp_files_planned} // 0)
: ($tmp->{tmp_files_removed} // 0, $tmp->{vartmp_files_removed} // 0);
if ($tmp_count > 0 || $vartmp_count > 0) {
my $verb = $dry_run ? 'would remove' : 'removed';
my $msg = "Temp files: $verb $tmp_count from /tmp, $vartmp_count from /var/tmp";
$dry_run ? _info($msg) : _ok($msg);
}
else {
_info('Temp files: none to remove');
}
}
my $cores = $results->{cores} // {};
if (%$cores) {
my $count = $dry_run ? ($cores->{cores_planned} // 0) : ($cores->{cores_removed} // 0);
if ($count > 0) {
my $verb = $dry_run ? 'would remove' : 'removed';
my $msg = "Core dumps: $verb $count (older than $COREDUMP_MAX_AGE_DAYS days)";
$dry_run ? _info($msg) : _ok($msg);
}
else {
_info('Core dumps: none to remove');
}
if (($cores->{cores_kept_recent} // 0) > 0) {
_info('Core dumps kept (recent): ' . $cores->{cores_kept_recent});
}
}
my $packages = $results->{packages} // {};
if (%$packages) {
my $extras = $packages->{extras_count} // 0;
if ($extras > 0) { _warn("Packages not in any repo: $extras") }
else { _ok('Package audit: all packages from known repos') }
}
my $total_space = 0;
$total_space += ($results->{$_} // {})->{space_saved} // 0 for qw(dnf journal tmp cores);
if ($dry_run) {
_info('Estimated space to free: ~' . format_size($total_space));
}
elsif ($total_space > 0) {
_ok('Total space freed: ' . format_size($total_space));
}
else {
_info('Total space freed: 0 B');
}
if (@$warnings) {
print STDERR "\n";
_warn($_) for @$warnings;
}
print STDERR "\n${BOLD}${bar}$RESET\n";
printf STDERR " %sTotal time: %.1fs%s\n", $BOLD, $elapsed, $RESET;
print STDERR "${BOLD}${bar}$RESET\n\n";
return;
}
# ---------------------------------------------------------------------------
# Command line
# ---------------------------------------------------------------------------
sub usage {
my $name = $0;
$name =~ s{.*/}{};
return <<"USAGE";
Usage: $name [options]
Idempotent system cleanup and optimisation for Fedora, CentOS Stream and openEuler
Options:
--dry-run Preview without making changes
--skip-dnf Skip DNF cleanup (autoremove, old kernels, cache)
--skip-journal Skip journal vacuum
--skip-tmp Skip temp file cleanup
--skip-cores Skip core dump cleanup
--version Show the version and exit
-h, --help Show this help and exit
USAGE
}
sub parse_args {
my %opt = (
dry_run => 0,
skip_dnf => 0,
skip_journal => 0,
skip_tmp => 0,
skip_cores => 0,
);
my %flag_for = (
'--dry-run' => 'dry_run',
'--skip-dnf' => 'skip_dnf',
'--skip-journal' => 'skip_journal',
'--skip-tmp' => 'skip_tmp',
'--skip-cores' => 'skip_cores',
);
my @argv = @ARGV;
while (defined(my $arg = shift @argv)) {
if (exists $flag_for{$arg}) { $opt{ $flag_for{$arg} } = 1; next }
if ($arg eq '--help' || $arg eq '-h') { print usage(); exit 0 }
if ($arg eq '--version') {
my $name = $0;
$name =~ s{.*/}{};
print "$name $VERSION\n";
exit 0;
}
print STDERR "unrecognised argument: $arg\n";
print STDERR usage();
exit 2;
}
return %opt;
}
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
sub main {
my $start_time = now();
my @warnings;
my @failures;
my %results;
my %opt = parse_args();
check_root();
my ($os_id, $os_display, $version_id) = detect_os();
my $dnf_major = dnf_generation();
my $bar = "═" x 60;
print STDERR "\n${BOLD}${bar}$RESET\n";
print STDERR "${BOLD} System Optimise v$VERSION ($os_display $version_id)$RESET\n";
print STDERR "${BOLD}${bar}$RESET\n";
if ($opt{dry_run}) {
print STDERR "\n ${YELLOW}${BOLD}DRY RUN: no changes will be made$RESET\n";
}
print STDERR "\n";
my @sections = (
['dnf', 'DNF Cleanup',
sub { dnf_cleanup($os_id, $dnf_major, dry_run => $opt{dry_run}, warnings => \@warnings) },
$opt{skip_dnf} ? '--skip-dnf' : undef],
['journal', 'Journal Cleanup',
sub { journal_cleanup(dry_run => $opt{dry_run}, warnings => \@warnings) },
$opt{skip_journal} ? '--skip-journal' : undef],
['systemd', 'systemd Audit',
sub { systemd_audit(warnings => \@warnings) },
undef],
['tmp', 'Temp File Cleanup',
sub { temp_cleanup(dry_run => $opt{dry_run}, warnings => \@warnings) },
$opt{skip_tmp} ? '--skip-tmp' : undef],
['cores', 'Core Dump Cleanup',
sub { coredump_cleanup(dry_run => $opt{dry_run}, warnings => \@warnings) },
$opt{skip_cores} ? '--skip-cores' : undef],
['packages', 'Package Audit',
sub { package_audit($dnf_major, warnings => \@warnings) },
undef],
);
# Every section is isolated: one that fails or dies is recorded and reported
# without stopping the rest or the summary.
for my $section (@sections) {
my ($key, $title, $code, $skip_flag) = @$section;
print STDERR "\n${BOLD}── $title ──$RESET\n";
if (defined $skip_flag) {
_info("$title: skipped ($skip_flag)");
next;
}
my $result = eval { $code->() };
if (my $error = $@) {
$error =~ s/\s+\z//;
_fail("$title crashed: $error");
push @failures, "$title: crashed ($error)";
next;
}
$results{$key} = $result;
push @failures, map { "$title: $_" } @{ $result->{errors} // [] };
}
my $elapsed = now() - $start_time;
print_summary($os_display, $version_id, \%results, \@warnings, $elapsed,
dry_run => $opt{dry_run});
if (@failures) {
print STDERR "${RED}${BOLD}Failed operations (" . scalar(@failures) . "):$RESET\n";
_fail($_) for @failures;
print STDERR "\n";
exit 1;
}
return 0;
}
$SIG{INT} = sub {
print STDERR "\nInterrupted.\n";
remove_scratch();
exit 130;
};
$SIG{TERM} = sub {
remove_scratch();
exit 143;
};
END {
remove_scratch();
}
# Only when this file is the program: a test harness may require it and call the
# pure functions directly.
exit(main()) unless caller;