#!/bin/bash

# TODO: distcc masquerade dir, pump mode
# TODO: fix interactive shell option
# TODO: maybe change the cpufreq governor?

# Configurables:

TMP=${TMP:-/tmp/SBo}
OUTPUT=${OUTPUT:-/tmp}
BUILDLOG=${BUILDLOG:-build.log}
DEFAULT_MAKEFLAGS="-j$(( $( nproc ) + 1 ))"

# End of configurables. It's probably best not to configure TMP or
# OUTPUT here (use the environment instead) anyway. Also it's probably
# convenient to add build.log to .git/info/exclude.

# If we're not running as root, re-exec as root, with args.
# Anything sbrun expects to possibly inherit from the caller's environment
# must be explicity set here as sudo will strip them from the environment
# before executing anything.
if [ "$(id -u)" != "0" ]; then
	exec sudo \
		TMP="$TMP" \
		OUTPUT="$OUTPUT" \
		MAKEFLAGS="$MAKEFLAGS" \
		BUILDLOG="$BUILDLOG" \
		DISTCC_HOSTS="$DISTCC_HOSTS" \
		"$0" "$@"
fi

# Inherit MAKEFLAGS from env, if present.
MAKEFLAGS="${MAKEFLAGS:-$DEFAULT_MAKEFLAGS}"

# Defaults, changed by -options.
NETWORK="no"
TRACK="yes"
STRACE=""
CLEANUP="no"
SRCSH="no"
PKGSH="no"
LOGDIR=""
NSENTER=""
TRACKFS=""

SELF=$(basename $0)

# unshare and nsenter use this. It's theoretically better to use
# an unpredictable filename (not one based on the PID), but anyone
# able to mess with /mnt already has root access.
NONET_PATH=/mnt/nonet.$SELF.$$

# Possible future options:
# -f  Run script with fakeroot. (still need root/sudo for unshare/nsenter,
#     and trackfs won't track failed writes, maybe this isn't that useful?)
# -I  Install package after it's built. Could count as scope creep.

long_help() {
   # note: root's pager is used, not the user's, since we use sudo.
	# not going to care about this one.
	cat <<EOF | ${PAGER:-less}
$SELF: paranoid SlackBuild wrapper

$SELF runs the SlackBuild script in the current directory,
with an optional custom environment.

By default, the SlackBuild can't access the network, and filesystem
activity is tracked: writes to system directories are flagged and
reported. Also, a complete log of the build's standard output
and standard error is written to "$BUILDLOG".

If $SELF is called as a non-root user, it re-executes itself via
sudo. If you hate sudo, just run $SELF as root.

$SELF is designed for use with SBo scripts, but will work for any
SlackBuild (it doesn't refer to the SBo .info file).

You'll want to install system/trackfs from SBo for filesystem tracking
to work.

$SELF written by B. Watson (yalhcru@gmail.com) and released
under the WTFPL. See http://www.wtfpl.net/txt/copying/ for details.

Usage: $SELF [-jN] [-n] [script] [variable=value ...]

Options may not be bundled (use -t -n, not -tn or -nt). All options
beginning with - must occur before [script] or [variable=value].

-jN  Run N make jobs in parallel. Default is to use MAKEFLAGS from
     the environment if set, otherwise "$DEFAULT_MAKEFLAGS". If a SlackBuild fails
     without -j1, this is a bug in the SlackBuild and you should ask
     its maintainer to add -j1 to the make command in the script.

-n   Allow the SlackBuild to access the network. If a SlackBuild
     fails without this flag, that's a bug in the SlackBuild and
     should be reported to its maintainer (EMAIL in the .info file).

-t   Don't use trackfs to watch for writes to system files. If a
     SlackBuild fails without this flag, it's a bug in either
     $SELF or trackfs, and should be reported to the maintainer
     at yalhcru@gmail.com.

-s   Run the script with strace. This option implies -t (trackfs
     will be disabled). The strace log will be written to the
     current directory as "strace.out".

-S   Like -s, but using strace's -ff option. Each child process executed
     gets its own strace log file. They will be named "strace.out.<pid>".

-x   Run the script with "sh -x", enables shell command tracing.

-i   Run an interactive shell in the source directory, after the script
     completes *successfully* (nothing happens if it fails). Useful for
     development, e.g. place 'exit 0' in the script wherever you need to
     examine the state of the source directory. May not work as expected
     if the SlackBuild creates multiple directories under \$TMP, or if
     multiple SlackBuilds are being run simultaneously.

-p   Run an interactive shell in the \$PKG directory, after the script
     completes (successfully or otherwise, provided the directory
     exists). Useful for development.

-c   Clean up (remove) source and package directories after the
     build completes. This option overrides \$TMP from the environment.

-d   Use distcc for the compile. You still have to set DISTCC_HOSTS in the
     environment, or in one of distcc's config files. This option sets
     CC and CXX, allows network access, and disables filesystem tracking.

-h, --help
     Show short usage message and exit.

-H   Show long help message (you're reading it now) and exit.

[script]
     Run this script instead of the .SlackBuild script. Useful for
     development, I hope. Useful also for Slack-derived distros that
     use something other than .SlackBuild for their script names.

[variable=value ...]
   All arguments containing an = (and not beginning with -) are passed as
   part of the SlackBuild script's environment. Options beginning with -
   must occur before environment variables. Example:

     $SELF -j1 SDL2=no DOCS=yes

   Leave off the -j1 to use the default number of jobs.

After the SlackBuild exits, any files written to outside of \$TMP,
\$OUTPUT, /tmp, /var/tmp, or /root/.ccache (collectively referred to
as "the sandbox") are logged to stdout. See trackfs(1) for details of
the log format, but any write outside the sandbox means a bug in the
SlackBuild and should be reported to its maintainer.

The exit status of $SELF is the exit status of the SlackBuild.

Note: the current directory needs to be writable, since the log and
(with -s/-S) strace output are written there.

======================================================================
The rest of this help message is a long-winded discussion that doesn't
include any more usage information. Feel free to stop reading at any
time :)
======================================================================

Why does sbrun exist? Why not use sbopkg or sbotools?  sbrun is targeted
more towards a SlackBuild developer/maintainer than an end user. My
workflow is to edit the script in one terminal and repeatedly execute it
in another. If you're editing a SlackBuild, you keep running it over &
over again. So I wrote a 1-liner shell script that did this:

sudo sh ./\$( pwd | sed 's,.*/,,' ).SlackBuild

I have /sbin:/usr/sbin in my user's PATH so sudo works fine, but it's a
PITA to pass environment variables using the above script (sudo strips
them out of the env). So I made the script take arguments, and treat
those as env vars (place them between 'sudo' and 'sh').

Then I added MAKEFLAGS support to it (-jN option). Then I found out some
of my builds were writing outside of $TMP (due to either my own mistakes,
or upstream bugs) and decided I needed a way to reliably detect that,
hence the trackfs stuff. Also someone on the mailing list posted output
from running a SlackBuild under bubblewrap, which prevented network access
the script was trying to do. Which seems like a nifty feature to have,
but bubblewrap is overkill for just running a shell script.

The strace and sh -x options were added because those are things I do
fairly often with buggy SlackBuilds. The elapsed time display is a nice
convenience (I complain that "This thing takes 2 hours to build!" when
really it's closer to 1 hour).

The -i option exists because I used to be in the habit of placing 'exec
bash -login' part of the way through a script as a way to get a shell in
the source dir... but this stopped being possible once I added build.log
to sbrun. So now I can stick 'exit 0' part way through the script and use
'sbrun -i' to get the same thing. -p was added for similar reasons.

The -c option is the only "end user" option, really. I mostly use it
for building dependencies maintained by other people, not my own stuff.

Basically, sbrun started out as a lazy typist's tool, and grew into
something more generally useful. It's lower-level than sbotools or sbopkg,
but more convenient than just executing ./*.SlackBuild. It requires no
initial setup or configuration (it's a self-contained shell script),
except you have to install trackfs.

Since it's *not* intended to replace sbotools or sbopkg, sbrun doesn't
do any of these things:

- download source files.
- check source file md5sums.
- allow building multiple packages at once (queue files).
- install/upgrade/remove packages (it *just* builds them).
- dependency resolution.
- sync the repo, or even have any concept of a repo (it only deals
  with a single SlackBuild script, in the current directory).
- anything to do with .info files. Nothing about sbrun is SBo-specific,
  it'll work with Pat's or AlienBOB's or anyone else's scripts (which
  is why it's called sbrun and not sborun).

Finally, a helpful hint: If you use git to push to SBo, you can't
add anything to .gitignore since it's tracked by git. But you can
use .git/info/exclude for the same purpose. Add build.log and maybe
strace.out there.
EOF
}

show_help() {
	# don't use warn here, log isn't open yet.
	if [ -n "$1" ]; then
		echo "$SELF: unknown option '$1'" 2>&1
	fi
	cat <<EOF
$SELF: paranoid SlackBuild wrapper.

$SELF written by B. Watson (yalhcru@gmail.com) and released
under the WTFPL. See http://www.wtfpl.net/txt/copying/ for details.

Usage: $SELF [-option [-option ...]] [script] [variable=value ...]

-jN  Run N make jobs in parallel.
-n   Allow the SlackBuild to access the network.
-t   Don't use trackfs to watch for writes to system files.
-s   Run the script with strace, output in "strace.out".
-S   Run the script with strace -ff, outputs in "strace.out.<pid>".
-x   Run the script with "sh -x", enables shell command tracing.
-c   Clean up (remove) source and package dirs after build completes.
-i   Run an interactive shell in the source directory.
-p   Run an interactive shell in the \$PKG directory.
-h, --help
     Show short usage message (you're reading it now) and exit.
-H   Show long help message and exit.
script
     Run this script instead of the default .SlackBuild script.
variable=value ...
     Passed to script as environment variables.
EOF
}

# maybe add 2>/dev/null to these, but for now I wanna know if they fail.
cleanup_nonet() {
	if [ "$NETWORK" = "no" ]; then
		umount $NONET_PATH
		rm -f $NONET_PATH
	fi
}

cleanup_log() {
	[ -n "$LOGDIR" ] && rm -rf "$LOGDIR"
}

cleanup_build() {
	[ "$CLEANUP" = "yes" ] && ( rm -rf "$TMP" ; rm -f $BUILDLOG )
}

# Add a dir to $PATH, if not already present. This is actually kinda
# pointless, it would work just as well to always add dirs to PATH
# even if they're redundant.
ensure_path() {
	if ! echo "$PATH" | sed 's,:,\n,g' | grep -q "^$1\$"; then
		#echo "$1 not in PATH, adding"
		export PATH="$1:$PATH"
	fi
}

# Handle ^C gracefully. TODO: the exit status should be 128 plus
# the number of the signal received. The hard-coded 130 means SIGINT,
# the ^C signal, but we trap other signals too. Maybe use:
# http://stackoverflow.com/questions/2175647/is-it-possible-to-detect-which-trap-signal-in-bash
signal_handler() {
	cleanup_log
	cleanup_nonet
	cleanup_build
	exit 130
}

# Print a number of seconds as either MM:SS (if less than 1 hour)
# or HH:MM:SS (if >= 1 hour). This function could almost be replaced
# by:  TZ=GMT printf '%(%H:%M:%S)T\n' "$1"
# ...except print_hms doesn't display the hours if they're 00, and using
# printf that way won't handle durations longer than 23:59:59 because
# it's trying to print a time of day (24:00:00 would be 00:00:00 of the
# next day). Hopefully no SlackBuild takes over a day to run, but you
# never know...
print_hms() {
	local sec="$1" hrs min

	hrs=$(( $sec / 3600 ))
	sec=$(( $sec % 3600 ))

	min=$(( $sec / 60 ))
	sec=$(( $sec % 60 ))

	if [ "$hrs" -gt "0" ]; then
		printf '%02d:%02d:%02d\n' $hrs $min $sec
	else
		printf '%02d:%02d\n' $min $sec
	fi
}

# perl-flavoured error messenger
warn() {
	echo "$SELF:" "$@" 1>&2
	echo "$SELF:" "$@" >> $BUILDLOG
}

# Suicide squad, attack!
die() {
	warn "$@"
	exit 1
}

### main()

# if these are in $PATH, 99.99% of all SBo builds will run
# correctly under sudo. Or maybe even 100%. At least, I can't
# remember running into problems, for quite a few years now.
ensure_path /sbin
ensure_path /usr/sbin
ensure_path /usr/share/texmf/bin

# parse -options
while printf -- "$1" | grep -q ^-; do
	case "$1" in
		-j*)              MAKEFLAGS="$1"          ;;
		-n)               NETWORK=yes             ;;
		-t)               TRACK=no                ;;
		-s)               TRACK=no; STRACE=-f     ;;
		-S)               TRACK=no; STRACE=-ff    ;;
		-x)               X="-x"                  ;;
		-c)               CLEANUP="yes"           ;;
		-i)               SRCSH="yes"             ;;
		-p)               PKGSH="yes"             ;;
		-d)               CC="distcc gcc"
		                  CXX="distcc g++"
		                  TRACK=no
		                  NETWORK=yes
		                  export CC CXX           ;;
		-h|-help|--help)  show_help ; exit 0      ;;
		-H)               long_help ; exit 0      ;;
		-*)               show_help "$1"; exit 1  ;;
	esac
	shift
done

# warn and die append to the log, make sure it starts out empty.
# This is the only place we use tee $BUILDLOG (everything else appends).
{
echo -n "== $SELF starting up at "
date
echo -n "== directory: "
pwd
echo "== command: $0" "$@"
echo
} | tee $BUILDLOG

# rest of arg parsing can use warn or die.
if echo "$1" | grep -qv '='; then
	SCRIPT="$1"
	shift
	#[ ! -e "$SCRIPT" ] && warn "$SCRIPT not found, I hope you know what you're doing!"
fi

# $ENV is only for showing to the user
ENV="MAKEFLAGS=$MAKEFLAGS"
export MAKEFLAGS TMP OUTPUT

# Add rest of args to environment. The echo|cut and eval stuff allows
# spaces to occur in the values. There is probably a better modern-bash
# way to do this, but (to me anyway) it'll be less readable.
for arg; do
	if echo "$arg" | grep -qv '='; then
		die "invalid/unknown argument '$1', try -h for help or -H for long help."
	else
		ENV="$ENV $arg"
		#eval export "$arg" # works but doesn't allow spaces
		var="$( echo "$arg" | cut -d= -f1 )"
		val="$( echo "$arg" | cut -d= -f2 )"
		eval "export $var='$val'"
	fi
done

# The easy way to remove the source and PKG dirs after the
# script runs is to guarantee they'll be the only things in
# $TMP. Normally, we don't create the $TMP dir, so we can
# catch 'script fails to create $TMP dir' errors. But with -c,
# we don't care about troubleshooting so much, and mktemp is
# the way to go.
if [ "$CLEANUP" = "yes" ]; then
	TMP="$( mktemp -d /tmp/sbrun.build.XXXXXX )"
	if [ -z "$TMP" ] || [ ! -d "$TMP" ]; then
		die "Can't create temp build dir in /tmp, bailing"
	fi
fi

# I wasn't gonna trap signals, but I can't break myself of the habit
# of hitting ^C.
# TODO: we should be trapping more signals here...
# TODO: find out why trackfs sometimes segfaults when I hit ^C. No
#       harm done (it was already killed by SIGINT), just irritating.
trap signal_handler INT TERM

if [ "$TRACK" = "yes" ]; then
	if ! /bin/which trackfs &>/dev/null; then
		warn "File tracking enabled, but trackfs not installed!"
		die "Install system/trackfs or re-run $SELF with -t."
	fi

	LOGDIR="$( mktemp -d /tmp/sbrun.XXXXXX )"
	if [ -z "$LOGDIR" ] || [ ! -d "$LOGDIR" ]; then
		die "Can't create temp log dir in /tmp, bailing"
	fi

	LOG=$LOGDIR/log

	# Fun fact: trackfs uses GNU-style -- to mean "no more options",
	# but it's undocumented in the man page and --help output.
	# The readlink stuff is here in case $TMP or $OUTPUT has a symlink
	# in its path: trackfs will log the real path, with the links resolved.
	TRACKFS=\
"trackfs -l $LOG \
	-I$( readlink -f "$TMP" )\\* \
	-I$( readlink -f "$OUTPUT")\\* \
	-I/tmp\\* \
	-I/var/tmp\* \
	-I/root/.ccache\\* \
	--"

fi

if [ "$STRACE" != "" ]; then
	TRACKFS="strace $STRACE -ostrace.out"
fi

# Used to do this, but it doesn't allow for cases where the directory
# has been renamed (foo.SlackBuild in a dir called foo.testing or foo.old).
#SCRIPT="./$( pwd | sed 's,.*/,,' ).SlackBuild"

# This is better, but during development, a user might have copies of the
# script named foo.old.SlackBuild and foo.new.SlackBuild, so not perfect.
# To allow for this, we now take an optional script name on the command line.
SCRIPT="${SCRIPT:-$( /bin/ls ./*.SlackBuild | head -1 )}"

if [ ! -e "$SCRIPT" ]; then
	die "$SCRIPT not found, bailing"
fi

{
	echo "Running $SCRIPT, logging to $BUILDLOG"
	echo "Environment:    $ENV"
	echo "File tracking:  $TRACK"
	echo "Network access: $NETWORK"
	if [ "$STRACE" != "" ]; then
		echo "strace log:     strace.out"
	fi
	echo
} | tee -a $BUILDLOG

# Set up no-network namespace. This isn't foolproof, there are probably
# ways for a script being run by root to escape the namespace, but
# a script that did that would hopefully never get approved by our
# beloved moderators.
if [ "$NETWORK" = "no" ]; then
	touch $NONET_PATH
	unshare --net=$NONET_PATH ifconfig lo 127.0.0.1 up
	NSENTER="nsenter --net=$NONET_PATH"
fi

START_TIME="$( date +%s )"

# Actually run the script. Note that the 'tee' command isn't being
# tracked by trackfs. The rigmarole with $? and RET might not be the
# best way to get the exit status, TODO: see if I can do this cleaner.
# Also, using { } instead of ( ) utterly fails.
( eval $NSENTER $TRACKFS sh $X $SCRIPT 2>&1; echo "$?" > $LOGDIR/ret ) | tee -a $BUILDLOG
RET="$( cat $LOGDIR/ret )"

END_TIME="$( date +%s )"

echo "$SCRIPT exit status: $RET" | tee -a $BUILDLOG

{
	echo -n "Elapsed time: "
	print_hms $(( $END_TIME - $START_TIME ))
} | tee -a $BUILDLOG

cleanup_nonet

if [ "$TRACK" = "yes" ]; then
	if [ -s $LOG ]; then
		warn "WARNING: files altered outside the sandbox:"
		cat $LOG 1>&2
		cat $LOG >> $BUILDLOG
	fi

	cleanup_log
fi

# spawn shell(s) if requested. -i and -p are not mutually exclusive.

# TODO: maybe do this cleaner, using trackfs?
if [ "$SRCSH" = "yes" ]; then
	if [ "$RET" != "0" ]; then
		warn "Script failed (status $RET), ignoring -i option"
	else
		SRCDIR="$( /bin/ls -td $TMP/*/ | grep -v /package- | head -1 )"
		( cd $SRCDIR && bash -login )
	fi
fi

# PRGNAM is problematic. We don't want to use $( basename $( pwd ) )
# because the directory might have been renamed (foo => foo.testing or
# foo.old). Reading the .info file is no good (this might not be an SBo
# build). For now, extract it from the script name, but eventually this
# won't work because I want to support passing a script name someday
# instead of hard-coding .SlackBuild.
if [ "$PKGSH" = "yes" ]; then
	PRGNAM="$( echo $SCRIPT | sed 's,^\./\(.*\)\.SlackBuild$,\1,' )"
	PKG=$TMP/package-$PRGNAM
	if [ -d "$PKG" ]; then
		( cd $PKG && bash -login )
	else
		warn "$PKG not found, ignoring -p option"
	fi
fi

cleanup_build

# Our return status is that of the SlackBuild.
exit $RET