Bash function and exiting early

Monday was just beginning to roll on as Monday does, I had managed to work out the VPN issues and had just started to do some planned work. Then, slack tells me that new deployment had just been pushed out successfully, but the service was actually down. Now, we had HTTP healthchecks which was hitting a specific endpoint but apparently that was successful, but functionally the service was down. So I check the service logs, which shows something like this:

Oct 14 00:03:35 ip-192-168-6-113.eu-central-1.compute.internal docker[20833]: The environment file is invalid!
Oct 14 00:03:35 ip-192-168-6-113.eu-central-1.compute.internal docker[20833]: Failed to parse dotenv file due to an unexpected escape sequence..

This was a PHP service which was using phpdotenv to load the environment variables from a file. Okay, so we have found the issue which we can now fix.

However, I think why didn’t the whole startup script just abort when it got this error? This is how our startup script looked like at this stage:

#!/usr/bin/env bash

CHOWN_BIN=/usr/bin/chown
GREP_BIN=/usr/bin/grep


function _check_migrations() {
  php$PHP_VERSION $APPLICATION_ROOT/artisan migrate:status | $GREP_BIN -c 'No'
}

FAILED_MIGRATIONS_COUNT=$(_check_migrations)
if [ $FAILED_MIGRATIONS_COUNT != "0" ]
then
  echo "ERROR: Cannot start while there are $FAILED_MIGRATIONS_COUNT unapplied migrations!!!"
  exit 1
fi


$CHOWN_BIN -R $PHP_FPM_USER:$PHP_FPM_GROUP $APPLICATION_ROOT
$PHP_FPM_BIN -y $PHP_FPM_CONF --nodaemonize &

CHILD_PID=$!
wait $CHILD_PID

The error above was happening when the _check_migrations function was being called. Since we didn’t have a -e in the Bash script, the script continued executing (hence starting the php-fpm workers) even though the php artisan command had failed to execute successfully.

So I thought ok, i will just add a set -e to the script above:

diff --git a/scripts/app.sh b/scripts/app.sh
index 6df3af8..3553eff 100755
--- a/scripts/app.sh
+++ b/scripts/app.sh
@@ -1,4 +1,5 @@
 #!/usr/bin/env bash
+set -e
 
 CHOWN_BIN=/usr/bin/chown
 GREP_BIN=/usr/bin/grep

So, I tried the above and what happened? The script will error out if the php command above runs successfully and if the output doensn’t have the word No (i.e. no migrations need to be applied). Why? That’s how grep works. If it doesn’t find a match, the exit code is non-zero. Frantic googling later, I next have another fix:

index 3553eff..64d0f60 100755
--- a/scripts/app.sh
+++ b/scripts/app.sh
@@ -11,7 +11,9 @@ function _term() {
 }
 
 function _check_migrations() {
-  php$PHP_VERSION $APPLICATION_ROOT/artisan migrate:status | $GREP_BIN -c 'No'
+  # we have the last | cat so that the overall exit code is that of cat in case
+  # of the first two commands executing successfully
+  php$PHP_VERSION $APPLICATION_ROOT/artisan migrate:status | $GREP_BIN -c 'No' | cat
 }
 

This fails again, since grep was the problem, but obviously I was in a hurry to think. Then I think i know of this thing called, -o pipefail, let me try that:

index 64d0f60..150fd76 100755
--- a/scripts/app.sh
+++ b/scripts/app.sh
@@ -1,5 +1,5 @@
 #!/usr/bin/env bash
-set -e
+set -eo pipefail
 
 CHOWN_BIN=/usr/bin/chown
 GREP_BIN=/usr/bin/grep

But that doesn’t work either since grep is the problem! So, I decide to lose the conciseness and do this:

index 150fd76..6a9457a 100755
--- a/scripts/app.sh
+++ b/scripts/app.sh
@@ -1,5 +1,4 @@
 #!/usr/bin/env bash
-set -eo pipefail
 
 CHOWN_BIN=/usr/bin/chown
 GREP_BIN=/usr/bin/grep
@@ -13,7 +12,12 @@ function _term() {
 function _check_migrations() {
   # we have the last | cat so that the overall exit code is that of cat in case
   # of the first two commands executing successfully
-  php$PHP_VERSION $APPLICATION_ROOT/artisan migrate:status | $GREP_BIN -c 'No' | cat
+  status=`php$PHP_VERSION $APPLICATION_ROOT/artisan migrate:status`
+  exit_status=$?
+  if [ $exit_status -ne 0 ]; then
+    exit $exit_status
+  fi
+  echo $status | $GREP_BIN -c 'No'
 }

This works as in it will exit the script if there is an error running the php command which is what we want, else proceed as earlier. Or so I thougt.

It turns out when you cal a Bash function using the syntax $() you are actually invoking a subshell (duh!) which means exiting in the Bash function, only exits from that shell - which makes sense but I didn’t know that. That means, the original issue I sought out to fix wouldn’t actually be fixed. Anyway, here’s the fixed version:

#!/usr/bin/env bash

CHOWN_BIN=/usr/bin/chown
GREP_BIN=/usr/bin/grep

# Helper functions
function _term() {
 echo 'Caught SIGTERM signal!'
 kill -15 $CHILD_PID
}

php$PHP_VERSION $APPLICATION_ROOT/artisan migrate:status
exit_status=$?
if [[ $exit_status -ne 0 ]]; then
 exit $exit_status
fi
FAILED_MIGRATION_COUNT=`php$PHP_VERSION $APPLICATION_ROOT/artisan migrate:status | $GREP_BIN -c 'No'`
if [ $FAILED_MIGRATION_COUNT != "0" ]; then
 echo "ERROR: Cannot start while there are $FAILED_MIGRATIONS_COUNT unapplied migrations!!!"
 exit 1
fi

trap _term SIGTERM

$CHOWN_BIN -R $PHP_FPM_USER:$PHP_FPM_GROUP $APPLICATION_ROOT
$PHP_FPM_BIN -y $PHP_FPM_CONF --nodaemonize &

CHILD_PID=$!

wait $CHILD_PID

Essentially, i have now running php artisan migrate:status twice. First to check if the .env file can be read successfully and error out if not. Second, actually check for the migrations. We can make it more concise, but who knows what will break?

If you really want to use a Bash function and also exit early, this link will help.

Here’s some test code:

function foo() {
	echo "in foo, exiting"
	exit 1
}

function bar() {
	echo "In bar"
}

bar
# this invocation will exit the script
foo
# this invocation will not exit the script
f=$( foo )
echo $f
echo "hi there"