Bash Script Coding Rules
I've been thinking about writing a Bash linter sometime. I don't know when exactly this'll happen, but here are the rules I'd like to see enforced by such a linter. These are also the rules I tend to follow when writing bash scripts, and I found that it improves both readability and stability.
Rules
Shebang is
#!/bin/bashor#!/bin/sh. No whitespace between!and the path.#! /bin/bashor#! /bin/shis wrong, relative paths are wrong, only the name of the binary (#!bash) is even more wrong. If you're not certain that your script will run inshordash, use#!/bin/bashfor good measure.-
All scripts must include
set -eat the top. This terminates the script if any command returns an error.There are situations where a command can return a failure and you wish to ignore it. Double-check if that case really applies to you. Then check again. If you still think it does, use
command || /bin/true. If you have to do that all over the place, you're doing it wrong. No, skippingset -eis not a solution then: You'll have to write your script more defensively. That means, add checks and only run those commands if you expect them to succeed. -
All scripts must include
set -uat the top. This terminates the script on read access to undefined variables.The rationale behind this rule is that
rm -rf $ROTPATH/*expands torm -rf /*if you meant to type$ROOTPATHthere. You do not want to find that bug on your production database. You'd rather bash told you, and this is whatset -ugives you.From time to time though, you need to access an undefined variable, probably in order to find out if it's defined or not (command-line argumentés, for instance). Then these come in handy:
${VAR:=default}returns the variable if defined, otherwise sets it todefaultand then returns that.${VAR:-default}returns the variable if defined or elsedefault, without defining the variable if it's undefined.
I tend to prefer the
:-form. An appearance of:=is usually a sign that you should've initialized that variable somewhere earlier in your script.Note that the default can also be the empty string:
${VAR:-}is perfectly fine. This is still better than omittingset -uand letting the shell just default to the empty string, because by making this explicit, you get to decide that this can be empty. You won't do that inside anrm -rf(unless you're a bad person). Consider whether or not your script should use
set -xso it displays commands as it executes them. This is usually desirable in build scripts.Running binaries or sourcing scripts with relative paths is brittle and should be avoided. It breaks when the user runs the script from within another directory. Use absolute paths instead, or at the very least
cdinto the correct place.-
Always enclose variables that may contain whitespace (especially, user-controlled variables like command line arguments) in quotes to prevent the whitespace from doing weird things. So, this is bad:
If
MYPATHwere to contain whitespace, this fails:# mkdir "/tmp/white space" # /tmp/test.sh "/tmp/white space" /tmp/test.sh: line 6: cd: /tmp/white: No such file or directory
So instead, write this as:
-
if statements:
if [ "${FEATURE_ENABLED:-false}" = "true" ] && [ "`whoami`" = "root" ]; then if apt-get update; then echo "Cool, we successfully updated Apt's package lists!" fi else echo "Our awesome cool feature is disabled or we don't have permissions :(" fi
Space between
ifand[.if[is an error and your script is broken (commandif[does not exist).Space between
[and"$VAR".if ["$VAR"is an error and your script is broken (command[somethingdoes not exist).$VARinside"", so if the variable's value is the empty string""or contains whitespace, yourifstatement doesn't break.If you have multiple conditions, use
&&and||with multiple[ ]constructs instead of-aand-o. It's easier to read for programmers and I found-aand-odon't work reliably.Use
${VAR:-default}to define a default value, so your script doesn't get killed byset -uif the variable can be undefined here.Space around the
=operator. It needs to be a word of its own.Only a single
=for comparisons.==is invalid in[ ]checks.Space before
]. This also needs to be its own word.No space after
], put the semicolon there directly.Space after
;.thenon the same line asifIf
if [ $? = 0 ]; thenappears in your code, you're doing it wrong. Useif command; theninstead.
-
If you're using non-standard tools, check if they're installed. Such a check can be done using
which: If you do something along the lines of
PINGOUT="`ping -c5 "${HOST:-8.8.8.8}"`", be sure to close all'a dem""correctly.-
No running things in the background using
&without callingwaitsomewhere.No, I won't grant you an exception.
No, you're not special.
Especially not if you're writing an init script. Use start-stop-daemon instead. If your distro doesn't have that, well, switch to one that does or use systemd. It's probably way easier anyway, init scripts are horrible.
This is what it should look like:
-
Take care when redirecting both stdout and stderr because order matters. This works:
This does not:
You can compare this to a programming language. Suppose you have two variables that you wish to change to the same value. This works:
stdout = "/dev/null" stderr = stdout
This does not:
stderr = stdout stdout = "/dev/null"
In the latter case,
"/dev/null"is only written into thestdoutvariable, not intostderr. Bash internally does the same thing when you use>, only with file descriptors. Be aware that
apt-get update &>/dev/nullonly works in bash, so have#!/bin/bashin your shebang if you use it.No indented multiline strings. Heck, no multiline strings at all. Use a here-document instead.
-
If writing here-documents, put the
<<EOFmarker at the front of the line. It's easier to see this way. If your here-document is itself a bash script containing other here-documents, theEOFtext may be anything you choose: -
If you need to shell out to a subscript (e.g. if you want to run something complex inside a chroot or on a remote host), render it first using a here-document. Those can also contain variables:
THIS_OTHER_PACKAGE="sysstat" <<EOF cat >"$MNT/install.sh" #!/bin/bash set -e # Yes, i really do mean it when I say "all scripts" set -u apt-get install htop iftop iotop "$THIS_OTHER_PACKAGE" EOF chmod +x "$MNT/install.sh" chroot "$MNT" /install.sh
Conveniently, this also works across SSH:
THIS_OTHER_PACKAGE="sysstat" <<EOF ssh remote-host.somewhere.com /bin/bash -e -u apt-get install htop iftop iotop "$THIS_OTHER_PACKAGE" EOF
Note though that variables are replaced before the content is written into the document, so if you want to use an actual variable in the document, you need to escape its
$sign. Furthermore, you'll need to escape backticks.#!/bin/bash set -e set -u <<EOF ssh remote-host.somewhere.com /bin/bash -e -u MYHOSTNAME="\`hostname --fqdn\`" echo "Oh hai, my name is \$MYHOSTNAME!" EOF
If you're not sure what I mean, try this script with a simple
$instead of\$and you'll see. Keep line length reasonable. I'm not saying it has to be 80 chars, but you should definitely manage to stay within 140.
Example
Here's an example script, outlining most of the rules above and it even contains a simple but elegant argument parser:
#!/bin/bash set -e set -u # default server SERVER="8.8.8.8" while [ -n "${1:-}" ]; do case "$1" in -h|--help) echo "Usage: $0 [--mtr] [--server <server>]" echo echo "Options:" echo " -h --help This help text" echo " --mtr Use mtr instead of ping" echo " -s --server Server to connect to [$SERVER]" exit 0 ;; --mtr) MTR=true ;; -s|--server) SERVER="$2" shift # params with args need an extra shift ;; *) echo "Unknown option $1, see --help" exit 3 esac shift done if [ "${MTR:-false}" = "true" ]; then if ! which mtr >/dev/null; then echo "mtr is missing -> abort" exit 4 fi mtr "$SERVER" else ping "$SERVER" fi