7 minutes
Building Better Install Scripts
I’ve made a lot of dead-end projects over the past decade. The oldest ones were nothing special, just little utilities that I felt were useful for one reason or another and as a result these have been lost to disk reformats and landfills.
It wasn’t until more recently that I actually have made private and public tools or projects that I really wanted to share with others, and in some cases with less technical target audiences. This shift means that I needed to actually start making my tools not just usable for me or my internal teams but also easy to install and use for others who might want to give them a try.
While there are a number of potential ways to package software (installation packages for package managers or stand-alone binaries if you’re fortunate enough to have a relatively simple project). Some projects take ease of installation to the extreme by providing single command-line install options for uses in PowerShell or Bash.
I wanted to look at my janky installation scripts and wikis to create a template shell script that I can use in my projects going forward. A script that has the bones I need to do all my normal tasks of:
- Checking installation requirements
- Downloading and processing installation files
- Performing installation.
This first time around I’ve focused on a bash implementation though I will eventually port this to PowerShell for Windows support as well.
The full install script can be found here. The remainder of this post will go over the parts of the script in a little more detail to help out any new bash-ites.
Dependency Checking
The script starts off by defining the possible color codes that we might want to use to colorize and style text on the command-line. This was yanked from Ben Bidmead (@pry0cc)’s great Axiom project. By defining the color codes up front we can provide a more pleasing ‘UI’.
#!/bin/bash
# Colors referenced from axiom by pry0cc - https://github.com/pry0cc/axiom/blob/master/interact/includes/vars.sh
# Reset
Color_Off='\033[0m' # Text Reset
# Regular Colors
Black='\033[0;30m' # Black
Red='\033[0;31m' # Red
Green='\033[0;32m' # Green
Yellow='\033[0;33m' # Yellow
Blue='\033[0;34m' # Blue
Purple='\033[0;35m' # Purple
Cyan='\033[0;36m' # Cyan
White='\033[0;37m' # White
# Bold
BBlack='\033[1;30m' # Black
BRed='\033[1;31m' # Red
BGreen='\033[1;32m' # Green
BYellow='\033[1;33m' # Yellow
BBlue='\033[1;34m' # Blue
BPurple='\033[1;35m' # Purple
BCyan='\033[1;36m' # Cyan
BWhite='\033[1;37m' # White
Next the script defines some global variables to use to track common things that install scripts need to manage, dependencies, dependency check results, desired install path, and the tool or application name are all configured in this one place to make the reuse of the script easier.
DEPCHECKFAIL=0
DEPENDENCIES=(
"crontab"
"docker"
"docker-compose"
"wget"
)
INSTALL_PATH="$HOME/.cryptbreaker"
TOOL_NAME="Cryptbreaker"
If you’re newer to bash you might not recognize the DEPENDENCIES definition, but bash arrays are plenty of fun and allow us to make the script more modular and minimize code by being able to utilize a for loop.
Now the real content of the script begins.
check_bin_in_path() {
dep=$1
if [[ ! $(type -P $1) ]]
then
echo -e "[${BRed}X${Color_Off}] Please install ${BBlue}$1${Color_Off} then run this installation again" 1>&2
DEPCHECKFAIL=1
fi
}
The first helper function declaration takes a dependency as it’s first argument and then uses the bash built in type to check to see if the referenced binary is accessible in the current environment. If it is not we use the echo command with extended support to inform the user that they are missing a dependency and set the DEPCHECKFAIL variable to 1.
In addition to checking if certain binaries are available we may also need to check if the current user is a member of required groups. This is done with the following helper function.
check_member_of_group() {
group=$1
if [[ ! $(id) =~ .*\($group\).* ]]
then
echo -e "[${BRed}X${Color_Off}] Please ensure that your account is a member of the ${BBlue}$group${Color_Off} group and then run this installation again" 1>&2
DEPCHECKFAIL=1
fi
}
The above function takes a target group to check membership of as its first argument and then uses regular expression comparison to search the output of the id command for the presence of the desired group.
Just like last time, if the user is not a member of the desired group a message is displayed to the user and the DEPCHECKFAIL variable is set to 1.
With these two helper functions defined to allow for dependency checking the stage is set to implement the first main chunk of logic when it comes to our installer. The way I see it there are three main tasks for any installer:
- Check Dependencies
- Download Required Files
- Run various commands to wind up in desired end state
So lets finally knock out that first step:
check_dependencies() {
echo ""
echo -e "[${BWhite}.${Color_Off}] Checking for Required Dependencies"
for dep in "${DEPENDENCIES[@]}"
do
check_bin_in_path $dep
done
check_member_of_group docker
if [[ $DEPCHECKFAIL -eq 1 ]]
then
exit 1
else
echo -e "[${BGreen}+${Color_Off}] Required Dependencies Present"
fi
}
In this case the script simply notifies the user that the dependency check is happening, then uses a for loop to check for each of the dependencies declared in the DEPENDENCIES array variable. Next the script verifies that the current user is a member of the docker group. Remember, that each of these helper functions will set the DEPCHECKFAIL variable to 1 if any dependency check failed. Since this is a global variable our check___dependencies function can now look at the variable and exit with an error code of 1 if there was such a failure, otherwise the installation will inform the user of successful checks and continue.
File Download
Next the script defines the download___files function to retrieve and process the files required by whatever tool we’re installing.
download_files() {
if [[ -d $INSTALL_PATH ]]
then
echo -en "[${BYellow}!${Color_Off}] Existing $TOOL_NAME folder found. Override? [yN]: "
read CHOICE
if [[ $CHOICE =~ ^[Yy]$ ]]
then
echo -e "[${BWhite}.${Color_Off}] Will replace the existing installation"
rm -rf $INSTALL_PATH
else
echo -e "[${BWhite}.${Color_Off}] Will keep current installation, nothing for installer to do"
exit 0
fi
fi
mkdir -p $INSTALL_PATH
cd $INSTALL_PATH
echo -e "[${BWhite}.${Color_Off}] Downloading files..."
wget https://raw.githubusercontent.com/Sy14r/Cryptbreaker/main/docker-compose.yml 1>&2 2>/dev/null
echo -e "[${BGreen}+${Color_Off}] Download complete."
}
First the script checks if the installation directory already exists, if it does it will prompt the user to verify if they want a fresh install or exit if the user declines.
The installation of the tool here, Cryptbreaker, is a fairly simple process, just download a single docker-compose file to the installation directory. This is accomplished via wget (which is in our dependencies array 😉)
Installation
Lastly, the scaffolding installation script defines a perform___install function which is intended to perform any additional actions with the downloaded files. This is where you might configure auto-updating, add links or shortcuts, configure startup actions etc.
perform_install() {
# Crontab autoupdate nightly at midnight
echo -en "[${BBlue}?${Color_Off}] Would you like to configure for nighlty auto-updates? [yN]: "
read CHOICE
if [[ $CHOICE =~ ^[Yy]$ ]]
then
echo -e "[${BWhite}.${Color_Off}] Configuring for nightly auto-update"
line="0 0 * * * bash -c \"cd $INSTALL_PATH; docker-compose down; docker-compose pull; docker-compose up -d\""
(crontab -u $USER -l; echo "$line" ) | crontab -u $USER - 1>&2 2>/dev/null && echo -e "[${BGreen}+${Color_Off}] Autoupdates enabled"
else
echo -e "[${BWhite}.${Color_Off}] Not modifying cron for autoupdates"
fi
echo -e "[${BWhite}.${Color_Off}] Starting $TOOL_NAME"
echo -e "[${BWhite}.${Color_Off}] Pulling latest images"
cd $INSTALL_PATH
docker-compose pull &>/dev/null
echo -e "[${BWhite}.${Color_Off}] Launching $TOOL_NAME"
echo ""
docker-compose up -d
echo ""
echo -e "[${BGreen}+${Color_Off}] $TOOL_NAME successfully installed"
exit 0
}
For this installation script, we prompt the user to see if they want auto updates, add an entry via crontab if updates are desired and then run various docker-compose commands to get the service running.
With all these functions declared all that’s left is to call them and the last three lines of the script do just that:
check_dependencies
download_files
perform_install
Welp, that’s it for now. While not groundbreaking this scaffolding will save me time on all my future installation scripts…. hopefully it can help you too.
Happy Hacking