thmsmlr

bash-compose-up

July 1, 2020☕️2 min read

If you write software for a living, especially in modern times, you find yourself running a lot of different servers locally. Especially with microservices and what not, it's super common to have to run two or three different services locally just to get a local working environment.

The good news is that most people just use docker-compose or some fancy kubernetes stuff that the one dude who quit 18 months ago setup. It's great, you have a single command and it doesn't matter what mess you've created, it all just behaves like a monolith 🌈

Anyways, I was at work last week and I complaining about docker for like the billionth time. I just wanted that same functionality – spin up a bunch of server, have their outputs interlaced, and have the ability to press ctrl-c and have them all exit. No orphaned processes.

Introducing Bash-Compose

The idea is simple, spin up a bunch of commands, prettify the outputs, kill them all on ctrl-c. Let's take a look,

#!/usr/bin/env bash

#
# https://gist.github.com/thmsmlr/8d5ec2dded13ff91d2863177c3027de9
#
#
# This is a basic script to launch multiple programs and kill them
# all at the same time. It does this by launching the first n-1 jobs as
# background jobs, then running the nth job as a foreground job.
#
# It traps the exist signal produced by doing `CTRL-C` on the CLI and
# iterates through the PIDs of the background jobs and runs kill -9 on them.
#
# This script also tries to print our the results of each script with
# some color coding to make the outputs of these scripts somewhat readable.
#
# Requirements:
#   - Bash v4 or higher
#
set -e

declare -A PROGRAMS=(
  ["UI"]="PYTHONUNBUFFERED=TRUE python -m http.server 5001"
  ["API"]="npx http-server"
)

declare -A PIDS

NUM_PROGRAMS=${#PROGRAMS[@]}
COUNT=0
ESC=$(printf '\033')

# Start the background jobs
for NAME in "${!PROGRAMS[@]}"
do
  COUNT=$((COUNT + 1))
  COLOR=$((31 + COUNT))
  echo "Starting program ${ESC}[${COLOR}m$NAME${ESC}[0m: ${ESC}[2m${PROGRAMS[$NAME]}${ESC}[0m"
  if [ "$COUNT" != "$NUM_PROGRAMS" ]
  then
    eval "${PROGRAMS[$NAME]}" |& sed -e "s/^/${ESC}[${COLOR}m$NAME	|${ESC}[0m /" &
    PIDS[$NAME]=$!
  fi
done

# Trap and kill the background jobs
trap onexit INT
function onexit() {
  echo
  echo "Shutting down $NAME..."
  for NAME in "${!PIDS[@]}"
  do
    echo "Shutting down $NAME..."
    kill -9 "${PIDS[$NAME]}"
  done
}

# Start the final foreground job
#  NOTE: that $NAME is set to the last value in the loop
eval "${PROGRAMS[$NAME]}" |& sed -e "s/^/${ESC}[${COLOR}m$NAME	|${ESC}[0m /"
 

Alright, so let's walk through the script. First we declare all the programs that we want to start up in an associative bash array giving them pretty names that we can use to identify them.

declare -A PROGRAMS=(
  ["UI"]="PYTHONUNBUFFERED=TRUE python -m http.server 5001"
  ["API"]="npx http-server"
)

Next, we iterate through the first n-1 programs launching background jobs using the & directive in bash and save the PIDS around for later. We also pipe the stderr & stdout of the background job into a sed script which adds a nice pretty prefix.

eval "${PROGRAMS[$NAME]}" |& sed -e "s/^/${ESC}[${COLOR}m$NAME	|${ESC}[0m /" &
PIDS[$NAME]=$!

Note that we don't launch the last job in the array, if [ "$COUNT" != "$NUM_PROGRAMS" ]. The reason why is because we want this script itself to be blocking.

Next, we setup a trap on the ctrl-c interrupt signal that once the blocking program is exited, we also go through and kill all the background processes. Hence why we saved those PIDs

# Trap and kill the background jobs
trap onexit INT
function onexit() {
  echo
  echo "Shutting down $NAME..."
  for NAME in "${!PIDS[@]}"
  do
    echo "Shutting down $NAME..."
    kill -9 "${PIDS[$NAME]}"
  done
}

And finally, we launch the last program in the associative array in the foreground which should block the script until you bail out. Once again, prettifying the output. This command since the $NAME variable will remain the same value as the last iteration of the array. It's a dirty trick, but it works.

# Start the final foreground job
#  NOTE: that $NAME is set to the last value in the loop
eval "${PROGRAMS[$NAME]}" |& sed -e "s/^/${ESC}[${COLOR}m$NAME	|${ESC}[0m /"

You can find the code posted in this gist