#!/bin/sh
# Copyright (c) 2013,2015-2019 Guillaume Outters
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

# Peut être pour fiabiliser à la fois intégré à un cron et déclenché sur changement de fichiers.

# NOTE: multi
# on pourrait faire du multi-postage en parallèle (for i in *.conf.flute ; do faireConf $i & ; done) si:
# - le verrou à la source était verrouillable une fois pour toutes (sinon, si chaque instance essaie de le verrouiller pour elle-même, la première l'obtient, les autres bloquent, donc on a perdu la concurrence)
# - symétriquement, il ne serait relâché qu'une fois la dernière instance terminée
# - dans une instance, l'échec d'obtention du verrou distant n'annulerait pas le verrou local
# - si des fichiers sont à réécrire à la source (des .deja et autres), les instancent doivent se synchroniser pour le faire séquentiellement

DELAI_MAX=20 # Combien de minutes est-on prêts à attendre qu'un verrou se relâche avant d'abandonner?
VERROU_VIF=20 # À partir de combien de secondes d'inactivité un verrou est-il considéré caduque (non maintenu)? On estime qu'il s'agit aussi du délai maximal de mise en route (entre la création du verrou et le lancement du rsync qui l'emprunte).

set -e

SCRIPTS="`command -v "$0"`" ; [ -x "$SCRIPTS" -o ! -x "$0" ] || SCRIPTS="$0" ; case "`basename "$SCRIPTS"`" in *.*) true ;; *sh) SCRIPTS="$1" ;; esac ; case "$SCRIPTS" in /*) ;; *) SCRIPTS="`pwd`/$SCRIPTS" ;; esac ; delie() { while [ -h "$SCRIPTS" ] ; do SCRIPTS2="`readlink "$SCRIPTS"`" ; case "$SCRIPTS2" in /*) SCRIPTS="$SCRIPTS2" ;; *) SCRIPTS="`dirname "$SCRIPTS"`/$SCRIPTS2" ;; esac ; done ; } ; delie ; MOI="$SCRIPTS" ; SCRIPTS="`dirname "$SCRIPTS"`" ; delie

proff() { cat ; }
[ ! -f "$SCRIPTS/../texte/proff" ] || . "$SCRIPTS/../texte/proff"

#= Couleurs =#
couleur() { local couleur="$1" ; shift ; echo "[${couleur}m$*[0m" ; }
info() { couleur 90 "$@" ; }
alerte() { couleur 33 "$@" ; }
erreur() { couleur 31 "$@" ; }
tvb() { couleur 32 "$@" ; }
#- Couleurs -#
COULEURS="`sed -e '/^#= Couleurs/,/^#- Couleurs/!d' < "$MOI"`"

# Bute récursivement toute une sous-arbo de processus (sans avoir besoin de taper tout le groupe).
bute()
{
	# Inspiration piochée sur:
	# https://stackoverflow.com/a/26966800
	[ -z "$*" ] && return || true
	bute `IFS=\| ; ps axwwo ppid,pid | egrep "^ *($*) " | awk '{print$2}'`
	kill "$@"
}

uucommande()
{
	(
		echo "set -e"
		printf "commande='"
		(
			printf "%s" "$1"
			shift
			while [ $# -gt 0 ]
			do
				printf "\003%s" "$1"
				shift
			done
		) | sed -e "s/'/'\\\\''/g" -e "\$s/$/'/" # Certains sed tenant à ajouter une dernière fin de ligne, autant que nous y mettions l'apostrophe fermante.
		echo # Pour les sed qui gentiment n'ont pas inséré une fin de ligne sur la précédente.
		printf 'IFS="\003"\n'
		echo '$commande'
	) | uuencode -m .exec.flute
}

adest()
{
	local nohup=non
	[ "x$1" = "x--nohup" ] && shift && nohup=oui || true
	if echo "$dest" | grep -q '^[^/]*:'
	then
		local hote="`echo "$dest" | cut -d : -f 1`"
		local dossier="`echo "$dest" | cut -d : -f 2-`"
		local commande="`uucommande "$@"`"
		local exe="sh .exec.flute"
		[ $nohup = non ] || exe="nohup $exe < /dev/null > /dev/null 2>&1 &"
		( ssh "$hote" "cd \"$dossier\" ; echo '$commande' | uudecode ; $exe" )
	elif [ $nohup = oui ]
	then
		( cd "$dest" && nohup "$@" < /dev/null > /dev/null 2>&1 & )
	else
		( cd "$dest" && "$@" )
	fi
}

url()
{
	local url="$1"
	if echo "$url" | grep -q '^[^/]*:'
	then
		echo "$url"
	else
		local serveur="`id -u -n`@`hostname`" chemin="$url"
		case "$chemin" in
			/*) true ;;
			*) chemin="`pwd`/$chemin" ;;
		esac
		case "$chemin" in
			"$HOME") chemin=. ;;
			"$HOME"/*) chemin="`echo "$chemin" | sed -e "s#^$HOME/##"`" ;;
		esac
		echo "$serveur:$chemin"
	fi
}

commentaireDate()
{
	sed -e "1i\\
# `date "+%Y-%m-%d %H:%M:%S"` $*
"
}

asource()
{
	( cd "$source" && "$@" )
}

internes()
{
	cat <<TERMINE
- /.*.flute
- /.*.??????
- /**/.*.??????
TERMINE
}

pondreDeverrouiller()
{
	echo "#!/bin/sh"
	echo "$COULEURS"
	
	case "$1" in
		source)
			cat <<TERMINE
if ps axo pid,command | egrep -q '^[ 	]*$$[ 	][ 	]*([^ 	]*sh )?[^ 	]*flute '
then
	[ -z "\$1" ] || info "(deverrouiller \$*: verrouillé par la tâche locale $$ en cours d'exécution)" >&2
	exit 1
fi
TERMINE
			;;
		dest)
			cat <<TERMINE
# -i: l'appelant souhaite que nous lui répondions si nous avons été créés par lui.
if [ "x\$1" = x-i ]
then
	appelant="\$2"
	shift ; shift
	if [ \$appelant = $idarbofactice ]
	then
		exit 0
	fi
fi
if ps axo command | grep -q '^[^ ]*rsync .*--link-dest .verrou.flute/$idarbofactice/'
then
	[ -z "\$1" ] || info "(\$1: verrouillé par la tâche distante $idarbofactice en cours d'exécution)" >&2
	exit 1
fi
TERMINE
			;;
	esac
	
	cat <<TERMINE
[ -z "\$1" ] || info "\$1: verrou caduque (la tâche qui l'a posé ne tourne plus); on le supprime." >&2
# Si le rsync n'existe plus, alors ce ./deverrouiller est caduque. Afin qu'il ne soit plus appelé, on marque le dossier comme "en cours de constitution" ("donc ne cherchez pas ./deverrouiller, il n'existe peut-être pas ou peut-être pas complètement").
[ ! -d .verrou.flute ] || touch .verrou.flute
exit 0
TERMINE
}

verrouiller()
{
	local ou="$1"
	local libelle="$2"
	[ $g_trace -lt 3 ] || info "[INFO] $$ verrouille $*" >&2
	$ou sh -c \
	"
		$COULEURS
		
		[ ! -d .verrou.flute ] && mkdir .verrou.flute && exit 0
		
		# Si le verrou existe, est-il récent (en cours de création, donc potentiellement sans deverrouiller mais pourtant à ne pas supprimer)?
		if find .verrou.flute -maxdepth 0 -mtime -${VERROU_VIF}s | grep -q .
		then
			[ -z \"$libelle\" ] || info \"($libelle: verrou posé par une tâche concurrente encore active il y a peu)\" >&2
			exit 1
		fi
		# S'il peut être considéré comme définitif, il est censé contenir un script vérifiant s'il (le verrou) est encore exploité.
		if [ -x .verrou.flute/deverrouiller ] && ! .verrou.flute/deverrouiller \"$libelle\"
		then
			[ -z \"$libelle\" ] || info \"($libelle: verrou maintenu)\" >&2
			exit 1
		fi
		exit 0
	fi
	"
}

biverrou()
{
	# NOTE: Verrouillage
	# Un dossier peut être verrouillé, aussi bien comme source que comme destination. Chaque typologie de verrou embarque son déverrouilleur:
	# - par PID pour le source (si ce PID tourne encore au moment de la tentative de déverrouillage, c'est qu'il bosse)
	# - par option passée à rysnc pour la destination (si un ps | grep cette option voit quelque chose, c'est qu'il bosse)
	verrouiller asource "à la source ($source)" || return 1
	if ! verrouiller adest "à la cible ($dest)"
	then
		# Bon c'est foutu: on sort, non sans avoir libéré notre verrou local.
		rm -R "$source/.verrou.flute"
		return 1
	fi
	# On a le verrou! On y prépare notre possible "mort trop brutale pour faire le ménage".
	pondreDeverrouiller source > "$source/.verrou.flute/deverrouiller" && chmod a+x "$source/.verrou.flute/deverrouiller"
	pondreDeverrouiller dest | adest sh -c "cat > .verrou.flute/deverrouiller && chmod a+x .verrou.flute/deverrouiller && mkdir .verrou.flute/$idarbofactice/"
}

semanifester()
{
	[ $g_trace -lt 3 ] || info "[INFO] $$ lance son manifestant (`sh -c 'echo $PPID'`)" >&2
	local intervalle="`expr $VERROU_VIF \* 3 / 4`"
	while true
	do
		while true
		do
			echo
			sleep $intervalle
		done | adest sh -c "while read rien ; do [ ! -d .verrou/flute ] || touch .verrou.flute ; done" 2> /dev/null # Redirection: pour éviter entre autres le très disgracieux "Broken pipe" lorsque l'on tuera notre manifestant.
	done
}

faire()
{
	[ $g_trace -lt 3 ] || alerte "[INFO] faire $*" >&2
	local prefixe=
	[ "x$1" = x-p ] && shift && prefixe="$1" && shift || true
source="$1"
dest="$2"
	
	# Tentative d'acquérir le verrou.
	# Nécessaire car on n'est peut-être pas le seul à alimenter la destination… Voire elle est en train d'alimenter une de ses propres cibles, voire même c'est peut-être nous sa cible.
	
	local tmax="`date +%s`"
	tmax="`expr $tmax + \( $DELAI_MAX \* 60 \)`"
	
	idarbofactice="`hostname`.$$"
	
	while ! biverrou
	do
		if [ `date +%s` -gt $tmax ]
		then
			alerte "# Impossible d'acquérir le verrou au bout de $DELAI_MAX mn." >&2
			return 3
		fi
		sleep $VERROU_VIF
	done
	trap menage0 EXIT
	trap menage1 INT TERM
	
	semanifester < /dev/null > /dev/null 2>&1 &
	manifestant=$!
	
	_faire
	
	deverrouiller
	
	# Si des choses se sont passées, on peut déclencher la réaction en chaîne.
	# N.B. sur les relations réciproques: il se peut que notre cible nous possède comme cible. On pourrait se dire que c'est idiot qu'elle nous synchronise à elle, puisqu'on vient de la synchroniser à nous. Cependant, ça a un intérêt: si notre cible avait des fichiers que nous n'avions pas, ce sera l'occasion pour elle de rattraper le retard (même si ce n'était pas le but). Dans le pire des cas, au bout d'un aller-retour plus personne n'aura de nouveauté à partager, et les échanges s'arrêteront d'eux-mêmes.
	[ "x$jAiBosse" = "xoui" ] && apres || true
}

menage0()
{
	[ $g_trace -lt 3 ] || info "[INFO] ménage normal" >&2
	deverrouiller
	trap - EXIT
	exit 0
}

menage1()
{
	[ $g_trace -lt 3 ] || info "[INFO] ménage sur erreur" >&2
	local err=$?
	[ $err -ne 0 ] || err=1
	deverrouiller
	trap - EXIT
	exit $err
}

deverrouiller()
{
	[ $g_trace -lt 3 ] || info "[INFO] $$ deverrouille (manifestant: $manifestant)" >&2
	[ -z "$manifestant" ] || bute "$manifestant" 2> /dev/null || true
	manifestant=
	[ -d "$source/.verrou.flute" ] && rm -R "$source/.verrou.flute" || true
	adest sh -c "[ -x .verrou.flute/deverrouiller ] && ./.verrou.flute/deverrouiller -i $idarbofactice && rm -R .verrou.flute || true" || true # Attention, petite possibilité qu'on supprime le mauvais si une autre flute est lancée pile entre notre .verrou.flute -i, et notre rm. Mais ce ne serait vraiment pas de bol.
}

_faire()
{
	jAiBosse=non

	# Suppression des fichiers de travail éventuellement restés.
	# En effet, pour peu qu'au terme de la précédente copie le fichier temporaire "dest" ait fini avec le même contenu que celui "source", et qu'un optimiseur d'espace disque (de type lnertotal) ait décidé de les regrouper sur le même inode, il serait malencontreux qu'en commençant cette itération en vidant puis reconstituant le temporaire "nouveaux à transférer" on tape aussi (par hard link) le "déjà présents à destination": en ce cas la suite estimerait n'avoir plus rien à transférer.
	rm -f \
		"$source/$prefixe.ici.triees.flute" \
		"$source/$prefixe.sdeja.triees.flute" \
		"$source/$prefixe.ddeja.flute" \
		"$source/$prefixe.ddeja.triees.flute" \
		"$source/$prefixe.nouveaux.flute"
	
# On évite de transmettre:
# - nos fichiers de travail
# - les fichiers temporaires d'un autre rsync qui serait en train de nous alimenter
	( adest cat .deja.flute 2> /dev/null || true ) > "$source/$prefixe.ddeja.flute"
while true
do
		# On prépare la liste de ce qui aura été fait après ce tour de boucle *avant* le transfert; ainsi, si de nouveaux fichiers apparaissent alors que l'on transfère, ils ne feront pas partie de la liste et, par sécurité, seront donc réémis au prochain tour de boucle.
		# Il s'agit de la liste des déjà présents à destination + les fichiers présents à source maintenant qui ne sont pas déjà à destination.
		sort < "$source/$prefixe.ddeja.flute" > "$source/$prefixe.ddeja.triees.flute"
		( cd "$source" && find . -type f -not -name "*.flute" | sed -e 's#^./#- /#' ) | sort > "$source/$prefixe.ici.triees.flute"
		diff "$source/$prefixe.ddeja.triees.flute" "$source/$prefixe.ici.triees.flute" | grep '^> ' | cut -c 3- > "$source/$prefixe.nouveaux.flute"
		# Tant qu'à faire, si on a repéré de nouveaux fichiers chez nous, on peut les marquer comme déjà faits: ça évitera que notre cible, en retour, croie bon de nous les transférer.
		touch "$source/.deja.flute"
		sort < "$source/.deja.flute" > "$source/$prefixe.sdeja.triees.flute"
		diff "$source/$prefixe.sdeja.triees.flute" "$source/$prefixe.ici.triees.flute" | grep '^> ' | cut -c 3- | commentaireDate "Nouveaux fichiers locaux:" >> "$source/.deja.flute"
		# Si rien de nouveau, on sort.
		[ -s "$source/$prefixe.nouveaux.flute" ] || break
		info "`wc -l < "$source/$prefixe.nouveaux.flute" | tr -d '\011 '` nouveaux fichiers à transférer"
		# Si c'est un rsync distant, il faut mettre des \ devant les espaces (merci rsync utilisant ssh et son interprétation shell!).
		destrsync="`echo "$dest" | sed -e '/^[^/]*:/s# #\\\\ #g'`"
		( internes ; cat "$source/$prefixe.ddeja.flute" ) | rsync --link-dest .verrou.flute/$idarbofactice/ --exclude-from=- -av --partial "$source/" "$destrsync/"
		# Le transfert s'étant bien passé, on peut pousser la liste .deja.flute actualisée.
		# On utilise rsync, qui ne remplacera le fichier distant qu'à l'issue du bon transfert.
		cat "$source/$prefixe.nouveaux.flute" | commentaireDate "Poussés depuis `url "$source"`" >> "$source/$prefixe.ddeja.flute"
		rsync -a "$source/$prefixe.ddeja.flute" "$destrsync/.deja.flute"
		jAiBosse=oui
	sleep 2
		[ `find "$source" -type f -newer "$source/$prefixe.ddeja.flute" -not -name "*.flute" | wc -l` -ne 0 ] || break
done
}

apres()
{
	local commande="[ ! -x .post.flute ] || ./.post.flute"
	local optionsADest=
	[ $apresLancerFlute = non ] || commande="$commande ; if command -v flute > /dev/null 2> /dev/null && find . -maxdepth 1 -name '*.conf.flute' | grep -q . ; then flute . ; fi"
	if [ $apresMasque = oui ]
	then
		optionsADest=--nohup
	else
		info "Lancement du .post.flute`[ $apresLancerFlute = non ] || printf " et de flute"` sur $dest"
	fi
	adest $optionsADest sh -c "$commande"
}

faireConf()
{
	local conf="$1"
	source="`dirname "$1"`"
	local prefixe="`basename "$conf" .conf.flute`"
	[ "`basename "$conf"`" != .conf.flute ] || prefixe=
	faire -p "$prefixe" "$source" "`cat "$conf"`"
}

auSecours()
{
	local moi="`basename "$0"`"
	proff >&2 <<TERMINE
# $moi © 2013,2015-2019 Guillaume Outters
# Synchronisation unidirectionnelle de dossiers, chaque fichier n'étant jamais transféré qu'une fois:
# - s'il est modifié à la source, il ne sera pas retransféré (la destination peut donc servir de coffre-fort de l'état originel du fichier)
# - s'il est supprimé à destination, il ne sera pas retransféré (une photo jugée mauvaise, ou déplacée pour classement dans un dossier local, ne risque pas de resurgir).
# Source et destination sont verrouillées durant le transfert: lancer en même temps une synchro et sa réciproque n'aura donc pas d'incidence malheureuse.

Utilisation:
  $moi <.*.conf.flute>+
  $moi <source> <dest>
  $moi <source>
Paramètres:
  <.*.conf.flute>
    Fichier de configuration, placé dans un dossier source, et ayant pour contenu une destination (chemin local ou URL SSH / scp / rsync).
  <source>
    Dossier source (chemin local ou URL SSH / scp / rsync).
    Dans la troisième forme (sans <dest>), chaque fichier .conf.flute de <source> sera considéré comme porteur d'une <dest>.
  <dest>
    Dossier destination (chemin local ou URL SSH / scp / rsync).
TERMINE
	exit 1
}

tifs()
{
	unset IFS
	"$@"
}

analyserParametres()
{
	sep="`printf "\003"`"
	
	apresLancerFlute=oui
	apresMasque=oui
	aFaire=
	g_trace=0
	while [ $# -gt 0 ]
	do
		case "$1" in
			-v) g_trace="`expr "$g_trace" + 1`" ;;
			-h|--help) auSecours ;;
			-n) apresLancerFlute=non ;;
			-f) apresMasque=non ;;  # --foreground
			*)
				if [ -z "$aFaire" ]
				then
					aFaire="$1"
				else
					aFaire="$aFaire$sep$1"
				fi
				;;
		esac
		shift
	done
}

tourner()
{
	if [ $# -eq 2 -a -d "$1" -a -d "$2" ] && [ "`find "$1" "$2" -name ".*.conf.flute" | wc -l`" -eq 0 ] # Le && plutôt que -a, car sh sous FreeBSD 10 évalue le find avant même d'avoir vérifié que $# -eq 2 et ce sont des dossiers.
	then
		# Ancienne sauce: source, destination précisées sur la ligne de commande. Comment distinguer de deux dossiers (sources) à synchroniser?
		faire "$1" "$2"
	elif [ $# = 0 ]
	then
		auSecours
	else
		for d in "$@"
		do
			if [ -f "$d" ]
			then
				faireConf "$d"
			else
				find "$d" -maxdepth 1 -name "*.conf.flute" | sort | \
				(
					trouve=
					while read conf
					do
						trouve=1
						faireConf "$conf"
					done
					[ -n "$trouve" ] || erreur "Aucun fichier .conf.flute dans $d" >&2
				)
			fi
		done
	fi
}

analyserParametres "$@"
IFS="$sep"
tifs tourner $aFaire
