#!/usr/bin/env bash

# librerelease - uploads packages to the repo server and publishes them
#
# Copyright (C) 2010-2012 Joshua Ismael Haase Hernández (xihh) <hahj87@gmail.com>
# Copyright (C) 2010-2013 Nicolás Reynolds <fauno@parabola.nu>
# Copyright (C) 2013 Michał Masłowski <mtjm@mtjm.eu>
# Copyright (C) 2013-2014, 2017-2018, 2024 Luke T. Shumaker <lukeshu@parabola.nu>
# Copyright (C) 2019-2020, 2022-2024 Bill Auger <mr.j.spam.me@gmail.com>
#
# For just the create_signature() function:
#   Copyright (C) 2006-2013 Pacman Development Team <pacman-dev@archlinux.org>
#   Copyright (C) 2002-2006 Judd Vinet <jvinet@zeroflux.org>
#   Copyright (C) 2005 Aurelien Foret <orelien@chez.com>
#   Copyright (C) 2006 Miklos Vajna <vmiklos@frugalware.org>
#   Copyright (C) 2005 Christian Hamar <krics@linuxforum.hu>
#   Copyright (C) 2006 Alex Smith <alex@alex-smith.me.uk>
#   Copyright (C) 2006 Andras Voroskoi <voroskoi@frugalware.org>
#
# License: GNU GPLv3+
#
# This file is part of Parabola.
#
# Parabola 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 3 of the License, or
# (at your option) any later version.
#
# Parabola 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 Parabola. If not, see <http://www.gnu.org/licenses/>.

# create_signature() is taken from pacman:makepkg, which is GPLv2+,
# so we take the '+' to combine it with our GPLv3+.

set -euE

source "$(librelib conf)"     # LIBREUSER, load_conf()
source "$(librelib messages)" # setup_traps(), msg(), msg2() error(), print(), prose(), flag()

declare -ri STAGING_LOCK=8
declare -ra RSYNC_FLAGS=(
	--no-group
	--no-perms
	--copy-links
	--hard-links
	--partial
	--human-readable
	--progress
)
DRY_RUN=''       # main()
UPLOAD_ONLY=''   # main()
TIER0_HOST=''    # main()
TIER0_STAGING='' # main()
TIER0_LOGIN=''   # main()
TIER0_PORT=''    # main()
SSH_CMD=()       # main()
RSYNC_DEST=''    # main()

## helpers ##

lock_staging() {
	lock $STAGING_LOCK "${WORKDIR}/staging.lock" \
		"Waiting for an exclusive lock on the staging directory"
}

unlock_staging() {
	lock_close $STAGING_LOCK
}

list0_files() {
	find -L "${WORKDIR}/staging" -type f -not -name '*.lock' \
		-exec realpath -z --relative-to="${WORKDIR}/staging" {} + | sort -z
}

# This function is taken almost verbatim from makepkg
create_signature() {
	local filename="$1"
	msg "Signing package..."

	local SIGNWITHKEY=()
	if [[ -n $GPGKEY ]]; then
		SIGNWITHKEY=(-u "${GPGKEY}")
	fi

	if gpg --detach-sign --use-agent "${SIGNWITHKEY[@]}" \
		--no-armor "$filename" &>/dev/null; then
		msg2 "Created signature file %s." "$filename.sig"
		return $EXIT_SUCCESS
	else
		error "Failed to sign package file."
		return $EXIT_FAILURE
	fi
}

sign_packages() {
	IFS=$'\n'
	local files=($(find "${WORKDIR}/staging/" -type f -not -iname '*.sig' -print))
	local file
	for file in "${files[@]}"; do
		if [[ -f "${file}.sig" ]]; then
			msg2 "File signature found, verifying..."

			# Verify that the signature is correct, else remove for re-signing
			if ! gpg --quiet --verify "${file}.sig" >/dev/null 2>&1; then
				error "Failed!  Re-signing..."
				rm -f "${file}.sig"
			fi
		fi

		if ! [[ -f "${file}.sig" ]]; then
			create_signature "$file" || return
		fi
	done
}

# Clean everything if not in dry-run mode
clean_files() (
	local file_list=$1
	local rmcmd

	if [[ -z $DRY_RUN ]]; then
		rmcmd=(rm -fv)
	else
		rmcmd=(printf "$(_ "removed '%s' (dry-run)")\n")
	fi

	msg "Removing files from local staging directory"
	cd "${WORKDIR}/staging"
	xargs -0r -a "$file_list" "${rmcmd[@]}"
	find . -depth -mindepth 1 -type d \
		-exec rmdir --ignore-fail-on-non-empty -- '{}' +
)

## The different modes ##

usage() {
	print "Usage: %s [OPTIONS]" "${0##*/}"
	print 'Upload locally-staged files to the configured repo server.'
	echo
	prose 'librerelease signs any unsigned files in your local staging
	       directory (`libretools.conf:${WORKDIR}/staging`, (presumably
	       placed there by `librestage`), uploads all staged files to the
	       libretools.conf:TIER0_{LOGIN,HOST,PORT,STAGING} server, cleans
	       your local staging directory, and then on the server calls
	       `db-update` on any package files among them.'
	echo
	prose 'This requires the `gpg` program configured with your GPG key,
	       either with the GPGKEY environment variable or with
	       makepkg.conf(5):GPGKEY.'
	echo
	prose 'The default libretools.conf assumes that your hackers.git
	       username is the same as your local username.  If this is not the
	       case, you will need to set libretools.conf:TIER0_LOGIN to your
	       hackers.git username; either in the global /etc/libretools.conf
	       or in your personal ~/.config/libretools/libretools.conf.'
	echo
	prose 'librerelease makes several SSH calls to the
	       libretools.conf:TIER0_HOST server; if your SSH key requires
	       authentication to use (as it should!), this can be tedious.  We
	       have two recommended solutions:'
	bullet 'either configure an ssh-agent to cache the authentication, or'
	bullet 'set `ControlMaster` and `ControlPath` in your ~/.ssh/config so
	        that connections can be reused, and then uncomment the
	        HOOKPRERELEASE line in /etc/libretools.conf to have librerelease
	        create a background connection that all of its calls can reuse.'
	echo
	print "Options:"
	flag \
		'Settings:' \
		'-n' "Dry-run: don't actually do anything" \
		'-u' "Upload-only: do not run db-update on the server" \
		'Alternate modes:' \
		'-c' "Clean Local: delete packages in local staging directory" \
		'-C' "Clean Remote: delete packages in remote staging directory" \
		'-l' "List: list packages but not upload them" \
		'-h, --help' "Help: Show this message"
}

list() {
	find "$WORKDIR/staging/" -mindepth 1 -maxdepth 1 -type d -not -empty | sort |
		while read -r path; do
			msg2 "${path##*/}"
			cd "$path"
			find -L . -type f -not -name '*.lock' | sed 's|^\./|     |' | sort
		done
}

clean_local() {
	lock_staging

	local file_list
	file_list="$(mktemp -t "${0##*/}.XXXXXXXXXX")"
	trap "rm -f -- ${file_list@Q}" EXIT
	list0_files >"$file_list"

	clean_files "$file_list"

	unlock_staging
}

clean_remote() {
	msg "Removing files from remote staging directory"
	"${SSH_CMD[@]}" "rm -rfv ${TIER0_STAGING@Q}/*"
}

release() {
	local file_list dbupdate_log
	file_list="$(mktemp -t ${0##*/}_lst.XXXXXXXXXX)"
	dbupdate_log="$(mktemp -t ${0##*/}_log.XXXXXXXXXX)"
	trap "rm -f -- ${file_list@Q} ${dbupdate_log@Q}" INT RETURN TERM

	## prepare ##

	msg "Running HOOKPRERELEASE..."
	local hookcmd
	for hookcmd in "${HOOKPRERELEASE[@]}"; do
		(
			PS4="   \\[$BOLD\\]\$\\[$ALL_OFF\\] "
			eval -- "set -x; $hookcmd"
		)
	done

	lock_staging

	sign_packages || return $EXIT_FAILURE

	# collect staged files and set permissions for repository-bound files
	list0_files >"$file_list"
	find "${WORKDIR}/staging" -type f -exec chmod 644 {} +
	find "${WORKDIR}/staging" -type d -exec chmod 755 {} +

	# prepare remote staging directory tree
	local upload_size
	upload_size="$(cd "${WORKDIR}/staging" && du -hc --files0-from="$file_list" | sed -n '$s/\t.*//p')"
	msg "%s to upload" "$upload_size"

	xargs -0r -a "$file_list" dirname -z | "${SSH_CMD[@]}" \
		"mkdir -p -- ${TIER0_STAGING@Q} && cd ${TIER0_STAGING@Q} && xargs -0r mkdir -pv --"

	## upload ##

	msg "Uploading packages..."
	if ! rsync ${DRY_RUN} "${RSYNC_FLAGS[@]}" \
		-e "ssh ${TIER0_PORT:+-p $TIER0_PORT}" \
		-0 --files-from="$file_list" \
		"${WORKDIR}/staging" \
		"$RSYNC_DEST"; then
		error "Sync failed, try again"
		return $EXIT_FAILURE
	fi

	clean_files "$file_list"

	unlock_staging

	if $UPLOAD_ONLY; then
		return $EXIT_SUCCESS
	fi

	## publish ##

	msg "Running db-update on repos"
	(
		# this contraption allows detecting `db-update` exit failure,
		# while logging output both to file and to the local shell in real-time,
		# while preserving (restoring) colors lost in the pipeline
		set -o pipefail
		"${SSH_CMD[@]}" "STAGING=${TIER0_STAGING@Q} DBSCRIPTS_CONFIG=${DBSCRIPTS_CONFIG@Q} db-update" |
			tee "$dbupdate_log" |
			while read line; do
				if [[ $line =~ ^==\> ]]; then
					msg "${line#==\> }"
				elif [[ $line =~ ^\ *-\> ]]; then
					msg2 "${line#*\> }"
				else
					echo "$line"
				fi
			done
	)

	msg "Running HOOKPOSTRELEASE..."
	for hookcmd in "${HOOKPOSTRELEASE[@]}"; do
		(
			PS4="   \\[$BOLD\\]\$\\[$ALL_OFF\\] "
			eval -- "set -x; $hookcmd"
		)
	done
}

## main entry ##

main() {
	# Parse CLI options
	local mode=release # publish packages to public repo (default)
	UPLOAD_ONLY=false  # upload and publish
	local args
	if ! args="$(getopt -n "${0##*/}" -o 'cChlnu' -l 'help' -- "$@")"; then
		mode=errusage
	else
		eval "set -- $args"
		local flag
		while true; do
			flag=$1
			shift
			case "$flag" in
				-c) [[ $mode == usage ]] || mode=clean_local ;;  # empties local staging area
				-C) [[ $mode == usage ]] || mode=clean_remote ;; # empties remote staging area
				-h | --help) mode=usage ;;                       # print 'Usage' message
				-l) [[ $mode == usage ]] || mode=list ;;         # pretty-print locally-staged packages
				-n) DRY_RUN='--dry-run' ;;                       # only show what would be done
				-u) UPLOAD_ONLY=true ;;                          # upload, but do not publish
				--) break ;;
				*) panic 'unhandled flag: %q' "$flag" ;;
			esac
		done
		if [[ $mode != usage && $# != 0 ]]; then
			gnuerror 'does not take any positional arguments, got %d' "$#"
			mode=errusage
		fi
	fi
	case "$mode" in
		errusage)
			print "Try '%s --help' for more information." "${0##*/}" >&2
			exit $EXIT_INVALIDARGUMENT
			;;
		usage)
			usage
			exit $EXIT_SUCCESS
			;;
		release | clean_local | clean_remote | list) : ;;
		*) panic 'invalid mode: %q' "$mode" ;;
	esac

	if [[ -w / ]]; then
		error "This program should be run as unprivileged user"
		return $EXIT_NOPERMISSION
	fi

	# Load makepkg and libretools configuration files.
	#
	# The variables listed in the `load_conf` commands will be
	# used in this script, and so are mandatory (`load_conf` will
	# print an error message and return $EXIT_NOTCONFIGURED if
	# they aren't set).
	#
	# Additionally, libretools.conf may contain a few variables
	# that are optional for this script: TIER0_LOGIN, TIER0_PORT,
	# TIER0_STAGING, HOOKPRERELEASE, HOOKPOSTRELEASE
	#
	# Save the exit code and wait until after loading all config
	# files before exiting, in order to provide as much feedback
	# to the user as possible.
	declare -i ret=0
	load_conf makepkg.conf GPGKEY || ret=$?
	load_conf libretools.conf WORKDIR TIER0_HOST DBSCRIPTS_CONFIG || ret=$?
	[[ $ret -eq 0 ]] || exit $ret

	# validate/sanitize tier-0 repo URL components
	if [[ $TIER0_STAGING == '/~/'* ]]; then
		TIER0_STAGING=${TIER0_STAGING#'/~/'}
	elif [[ $TIER0_STAGING == '/~'* ]]; then
		error "Unfortunately, tilde expansion ('~' home directory) is not supported in libretools.conf::TIER0_STAGING"
		return $EXIT_NOTCONFIGURED
	fi

	# finalize state
	readonly DRY_RUN
	readonly UPLOAD_ONLY

	# construct the SSH and rsync destination parameters
	readonly TIER0_LOGIN
	readonly TIER0_HOST
	readonly TIER0_PORT
	readonly TIER0_STAGING=${TIER0_STAGING:-/home/${TIER0_LOGIN:-$LIBREUSER}/staging}
	readonly SSH_CMD=(ssh ${TIER0_PORT:+-p "$TIER0_PORT"} "${TIER0_LOGIN:+${TIER0_LOGIN}@}${TIER0_HOST}")
	readonly RSYNC_DEST="${TIER0_LOGIN:+${TIER0_LOGIN}@}${TIER0_HOST}:${TIER0_STAGING%/}/"

	# do the requested business
	$mode
}

setup_traps
main "$@"
