#!/bin/sh
# vim: set ts=4 sw=4 ft=sh:
#---help---
# Usage: apk-deploy-pkg [options]
#
# Read APK package from STDIN, install it and optionally restart the specified
# services. If anything fails, perform a rollback to the previous version.
#
# Options:
#   -f --overwrite-configs
#       Overwrite any local changes in /etc files provided by
#       the installed package.
#
#   -r --restart <svcname>[:<cmd>][,...]
#       A comma-separated list of services to restart after installing the
#       package. The service name may be a glob matching services in runlevels
#       default and boot, or "@own" which will select all services installed by
#       this package. Each service name may be followed with a colon and the
#       init script's command to execute (default is "restart"). If the command
#       cannot be executed (e.g. service is not running), service is restarted
#       first, then the command is executed unless it's "restart" or "reload".
#
#   -V --version
#       Print the program version and exit.
#
#   -h --help
#       Show this message and exit.
#
# Exit Codes:
#   1   Generic error code.
#   2   Invalid usage.
#   3   Pre-installation check failed.
#   4   Service to be restarted doesn't exist.
#   10  Installation failed and rollback failed too.
#   11  Service restart failed and rollback failed too.
#   20  Installation failed, but rollback was successfull.
#   21  Service restart failed, but rollback was successfull.
#
# Homepage: <https://github.com/jirutka/apk-deploy-tool>
#---help---
set -eu -o pipefail
set -f  # disable globbing

if [ $(id -u) -ne 0 ]; then
	if command -v doas >/dev/null; then
		exec doas -u root -- "$0" "$@"
	else
		exec sudo -u root -- "$0" "$@"
	fi
fi

readonly PROGNAME='apk-deploy-pkg'
readonly VERSION='0.5.3'

readonly DEPLOY_HOME=$(getent passwd "${DOAS_USER:-$SUDO_USER}" | cut -d: -f6)
readonly PKGS_DIR="${DEPLOY_HOME:?}/packages"


log() {
	local level="$1"
	local msg="$2"
	local prefix=''

	case "$level" in
		error) echo "ERROR: $msg" >&2;;
		*) printf '%s\n' "$msg";;
	esac
	logger -t "$PROGNAME" -p "local0.$level" "$msg"
}

die() {
	log error "$2"
	exit "$1"
}

help() {
	sed -n '/^#---help---/,/^#---help---/p' "$0" | sed 's/^# \?//; 1d;$d;'
}

expand_restart_list() {
	local restart_list="$1"
	local pkgname="$2"
	local cmd item svcname svcnames

	for item in $(echo "$restart_list" | tr ',' ' '); do
		svcname=${item%%:*}
		cmd='restart'
		[ "$item" = "$svcname" ] || cmd=${item#*:}

		case "$svcname" in
			'@own')
				svcnames=$(pkg_svcnames "$pkgname") \
					|| die 4 "This package doesn't provide any services!"
			;;
			*'*'* | *'['* | *'?'*)
				svcnames=$(find_enabled_services "$svcname") \
					|| die 4 "No service matching \"$svcname\" found in default or boot runlevel!"
			;;
			*)
				svcnames=$(rc-service --list | grep -Fx "$svcname") \
					|| die 4 "Service \"$svcname\" doesn't exist!"
			;;
		esac

		for svcname in $svcnames; do
			echo "$svcname:$cmd"
		done
	done
}

pkginfo_get() {
	local field="$1" apkfile="$2"
	tar -Oxzf "$apkfile" .PKGINFO | sed -En "s/^$field = (.*)/\1/p"
}

pkg_svcnames() {
	local pkgname="$1"
	apk info -Lq "$pkgname" | grep '^etc/init\.d/' | grep -o '[^/]\+$'
}

check_configs() {
	local pkgname="$1"
	local overwrite="$2"

	apk info -Lq "$pkgname" | grep '^etc/' | while read path; do
		path="/$path"
		[ -f "$path.apk-new" ] || continue

		diff -u "$path" "$path.apk-new" || true
		[ "$overwrite" = no ] || mv "$path.apk-new" "$path"
	done
}

find_enabled_services() {
	local svcname="$1"

	find /etc/runlevels/boot /etc/runlevels/default \
		-name "$svcname" -type l -exec basename {} \; | grep .
}

is_extra_started_command() {
	local svcname="$1"
	local cmd="$2"
	(
		set +eu
		. /etc/init.d/$svcname >/dev/null
		printf '%s\n' ${extra_started_commands:-}
	) | grep -qFx "$cmd"
}

rc_service() {
	local svcname="${1%%:*}"
	local cmd="${1#*:}"

	# If the <cmd> can be executed only when the service is started and it's
	# not, (re)start it before executing the <cmd>.
	# NOTE: --dry-run doesn't work for extra commands.
	if [ "$cmd" != 'restart' ] \
		&& ! rc-service -q "$svcname" status \
		&& is_extra_started_command "$svcname" "$cmd";
	then
		rc-service "$svcname" restart || return 1

		# Don't reload after restart
		[ "$cmd" = 'reload' ] && return 0
	fi

	rc-service "$svcname" "$cmd"
}


opts=$(getopt -n $PROGNAME -o fVh -l overwrite-configs,restart:,version,help -- "$@") || exit 2
eval set -- "$opts"

overwrite=no
restart_svcs=
while [ $# -gt 0 ]; do
	n=2
	case "$1" in
		-f | --overwrite-configs) overwrite=yes; n=1;;
		-r | --restart) restart_svcs="$restart_svcs$2,";;
		-V | --version) echo "$PROGNAME $VERSION"; exit 0;;
		-h | --help) help; exit 0;;
		--) shift; break;;
	esac
	shift $n
done

if [ "$restart_svcs" ] && ! expr "$restart_svcs" : '[][a-zA-Z0-9.:,_*@?-]*$' >/dev/null; then
	die 2 'Option --restart contains forbidden character(s)!'
fi

tmpdir=$(mktemp -d)
pkgfile="$tmpdir/pkg.apk"
trap "rm -Rf '$tmpdir'" EXIT HUP INT TERM

echo 'Reading apk package from stdin...'
cat > "$pkgfile"
mkdir -p "$PKGS_DIR"

pkgname=$(pkginfo_get pkgname "$pkgfile")
pkgver=$(pkginfo_get pkgver "$pkgfile")
pkgfile_old=$(find "$PKGS_DIR" -name "$pkgname~*.apk" | sort | tail -n 1)
pkgfile="$PKGS_DIR/$pkgname~$(date +%s).apk"

mv "$tmpdir/pkg.apk" "$pkgfile"

log info "Checking $pkgname-$pkgver dependencies..."
apk add --simulate --quiet "$pkgfile" || die 3 'Dry-run installation failed!'

log info "Installing $pkgname-$pkgver..."
apk add --no-progress "$pkgfile" || {
	[ "$pkgfile_old" ] || die 10 'Installation failed and no previous version for rollback exists!'

	log error 'Installation failed, rolling back to the previous version...'
	apk add --no-progress "$pkgfile_old" || die 10 'Rollback failed'
	exit 20
}

check_configs "$pkgname" "$overwrite"

if [ "$restart_svcs" ]; then
	log info 'Restarting services...'

	restart_svcs=$(expand_restart_list "$restart_svcs" "$pkgname")
	restarted_svcs=''

	for svc in $restart_svcs; do
		restarted_svcs="$restarted_svcs $svc"

		rc_service "$svc" || {
			svcname=${svc%%:*}

			[ "$pkgfile_old" ] \
				|| die 11 "Service $svcname failed and no previous version for rollback exists!"

			log error "Service $svcname failed, rolling back to the previous version..."
			apk add --no-progress "$pkgfile_old" || die 11 'Rollback failed'

			failed=no
			for svc in $restarted_svcs; do
				rc_service "$svc" || failed=yes
			done
			[ "$failed" = no ] || die 11 'Rollback failed'

			exit 21
		}
	done
fi

# Delete old versions of the package.
find "$PKGS_DIR" -name "$pkgname~*.apk" -a ! -name "${pkgfile##*/}" -delete
