loading...

Shell Scripts Matter

thiht profile image Thibaut Rousseau Updated on ・9 min read

Bash Logo

The shell is an odd beast. Although it goes against every current trend in software engineering (strong typing, compile checks over runtime checks, ...), shell scripts are here to stay, and still constitute an important part of every developer's life.

The weird thing about shell scripts is that even strong advocates of good practices gladly forget all they know when it comes to shell scripting.

Versioning? Why bother, it's disposable code.

Code quality? That's just a shell script, it's garbage anyway.

Testing? Nah. There aren't any decent tools for that.

Wrong, wrong, and wrong. Shell scripts have value. Everything you do for real code should be done for non trivial shell scripts, even for a one-time script. That includes versioning, code reviews, continuous integration, static code analysis, and testing.

Here is a summary of everything that can, and should be done when writing shell scripts.

Note: This article will use Bash as a reference shell. Most of the content can be transposed to other POSIX compliant shells.

Keep your scripts in version control

Keeping shell scripts under version control has multiple advantages:

  • It constitutes a library. Shell scripts can be hard to write. If there's a reference for something difficult somewhere, your coworkers will thank you when they need it. You should setup a "shell-scripts" repository somewhere as soon as possible.
  • They can be properly reviewed. Making mistakes is easy with shell scripts, and they can be very damaging. Code review should be mandatory for shell scripts, as for any other piece of code.
  • They can be improved. I won't explain to you what version control is. But with shell scripts versioned, it's easy to improve them regularly.

Please, from now on, version all your shell scripts before running them. Have someone reviewing your scripts in priority before executing them in production. It's not a waste of your coworkers' time, it's a time saver for the team.

Improve the quality of your scripts with ShellCheck

Although you can check the syntactic validity of your scripts with the command bash -n, much powerful tools exist.

ShellCheck is a static code analysis tool for shell scripts. It's really an awesome tool which will help you improve your skills as you use it. So do use it. You can install it globally on your machine, use it in your continuous integration, and it even integrates perfectly with most major editors. There really are no downsides to using ShellCheck and it can save you from yourself.

If Steam had used ShellCheck in 2015, this line would never have made it to production:

rm -rf "$STEAMROOT/"*

This code violates the SC2115 rule from ShellCheck.

Use Bash unofficial strict mode

The unofficial strict mode comes from Aaron Maxwell's article "Use the Unofficial Bash Strict Mode (Unless You Looove Debugging)". He suggests to start every Bash script with the following lines:

#!/bin/bash
set -euo pipefail
IFS=$'\n\t'
  • set -e will exit the script if any command returns a non-zero status code. To prevent the option from triggering on commands returning a non-zero status code even when no error occurred, there are two solutions:

  • using the || true pattern:

command_returning_non_zero || true
  • temporary disabling the option:
set +e
command_returning_non_zero
set -e
  • set -u will prevent using an undefined variable. In the case of undefined positional parameters ($1, $2, ...), you can give them a default value with the parameter expansion construct:
my_arg=${1:-"default"}
  • set -o pipefail will force pipelines to fail on the first non-zero status code.

  • IFS=$'\n\t' makes iterations and splitting less surprising, in the case of loops mostly. The default for this variable is usually IFS=$' \n\t' but the space as a separator often gives confusing results.

Read the original article for more details and solutions for common challenges when using the strict mode!

The unofficial strict mode is more intrusive than what we've seen before and can be hard to deal with, but it's worth it in the long run. Take the time to try it.

Do some cleanup!

When scripts are interrupted, either because of a user's action or because something bad occurred, most shell scripts don't clean up their mess. In the worst case, they might not restart services they had to temporarily disable. It's a shame given how easy it is to perform some cleanup and error catching with the trap command.

Once again, in "How "Exit Traps" Can Make Your Bash Scripts Way More Robust And Reliable", Aaron Maxwell gives some great advice.

Always add the following in your shell scripts:

cleanup() {
    # ...
}
trap cleanup EXIT

The trap command will execute the cleanup function as soon as the script exits. In this function you could remove temporary files, restart services, or whatever is relevant to your script.

Test your scripts with shUnit2

shUnit2 is a unit testing framework for shell scripts. It's inspired by JUnit. It's available in the standard repositories so you can install it with apt-get install shunit2 on an Ubuntu-based distro.

shUnit2 consists of a shell script you can source in your test file. To use it, there are multiple approaches. In order not to clutter the main script, I prefer writing the tests in a separate file. This means I'll have a script.sh file and a test_script.sh file.

Below is an example for a script offering a function to add two numbers.

add.sh must have the following structure:

add() {
    local a=$1
    local b=$2
    echo $(( a + b ))
}

if [[ "${BASH_SOURCE[0]}" = "$0" ]]; then
    # Main code of the script
    add $1 $2
fi

The [[ "${BASH_SOURCE[0]}" = "$0" ]] test is used to execute the main code only when the script is executed directly, not sourced.

test_add.sh will look like this:

. ./add.sh

test_add() {
    actual=$(add 5 8)
    expected=13
    assertEquals "$expected" "$actual"
}

. shunit2

First, the test file sources the main file add.sh (in Bash, . is an alias to source). The functions it declares are then available in the test script.

The actual tests are simple functions with a name starting by test. At the end, the globally installed shunit2 is sourced and performs its magic.

The test file can then be executed:

$ bash test_add.sh
test_add

Ran 1 test.

OK

The details of what shUnit2 can do are explained in its documentation.

There are alternatives to shUnit2, such as Bats or Roundup but I didn't have a chance to use them yet. Their usage should be relatively similar though. The point of this section is that testing shell scripts is doable and should be done, whatever solution you choose in the end.

Log what your script is doing

In the past, I made the mistake of not logging anything. I liked running a script and seeing it work magically without anything ugly showing in the console. I was wrong, because when something doesn't work as expected, it becomes impossible to know what happened. Running a script is not supposed to feel like magic, it must be somewhat verbose and understandable. For that, please log as much as possible in your scripts.

For this purpose, I usually add the following lines in my scripts:

readonly LOG_FILE="/tmp/$(basename "$0").log"
info()    { echo "[INFO]    $*" | tee -a "$LOG_FILE" >&2 ; }
warning() { echo "[WARNING] $*" | tee -a "$LOG_FILE" >&2 ; }
error()   { echo "[ERROR]   $*" | tee -a "$LOG_FILE" >&2 ; }
fatal()   { echo "[FATAL]   $*" | tee -a "$LOG_FILE" >&2 ; exit 1 ; }

This tiny logging framework allows to easily keep track of whatever happens during the script execution. Logging becomes as simple as writing info "Executing this and that...". Then it's easy to grep on the log file to find something specific. Feel free to improve these functions as you need, with the date, the calling function name (with $FUNCNAME), etc.

I don't use the builtin logger because it requires special privileges to write to /var/log and I'm not fond of its usage. Writing to a log file in /tmp is usually good enough. For cron scripts though you should probably investigate logger.

Use the -v or --verbose of the commands you invoke as needed to improve the quality of your logging.

Learn to debug your scripts

The easiest way to debug a shell script, besides logging, is to run it with bash -x. Another way is to use set -x inside the script. This option will make Bash print every command before its execution, replacing the variables with their real values. Used together with the unofficial strict mode, this method is useful to see what's going on in a script with less risk to break the environment.

It's also worth knowing that a few debuggers for Bash exist, for example bashdb. bashdb works in the same way as gdb, and can be used to add breakpoints, switching to step by step execution, showing the value of variables, etc. You can learn how to use bashdb with the video "Using BashDB to Debug Your Shell Scripts ":

Document your scripts

Any shell script should have a --help option. This doesn't seem easy? It is, thanks to the following wizardry:

#/ Usage: add <first number> <second number>
#/ Compute the sum of two numbers
usage() {
    grep '^#/' "$0" | cut -c4-
    exit 0
}
expr "$*" : ".*--help" > /dev/null && usage

The usage function will print every line starting with #/ comments, without this prefix.

The expr command will check if the string resulting of the concatenation of all the parameters contains --help. If so, it will call usage.

This is definitely not the cleanest way to parse parameters, but this quick method ensures you will add a --help flag.

For the sake of good practices, this StackOverflow post explains how to properly parse a script's parameters using the while/case/shift construct.

To generate HTML documentation from your comments, you can also check shocco.sh, which inspired the above trick.

Random advice

The following is a list of random good practices I've learned the hard way. I'll explain the rationale behind every advice as I go.

Use Bash for scripting

Use Bash by default, Sh if you have to. Try to forget about Ksh, Zsh or Fish unless there are really good reasons to use them. This choice not only ensures your script will work virtually everywhere, but also fosters comprehension of a script by the whole team. You don't write production scripts for yourself.

Use Bashisms

If you use Bash, don't half-use it. Use parameter expansion. Use local and readonly variables. Use improved conditional expressions.

Quote your variables

Even if you know quoting is not required, it's a good habit to always quote variables. The only exception is when you specifically want expansion to occur. More on word splitting.

Name your parameters

This goes without saying, but explicitly naming parameters ($1, $2, ...) makes the code self-documenting and helps readability. The parameter expansion of Bash is a great candidate for naming and assigning default values to positional parameters:

my_arg=${1:-default}

Use subshells as a way to control what's in your global scope

An example is worth a thousand words:

var=1
echo $var
(
    echo $var
    var=5
    echo $var
)
echo $var

will print:

1
1
5
1

My main usage for this is when I need to temporarily modify $IFS (for iterating over simple CSV-like files for example) and reset it to its original value afterwards.

Use a template

This script template summarizes every snippets shared along this article. I believe it's a good basis for any kind of script.

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

#/ Usage:
#/ Description:
#/ Examples:
#/ Options:
#/   --help: Display this help message
usage() { grep '^#/' "$0" | cut -c4- ; exit 0 ; }
expr "$*" : ".*--help" > /dev/null && usage

readonly LOG_FILE="/tmp/$(basename "$0").log"
info()    { echo "[INFO]    $*" | tee -a "$LOG_FILE" >&2 ; }
warning() { echo "[WARNING] $*" | tee -a "$LOG_FILE" >&2 ; }
error()   { echo "[ERROR]   $*" | tee -a "$LOG_FILE" >&2 ; }
fatal()   { echo "[FATAL]   $*" | tee -a "$LOG_FILE" >&2 ; exit 1 ; }

cleanup() {
    # Remove temporary files
    # Restart services
    # ...
}

if [[ "${BASH_SOURCE[0]}" = "$0" ]]; then
    trap cleanup EXIT
    # Script goes here
    # ...
fi

Stay informed

Shell scripting isn't moving that much these days. It's a great reason to read stuff about the topic, it makes it easy to keep up! Here are some interesting resources:


I hope this article brought light on what was possible with shell scripting. The tools are there. You know the practices. Now it's up to you to make scripting a delight to work with, for you and your team!

Discussion

markdown guide
 

Really great advice.

One other thing I'd promote is Google's Shell Style Guide: google.github.io/styleguide/shell.xml. It's the nearest thing I've found to a universally-agreed style guide for Bash scripting.

Of course, it's actually only mandated for code written internally at Google, but it seems to me to be worthy of wider adoption.

 
 

Great article, thanks for the tips. I did run into one problem, I copied your template to use it and just the base template is failing a shellcheck test:

^-- SC2145: Argument mixes string and array. Use * or separate argument.

github.com/koalaman/shellcheck/wik...

I took that advice and changed from $@ to $*, the example still works, so I thought I would point it out.

 
 

While the advice given here is indisputably great and worthwile, there's one more important thing IMO - most of the times you should not write long/complicated shell scripts. Among other people, Bourne himself stated that in relatively recent talks - it's not built for that really.

 
 

I really enjoyed this article and it's got some great tips in it. I've seen countless examples of shell scripts being treated as second-class code citizens and given how important they are it's so unhelpful to do that.

I totally agree that they should be version controlled - as all code should - but I think encouraging a 'shell-scripts' repo doesn't help to elevate them up to the level of tools written in other languages. For example, I wouldn't have a 'python-scripts' repo or 'java-apps' repo - I don't care what language a tool is written in, I want to know what it does.

StackExchange/blackbox is all shell but it's barely mentioned on the project page - it's a tool and you shouldn't need to know how it's implemented to be able to use it.

 

Excellent advice all around.

shellcheck is a winner.

I'd also add, split your script into functions and do most of the work inside "main" or similar func. That makes it cleaner which parts of the code are synchronously executed and which are functionality to be called later (an alternative to the subshell () advice above).

 

HI! Good article.

On "It constitutes a library. Shell scripts can be hard to write. If there's a reference for something difficult somewhere, your coworkers will thank you when they need it. You should setup a "shell-scripts" repository somewhere as soon as possible."

There is SparrowHub - a repository of useful scripts, so welcome to contribution. If one has a useful Bash script - please upload it to SparrowHub and share with others. It's pretty easy.

 

Use Bash by default, Sh if you have to. Try to forget about Ksh, Zsh or Fish unless there are really good reasons to use them.

If you want your scripts to be truly cross-platform /bin/sh should be the default choice. FreeBSD does not even include bash unless it's installed from ports.

 

Great article! I am getting an error for shUnit2.
Inside program I set IFS=$'\n\t' and when I source that file inside _test file I get the following error:
/usr/local/bin/shunit2: line 105: eval __shunit_lineno=""; if [ "${1:-}" = "--lineno" ]; then [ -n "$2" ] && __shunit_lineno="[$2] "; shift 2; fi: command not found
When I override IFS back to default inside _test file everything works as expected.

 

i don't always use the trap builtin command, but i will start using it in my script. You should have made provision for type checking using the type builtin command.

for example to check if a command is a function or an alias


    s() {
       printf "bash is good\n"
    }

    checkType=$(type -t s)

   [[ "$s" != "function" ]] && {
      printf "$s is not a function"
      exit 1 
   }

 

Great article!

Recently I wrote an article about testing bash scripts. I made a small comparison between different tools. Link: medium.com/wemake-services/testing...

So, my tool of choice is bats. It is really good. Here's how it looks: github.com/sobolevn/git-secret/tre...

 

Most of this is good stuff I agree with, but depending on "$0" to be a usable path to the executing script isn't the most reliable technique, and when it fails it will be pretty confusing.

unix.stackexchange.com/questions/1...

I tend to use a big multiline string for my usage information, instead.

 

Thanks for the comment!

I like to say perfect is the enemy of good. $0 is not perfect but good enough in most situations. I think it's a good trade-off compared to the 15 lines better solution :)

Knowing it's not perfect is important though, I won't mention it in the post because I believe comments are an actual part of the article itself, so your comment on this point is good!

You're right on multiline strings, they're probably better everyday. I wanted to show off weird stuff with this example. In practice though I have noticed people are more prone to update the #/ comments than a usage string, I have no idea why.

 

Overall a good article. I cannot say that I agree with everything, but that does not really matter. All information we find should be taken with a grain of salt.

I would maybe add a really good resource, mywiki.wooledge.org/FullBashGuide and related sites (Pitfalls, FAQ), and also #bash channel on Freenode.

Also one good advice I have learned during my journey, it is useful know that some commands, syntax and also a lot of advice on Internet are outdated or obsolete. wiki.bash-hackers.org/scripting/ob...

 

Any thoughts on how to include a general functions.bash file in all scripts? should there be required SCRIPT_DIR env var that all scripts depend on, or should they all us relative paths?

 

If someone is interested in a really lightweight bash testing framework, I've built a KISS unit testing script a while back. You can simply Drop it in your repo and it searches and executes tests once you run it. Just try it out, feedback appreciated: github.com/meonlol/t-bash

 

Great article!

Being a beginner in Bash Scripting, this article has provided a lot of valuable insight along with how to use them in the shell scripts we write. For me, the cleanup trap looks like a winner.

 

Hear hear ; good article, I'll have to add IFS to my habits :) bash scripting is somewhat underrated ...!

Probably the biggest problem I find with bash is that you re-write everything often with every new script and keeping your monolithic scripts up to date regularly is a chore.

I've been maintaining a little tool, itself written in bash, for managing bash snippets and "include"-ing external files (along search paths, or just from current working dir)... just like "real" programming languages! It makes working with bash so much easier...

 

Don't forget to give them the devops full treatment. Drop snapshots that can be deployed though your promotion process.

 

I just want to say that this is a really great article and I've forced my entire team to read it.

CJ

 

Thank you for excellent article!!!

 

TBH I've added this page to my bookmarks as a cheat sheet. It's become very useful for me and thank you for that.

 

Thank you. Does your mini logger logs time for each entry? Could help with all those failing crons...

 

No it doesn't. But it can easily be added with the date command with a pattern like +%Y-%m-%d %H:%M:%S

 

As a Bash shell scripting veteran of ~15 years, I'm amazed at how much I didn't know!

Excellent article.

Thank you.

 

Ironically, UnixToolTip tweeted a link: The Most Brutal Man Page. That man page is that of Bash's.

 

Another great tool worth mentioning is shfmt which can automatically pretty-print shell code. There is also an Atom editor plugin I wrote.

 

shfmt seems like a great tool, thanks for sharing!