#!/bin/sh -e
# Copyright 2025 Dylan Van Assche, Oliver Smith
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Seamlessly upgrading postmarketOS stable releases
SCRIPT_VERSION="1.10.0"

CHANNELS_ALL="
	edge:edge
	v20.05:v3.12
	v21.03:v3.13
	v21.06:v3.14
	v21.12:v3.15
	v22.06:v3.16
	v22.12:v3.17
	v23.06:v3.18
	v23.12:v3.19
	v24.06:v3.20
	v24.12:v3.21
	v25.06:v3.22
"

# These are verified to be working by CI at:
# https://gitlab.postmarketos.org/postmarketOS/postmarketos-release-upgrade/
UPGRADES_SUPPORTED="
	v22.06-v22.12
	v22.12-v23.06
	v23.06-v23.12
	v23.12-v24.06
	v24.06-v24.12
	v24.12-v25.06
	v25.06-edge
"

CHANNEL_OLD=""
CHANNEL_NEW="$1"
CHANNEL_LATEST_STABLE=""
UPGRADE_IS_SUPPORTED=""

MIRRORDIR_OLD_PMOS=""
MIRRORDIR_OLD_ALPINE=""
MIRRORDIR_NEW_PMOS=""
MIRRORDIR_NEW_ALPINE=""

STEP=0
STEP_TOTAL=4

UPGRADE_SCRIPT_LAST_MODIFIED="$(stat -c "%Y" "$0")"

VERB="upgrade"

VIRTUAL_NAME=".pmos-relup-apk-static"

# Wrap apk to never go into interactive mode. In postmarketOS, we configure apk
# with /etc/apk/interactive to go into interactive mode by default, but this
# would give bad usability here. The user already has to confirm once at the
# start of the script. Use </dev/null because --no-interactive is not available
# in all apk versions.
_APK="$(command -v apk)"
apk() {
	"$_APK" "$@" </dev/null
}

set_colors() {
	# See https://no-color.org
	if [ -n "$NO_COLOR" ]; then
		NORMAL=""
		RED=""
		GREEN=""
		YELLOW=""
	else
		NORMAL="\033[1;0m"
		RED="\033[1;31m"
		GREEN="\033[1;32m"
		YELLOW="\033[1;33m"
	fi
}


msg() {
	printf "$GREEN>>>$NORMAL %s\n" "$1"
}

msg_step() {
	msg "($STEP/$STEP_TOTAL) $1"
}

warn() {
	printf "$YELLOW>>> WARNING:$NORMAL %s\n" "$1"
}

err() {
	printf "$RED>>> ERROR:$NORMAL %s\n" "$1"
}

err_exit() {
	err "$1"
	exit 1
}

err_restore_apk_repos_exit() {
	err "$1"
	cp "$VAR_LIB_DIR/etc-apk-repositories.old" /etc/apk/repositories
	err_exit "Restored old /etc/apk/repositories"
}

get_channel_latest_stable() {
	CHANNEL_LATEST_STABLE="$(echo "$CHANNELS_ALL" \
		| tail -n2 \
		| tr -d "\t" \
		| cut -d : -f 1)"

	if [ -z "$CHANNEL_LATEST_STABLE" ]; then
		err_exit "Failed to get latest stable channel"
	fi
}

show_usage() {
	if [ "$1" != "-h" ]; then
		return
	fi

	echo "usage: postmarketos-release-upgrade [-h] [CHANNEL]"
	echo
	echo "arguments:"
	echo "  -h       show this help"
	echo "  CHANNEL  the release channel to upgrade/downgrade to, e.g."
	echo "           \"$CHANNEL_LATEST_STABLE\" (default) or \"edge\""
	echo
	echo "CHANNEL must be set if you are already on edge."
	echo "Script version: $SCRIPT_VERSION"

	exit 1
}

check_is_root() {
	if [ "$(id -u)" != "0" ]; then
		err_exit "postmarketos-release-upgrade must run as root"
	fi
}

init_log() {
	if [ -n "$VAR_LIB_DIR" ]; then
		return
	fi

	# shellcheck disable=SC2155
	export VAR_LIB_DIR="/var/lib/postmarketos-release-upgrade/$(date "+%Y-%m-%d-%H%M%S")"
	mkdir -p "$VAR_LIB_DIR"

	"$@" 2>&1 | tee "$VAR_LIB_DIR/log.txt"

	if [ -e "$VAR_LIB_DIR/success" ]; then
		exit 0
	fi
	exit 1
}

check_is_pmos() {
	if ! grep -q '^ID="postmarketos"$' /etc/os-release; then
		err_exit "This doesn't look like a postmarketOS install!"
	fi
}

check_boot_size() {
	# shellcheck disable=SC2155
	local boot_size_kb="$(df -P /boot | awk 'NR==2 {print $2}')"
	local min_size_kb="$((200 * 1024))"

	if [ "$boot_size_kb" -ge "$min_size_kb" ]; then
		return
	fi

	warn "Your boot partition is smaller than 200 MiB!"
	warn "This may cause the upgrade to fail."
	warn "Consider reinstalling instead."
}

check_mrtest() {
	if ! grep -q '^\.mrtest' /etc/apk/world; then
		return
	fi

	warn "Found virtual '.mrtest' packages in /etc/apk/world."
	err_exit "Run 'mrtest zap' first"
}

check_hardcoded_versions() {
	local pattern='[=><]'
	if ! grep -q "$pattern" /etc/apk/world; then
		return
	fi

	warn "Found hardcoded versions /etc/apk/world:"
	grep "$pattern" /etc/apk/world
	warn "Remove them first, e.g. by editing /etc/apk/world and running"
	warn "'apk fix'. This is for advanced users, if you don't know what"
	warn "you are doing, better make a backup first and ask for help."
	err_exit "Fix hardcoded versions first"
}

get_channel_current() {
	# shellcheck disable=SC2155
	local ret="$(grep '^VERSION="' /etc/os-release  | cut -d '"' -f 2)"
	case "$ret" in
		v*)
			# Cut off the SP number, e.g. v20.12.5 -> v20.12
			echo "$ret" | cut -d . -f 1-2
			;;
		*)
			echo "$ret"
			;;
	esac
}

get_channels() {
	if [ -z "$CHANNEL_OLD" ]; then
		CHANNEL_OLD="$(get_channel_current)"
	fi

	if [ -z "$CHANNEL_NEW" ]; then
		if [ "$CHANNEL_OLD" = "$CHANNEL_LATEST_STABLE" ]; then
			msg "Nothing to do, you are on the latest stable $CHANNEL_LATEST_STABLE."
			exit 0
		elif [ "$CHANNEL_OLD" = "edge" ]; then
			msg "Nothing to do, you are already on edge."
			msg "If you want to do a downgrade, set the channel as argument,"
			msg "as described in postmarketos-release-upgrade -h."
			exit 0
		else
			CHANNEL_NEW="$CHANNEL_LATEST_STABLE"
		fi
	fi
}

check_channels_upgrade_supported() {
	if echo "$UPGRADES_SUPPORTED" | grep -q "$CHANNEL_OLD-$CHANNEL_NEW"; then
		UPGRADE_IS_SUPPORTED=1
		return
	fi

	warn "This $VERB from $CHANNEL_OLD to $CHANNEL_NEW is not supported."
	warn "This can break your installation."
	warn "Do not proceed unless you know what you are doing!"
}

check_edge_warning() {
	if [ "$CHANNEL_NEW" != "edge" ]; then
		return
	fi

	warn "You are about to $VERB to postmarketOS edge!"
	warn "Edge is the development channel of postmarketOS, therefore it"
	warn "experiences frequent issues that affect stability. Some of these"
	warn "issues may even prevent a device from booting completely."
	warn "Severe problems are listed at: https://postmarketos.org/edge"
	warn "If you want a stable experience, use $CHANNEL_LATEST_STABLE instead of edge."
}

check_channels_is_downgrade() {
	if [ "$CHANNEL_NEW" = "edge" ]; then
		return
	fi

	if [ "$CHANNEL_OLD" = "edge" ] ||
	   [ "$(apk version -t "$CHANNEL_OLD" "$CHANNEL_NEW")" = ">" ]; then
		warn "You are about to perform a downgrade!"
		VERB="downgrade"
	fi
}

check_channels_valid() {
	if [ -z "$CHANNEL_OLD" ] || [ -z "$CHANNEL_NEW" ]; then
		err_exit "Failed to get the old/new channel names"
	fi

	if [ "$CHANNEL_OLD" = "$CHANNEL_NEW" ]; then
		err_exit "You are already on $CHANNEL_NEW!"
	fi

	if ! echo "$CHANNELS_ALL" | grep -q -- "^\t$CHANNEL_NEW:"; then
		if echo "$CHANNELS_ALL" | grep -q -- "v$CHANNEL_NEW:"; then
			warn "Do you mean v$CHANNEL_NEW (note the v)?"
		fi
		err_exit "Invalid new channel name: $CHANNEL_NEW"
	fi
}

get_mirrordir_pmos() {
	case "$1" in
		"edge") echo "master" ;;
		*) echo "$1" ;;
	esac
}

get_mirrordir_alpine() {
	local channel="$1"
	local i

	for i in $CHANNELS_ALL; do
		case "$i" in
			"$channel:"*)
				echo "$i" | cut -d : -f 2
				return
				;;
		esac
	done
}

get_mirrordirs() {
	MIRRORDIR_OLD_PMOS="$(get_mirrordir_pmos "$CHANNEL_OLD")"
	MIRRORDIR_OLD_ALPINE="$(get_mirrordir_alpine "$CHANNEL_OLD")"
	MIRRORDIR_NEW_PMOS="$(get_mirrordir_pmos "$CHANNEL_NEW")"
	MIRRORDIR_NEW_ALPINE="$(get_mirrordir_alpine "$CHANNEL_NEW")"

	if [ -z "$MIRRORDIR_OLD_PMOS" ] \
		|| [ -z "$MIRRORDIR_OLD_ALPINE" ] \
		|| [ -z "$MIRRORDIR_NEW_PMOS" ] \
		|| [ -z "$MIRRORDIR_NEW_ALPINE" ]; then

		err_exit "Failed to get all mirrordirs!"
	fi
}

confirm_upgrade() {
	msg "You are about to $VERB from $CHANNEL_OLD to $CHANNEL_NEW."
	msg
	msg "This will be done in the following steps:"
	msg "1) upgrade packages of $CHANNEL_OLD"  # always upgrade here!
	msg "2) dry run: $VERB packages to $CHANNEL_NEW"
	msg "3) $VERB packages to $CHANNEL_NEW"
	msg "4) prompt to reboot"
	msg
	msg "A log and backup of your /etc/apk/repositories will be in:"
	msg "$VAR_LIB_DIR"
	msg
	msg "It is strongly recommended to do this via SSH and in tmux/screen."
	msg "More information: https://postmarketos.org/upgrade"
	msg
	msg "This $VERB should work fine, but in the worst case your device"
	msg "may not boot anymore. Make backups of important data first!"
	msg
	msg "Make sure to read the release blog post of $CHANNEL_NEW, it may"
	msg "have a 'known issues' section: https://postmarketos.org/blog/"
	msg
	msg "(Script version: $SCRIPT_VERSION)"
	msg

	printf "$GREEN>>>$NORMAL Proceed with %s to %s? [y/N] " "$VERB" "$CHANNEL_NEW"

	read -r answer
	if [ "$answer" != "y" ]; then
		err_exit "Aborted"
	fi
}

set_exit_error_trap() {
	trap exit_error EXIT INT TERM 0
}

unset_exit_error_trap() {
	trap - EXIT INT TERM 0
}

step_upgrade_packages_current() {
	STEP=1
	if [ "$VERB" = "downgrade" ] ; then
		msg_step "Skip upgrade packages of $CHANNEL_OLD due to $VERB"
		return
	fi
	msg_step "upgrade packages of $CHANNEL_OLD"
	apk upgrade -a
}

check_upgrade_script_changed() {
	if [ "$UPGRADE_SCRIPT_LAST_MODIFIED" = "$(stat -c "%Y" "$0")" ]; then
		return
	fi

	msg "A new version of postmarketos-release-upgrade has been installed."
	msg "You are still on $CHANNEL_OLD."
	msg "Please run postmarketos-release-upgrade again."

	unset_exit_error_trap
	exit 0
}

replace_apk_repositories () {
	local old="$VAR_LIB_DIR/etc-apk-repositories.old"
	local new="$VAR_LIB_DIR/etc-apk-repositories.new"

	cp /etc/apk/repositories "$old"

	sed \
		-e "s#/postmarketos/$MIRRORDIR_OLD_PMOS#/postmarketos/$MIRRORDIR_NEW_PMOS#g" \
		-e "s#/$MIRRORDIR_OLD_ALPINE/#/$MIRRORDIR_NEW_ALPINE/#g" \
		"$old" > "$new"

	# Add or remove testing repository (pmOS edge may depend on packages in
	# Alpine testing, hence always add it when upgrading to edge)
	if [ "$CHANNEL_NEW" = "edge" ]; then
		grep '/edge/community' "$new" \
			| sed 's#/edge/community#/edge/testing#' \
			> "${new}.testing"
		cat "${new}.testing" >> "$new"
		rm "${new}.testing"
	elif [ "$CHANNEL_OLD" = "edge" ]; then
		sed -i "/\/$MIRRORDIR_NEW_ALPINE\/testing/d" "$new"
	fi

	if [ "$(cat "$old")" = "$(cat "$new")" ]; then
		err "$new and $old have the same content"
		err_exit "Your /etc/apk/repositories file has not been changed"
	fi

	cp "$new" /etc/apk/repositories
}

step_upgrade_packages_new_dry_run() {
	STEP=2
	msg_step "dry run: $VERB packages to $CHANNEL_NEW"
	msg "Replacing /etc/apk/repositories..."
	msg "Old /etc/apk/repositories:"
	cat /etc/apk/repositories

	replace_apk_repositories

	msg "New /etc/apk/repositories:"
	cat /etc/apk/repositories

	msg "Installing apk-tools-static from $CHANNEL_NEW"
	if ! apk update || ! apk add -u --virtual "$VIRTUAL_NAME" apk-tools-static; then
		err_restore_apk_repos_exit "Installing apk-tools-static failed"
	fi

	msg "Running 'apk update' and 'apk upgrade -a --simulate'..."
	if ! apk.static update || ! apk.static upgrade -a --simulate; then
		err_restore_apk_repos_exit "Dry run failed"
	fi
	msg "Dry run successful"
}

verify_channel_after_upgrade() {
	# shellcheck disable=SC2155
	local channel_after_upgrade="$(get_channel_current)"

	msg "Verifying channel in /etc/os-release..."

	if [ "$channel_after_upgrade" != "$CHANNEL_NEW" ]; then
		err "Unexpected channel found in /etc/os-release: '$channel_after_upgrade'"
		err "Full contents of /etc/os-release:"
		cat /etc/os-release
		exit 1
	fi
}

try_apk_fix() {
	msg "Running 'apk fix'..."
	apk.static fix
}

step_upgrade_packages_new() {
	STEP=3
	msg_step "$VERB packages to $CHANNEL_NEW"
	msg "Running 'apk upgrade -a'..."
	apk.static upgrade -a || try_apk_fix

	verify_channel_after_upgrade

	msg "Cleaning up..."
	# Remove the virtual package that depends on apk-tools-static, so
	# apk-tools-static gets removed unless the user already had it
	# installed before running this script.
	apk del "$VIRTUAL_NAME"
}

step_reboot_prompt() {
	unset_exit_error_trap

	STEP=4
	msg_step "Reboot prompt"
	msg
	msg "Your system is in a weird state between $CHANNEL_OLD and $CHANNEL_NEW now."
	msg "All bugs are features until rebooted. If you know what you are"
	msg "doing and don't want to reboot, press ^C."
	msg
	msg "Press return when you are ready to reboot."

	touch "$VAR_LIB_DIR"/success

	read -r answer

	if [ -e /in-pmbootstrap ]; then
		warn "Running in pmbootstrap, skipping reboot."
		msg "$VERB successful! \o/"
	else
		msg "Rebooting..."
		reboot
	fi
}

exit_error() {
	unset_exit_error_trap

	err "Release $VERB failed!"
	err
	err "Find the log and backup of /etc/apk/repositories in:"
	err "$VAR_LIB_DIR"
	err
	err "If this is a simple packaging error, running 'apk fix' may help."
	err
	err "If apk failed to install packages (e.g. due to network outage),"
	err "running 'apk upgrade -a' again may help."
	err
	err "If you need help now, consider joining the postmarketOS chat."
	err "Some nice folks hang out there in their free time and may help"
	err "you out: https://postmarketos.org/chat"
	err
	err "The wiki page for release upgrades may also have further"
	err "troubleshooting information: https://postmarketos.org/upgrade"

	if [ -n "$UPGRADE_IS_SUPPORTED" ]; then
		err
		err "If this looks like a bug (i.e. no network outage etc.):"
		err "please check if there's an existing bug report for this, and if"
		err "not, then report a new bug at:"
		err "https://gitlab.postmarketos.org/postmarketOS/postmarketos-release-upgrade/"
	fi

	exit 1
}

set_colors
get_channel_latest_stable
show_usage "$1"
check_is_root
init_log "$0" "$@"
check_is_pmos
check_boot_size
check_mrtest
check_hardcoded_versions
get_channels
check_channels_valid
check_channels_is_downgrade
check_channels_upgrade_supported
check_edge_warning
get_mirrordirs
confirm_upgrade
set_exit_error_trap

step_upgrade_packages_current
check_upgrade_script_changed
step_upgrade_packages_new_dry_run
step_upgrade_packages_new
step_reboot_prompt
