#!/bin/bash
# Copyright (C) 2008 Vladimir V. Kamarzin <vvk@altlinux.org>
# Copyright (C) 2020-2023 Vitaly Chikunov <vt@altlinux.org>
#
# Remove all kernels except current
#
# This file is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA.

export LC_ALL=C
PROG=${0##*/}
message() {
	echo >&2 "$PROG: $*"
}

fatal() {
	message "$@"
	exit 1
}

show_usage()
{
	[ -z "$*" ] || message "$*"
	echo >&2 "Try \`$PROG --help' for more information."
	exit 1
}

show_help()
{
        cat <<EOF
Convenient way to remove old kernels previously installed with update-kernel.

Usage: $PROG [options] [kernel...]

Options:
	-f, -y, --force   do not ask for removal confirmation (default is to ask)
	-n, --dry-run     just simulate removal
	-t, --type        remove old kernels for the specified flavour (un-def, 6.12, etc)
	-a, --all         remove old kernels for all flavours (keep latest for each one)
	-A, --purge       purge other flavours
	--no-backup       do not keep the backup kernel
	-h, --help        show this text and exit

Kernels to remove may be listed explicitly, as a package name
(kernel-image-un-def-6.1.121-alt1, un-def-6.1.121-alt1) or as a kernel
release (6.1.121-un-def-alt1); then only they are removed.

Note: currently booted and backup kernels won't be removed (a listed
backup kernel is removed, the booted one never is).
The backup kernel is the latest kernel with uptime of more than a day.
All numeric kernels (x.y) are considered to be a single flavour.
EOF
exit 0
}

#parse command line options
TEMP=$(getopt -n "$PROG" -o f,B,y,n,t:,h,a,A,v -l force,no-backup,dry-run,type:,help,all,purge,verbose -- "$@") || show_usage
eval set -- "$TEMP"

all=
dryrun=
force=
kernel_flavour=
nobackuplogic=
verbose=
while :; do
        case "$1" in
                --) shift; break
                        ;;
                -f|-y|--force) force="-y"
                        ;;
		-B) message "Option '-B' is deprecated, please use '--no-backup' instead."
			;&
		--no-backup)
			nobackuplogic=1
			;;
		-n|--dry-run) dryrun="--no-remove"
			;;
		-t|--type) shift ; kernel_flavour="$1"
			;;
		-a|--all) all=yes
			;;
		-A|--purge) all=purge
			;;
		-v|--verbose) verbose=1
			;;
                -h|--help) show_help
        esac
	shift
done

user_kernels=("$@")
user_packages=

if [ -n "$verbose" ]; then
	V() { echo >&2 "+ $*"; "$@"; }
else
	V() { "$@"; }
fi

cr=$'\n'
# set colors on tty
if [ -t 1 ]; then
        RED=$'\e[1;31m'
        GREEN=$'\e[1;32m'
	YELLOW=$'\e[1;33m'
        MAGENTA=$'\e[1;35m'
        BRIGHT=$'\e[1m'
        NORM=$'\e[0m'
else
        RED=
        GREEN=
	YELLOW=
        MAGENTA=
        BRIGHT=
        NORM=
fi

uname_r=${DEBUG_UNAME_OVERRIDE-$(uname -r)}

# get running kernel version
current_kernel_package=$(rpmquery -qf "/lib/modules/$uname_r/kernel" 2>/dev/null)
if [ -n "$current_kernel_package" ] ; then
    current_kernel_pkgname=$(rpmquery --queryformat "%{NAME}-%{VERSION}-%{RELEASE}\n" -q "$current_kernel_package")
    echo "Currently booted kernel package: $BRIGHT$current_kernel_pkgname$NORM"
    unset current_kernel_pkgname
else
    echo "Running kernel version: $BRIGHT$uname_r$NORM (package not found)"
fi

# Resolve kernels listed on the command line to installed packages (ALT#55852).
resolve_kernel() {
	local k=${1#kernel-image-} ver rel flv pkg
	pkg=$(rpm -q "kernel-image-$k" 2>/dev/null) || pkg=
	# Kernel release format: version-flavour-release.
	if [ -z "$pkg" ] && [[ $k =~ ^[0-9][^-]*-.+-[^-]+$ ]]; then
		ver=${k%%-*}
		rel=${k##*-}
		flv=${k#"$ver"-}
		flv=${flv%-"$rel"}
		pkg=$(rpm -q "kernel-image-$flv-$ver-$rel" 2>/dev/null) || pkg=
	fi
	# A bare flavour matches several kernels: require an exact one.
	[ -n "$pkg" ] && [ "$pkg" = "${pkg%%"$cr"*}" ] || return 1
	echo "$pkg"
}
if [ ${#user_kernels[@]} -gt 0 ]; then
	if [ -n "$all" ] || [ -n "$kernel_flavour" ]; then
		show_usage "kernels to remove cannot be combined with -t, -a, -A"
	fi
	declare -A seen_kernels
	for k in "${user_kernels[@]}"; do
		pkg=$(resolve_kernel "$k") || fatal "Kernel $k is not installed or is ambiguous."
		[ "$pkg" != "$current_kernel_package" ] ||
			fatal "Refusing to remove currently booted kernel $pkg."
		[ -z "${seen_kernels[$pkg]}" ] || continue
		seen_kernels[$pkg]=1
		user_packages+=" $pkg"
	done
	unset k pkg seen_kernels
fi

# set kernel flavour. if not defined with -t option, use current
current_kernel_flavour="$uname_r"
current_kernel_flavour="${current_kernel_flavour#*-}"
current_kernel_flavour="${current_kernel_flavour%-*}"
all_kernels="$(rpm -qa --qf '%{NAME}\t%{EVR}\n' 'kernel-image-*' | grep -v -w -e domU -e debuginfo -e checkinstall | cut -d- -f3-)"
# all_kernels have already stripped 'kernel-image-' prefix.
if [ -z "$kernel_flavour" ] && grep -qPx '\d+\.\d+' <<<"$current_kernel_flavour"; then
	# If we are booted into one of x.y (new) flavours apply purging logic
	# to the all of stable kernels.
	kernel_flavour=latest
fi
# Determine what flavours to consider for removal.
if [ -n "$all" ] ; then
	flavours="$(echo "$all_kernels" | cut -f1 | sort -u)"
elif [ "$kernel_flavour" = latest ]; then
	# Internal term for stable flavours, excluding -rc kernels. It's safe
	# to add std-def|un-def flavours here, because below it will be checked
	# for presence in the repo anyway. So, they will not be deleted if they
	# are in the repo, but they will be shown as latest.
	flavours=$(echo "$all_kernels" | grep -ve '\.0-alt0\.rc' | cut -f1 | grep -Px 'std-def|un-def|\d+\.\d+' | sort -u)
elif [ -n "$kernel_flavour" ]; then
	flavours="$kernel_flavour"
else
	flavours="$current_kernel_flavour"
fi
unset all_kernels

# Get the latest kernel other than the booted one with uptime >=1 day.
# The booted kernel is kept anyway, so it cannot serve as a backup
# (ALT#53849).
good_kernel=
good_kernel_days=
good_kernel_package=
booted_good=
if [ -z "$nobackuplogic" ] && [ -f "/var/log/wtmp" ]; then
	while read -r days kernel; do
		days=${days#(}
		days=${days%%+*}
		if [ "$kernel" = "$uname_r" ]; then
			booted_good=$days
			continue
		fi
		tver=${kernel%%-*}
		trel=${kernel##*-}
		tflv=${kernel#"$tver"-}
		tflv=${tflv%-"$trel"}
		pkg=kernel-image-$tflv-$tver-$trel
		[ -n "$good_kernel" ] || { good_kernel=$pkg; good_kernel_days=$days; }
		# Prefer a backup kernel which is still installed.
		if good_kernel_package=$(rpm -q "$pkg" 2>/dev/null); then
			good_kernel=$pkg
			good_kernel_days=$days
			break
		fi
		good_kernel_package=
	done < <(last -a reboot | awk '$ 10 ~ /+/ {print $10,$11}')
	unset days kernel tver trel tflv pkg
fi
if [ -n "$good_kernel" ]; then
	echo "Previous kernel with uptime $good_kernel_days days: $BRIGHT$good_kernel$NORM (backup)"
	if [ -z "$good_kernel_package" ]; then
		echo "${RED}Warning: Package for the backup kernel not found.$NORM"
	fi
elif [ -n "$booted_good" ]; then
	echo "${YELLOW}Warning: No backup kernel other than the booted one (uptime $booted_good days).$NORM"
else
	echo "${YELLOW}Warning: Backup kernel is not determined.$NORM"
fi
unset booted_good

# Speed up consecutive apt-cache runs under a user by configuring session
# persistent Dir::Cache.
APTCACHE=()
if tmpdir=$(mktemp -d); then
	readonly tmpdir
	_atexit() { rm -rf -- "$tmpdir"; }
	trap _atexit EXIT
	eval "$(apt-config shell APTCACHEDIR Dir::Cache/f)"
	[ -w "$APTCACHEDIR" ] || APTCACHE=(-o Dir::Cache="$tmpdir")
else
	fatal "Cannot create temporary directory."
fi

in_pkglist() {
	local flavour=$1
	[ -e "$tmpdir/pkgnames" ] ||
		apt-cache "${APTCACHE[@]}" pkgnames 'kernel-image-' > "$tmpdir/pkgnames"
	grep -P "^kernel-image-\Q$flavour\E\#" < "$tmpdir/pkgnames" > "$tmpdir/pkgnames.$flavour"
	local pkg pkglist=
	while read -r pkg; do
		pkglist=$(apt-cache "${APTCACHE[@]}" policy "$pkg" | grep -w 'pkglist$')
		[ -z "$pkglist" ] || break
	done < "$tmpdir/pkgnames.$flavour"
	echo "${pkglist:+yes}"
}

# Highest stable flavour will be used for newest kernel check.
newest_stable_kernel=
newest_stable_flavour=$(grep -Px '\d+\.\d+' <<<"$flavours" | sort -V | tail -1)
if [ -n "$newest_stable_flavour" ]; then
	newest_stable_kernel=$(rpm -q "kernel-image-$newest_stable_flavour" 2>/dev/null | sort -V | tail -1)
fi

# Sort the kernels
other_flavours=
keep_kernels=
apt_args_list=
pkglisted=
[ -z "$user_packages" ] || flavours=
for kernel in $user_packages; do
	whydel="requested"
	if [ "$kernel" = "$good_kernel_package" ]; then
		whydel="$whydel, backup kernel with uptime $good_kernel_days days"
	fi
	remove_kernels="$remove_kernels   $MAGENTA$kernel$NORM ($whydel)$cr"
	apt_args_list="$apt_args_list $(rpm -q --queryformat '%{NAME}=%{EPOCH}:%{VERSION}-%{RELEASE}\n' "$kernel" \
		| sed -e "s,(none):,,g")"
done
for flavour in $flavours; do
	all_kernels="$(rpm -q "kernel-image-$flavour" 2>/dev/null | sort -V)"
	newest_kernel=$(echo "$all_kernels" | tail -1)
	[ "$all" = "purge" ] || pkglisted=$(in_pkglist "$flavour")
	real_flavour=$flavour
	if grep -qPx '\d+\.\d+' <<<"$flavour"; then
		newest_kernel=$newest_stable_kernel
		[ "$kernel_flavour" = latest ] && flavour=latest
	fi
	for kernel in $all_kernels; do
		reason=
		whydel=
		if [ "$all" = "purge" ] && [ "$kernel_flavour" != "$flavour" ]; then
			# Old kernels belonging to 'latest' flavours will be handled below as non-latest.
			whydel="other flavour"
		elif [ "$all" != "purge" ] && [ -z "$pkglisted" ]; then
			whydel="EOL"
		elif [ "$kernel" = "$newest_kernel" ]; then
			reason="$reason, latest for $real_flavour"
			[ "$kernel_flavour" != "$flavour" ] && other_flavours=y
		fi
		if [ "$kernel" = "$current_kernel_package" ]; then
			reason="$reason, currently booted"
		fi
		if [ "$kernel" = "$good_kernel_package" ]; then
			reason="$reason, with uptime $good_kernel_days days"
		fi
		if [ -n "$reason" ]; then
			keep_kernels="$keep_kernels   $GREEN$kernel$NORM (${reason#, })$cr"
		else
			[ -z "$whydel" ] || whydel=" ($whydel)"
			remove_kernels="$remove_kernels   $MAGENTA$kernel$NORM$whydel$cr"
			apt_args_list="$apt_args_list $(rpm -q --queryformat '%{NAME}=%{EPOCH}:%{VERSION}-%{RELEASE}\n' "$kernel" \
				| sed -e "s,(none):,,g")"
		fi
	done
done
howmuch=$(echo "$keep_kernels" | grep -c .)
if [ "$howmuch" -gt 0 ]; then
	[ "$howmuch" -eq 1 ] && phrase="this kernel" || phrase="these $BRIGHT$howmuch$NORM kernels"
	echo "Keeping $phrase (with the reason why):"
	printf "%s" "$keep_kernels"
	[ -n "$other_flavours" ] && echo "Specify -A to remove all kernels of other flavours than $kernel_flavour."
	echo
fi

if [ -z "$apt_args_list" ] ; then
	echo "Nothing to remove."
	exit 0
fi

howmuch=$(echo "$remove_kernels" | grep -c .)
[ "$howmuch" -eq 1 ] && phrase="this kernel" || phrase="these $BRIGHT$howmuch$NORM kernels"
echo "Will be removing $phrase:"
echo "$remove_kernels"

if [ "$UID" != "0" ]; then
	echo >&2 "${RED}Warning: This program requires root privileges.$NORM"
fi

echo -n "Confirm" ${dryrun:+--dry-run} "uninstall action for $phrase [Y/n]? "
if [ -n "$force" ]; then
	echo "yes"
else
	while true; do
		read -r || { echo "Aborting"; exit 1; }
		shopt -s nocasematch
		case "$REPLY" in
			n|no|q) exit 0 ;;
			y|ye|yes|'') break ;;
			*) ;;
		esac
		echo -n "[Y/n] "
		shopt -u nocasematch
	done
fi


# shellcheck disable=SC2086
V apt-get -y $dryrun remove $apt_args_list

# Mask non-zero apt exit code on dry run:
if [ -n "$dryrun" ]; then
    exit 0
fi
