Betterdev. blog/minimal saf…

Betterdev.blog /

Published: December 14, 2020

Bash script. Almost everyone has to write one sooner or later. Very few people say, “Yes, I love writing them.” That’s why almost everyone pays little attention to them when they write about them.

I’m not going to try to make you a Bash expert (because I’m not either), but I’ll show you a minimal template that will make your scripts safer. You don’t need to thank me. Your future self will thank you.

Why write scripts in Bash?

The best summary of the Bash script recently appeared on my Twitter feed.

Twitter.com/JakeWharton…

But Bash has something in common with another well-loved language. Just like JavaScript, it won’t go away easily. While we can hope Bash doesn’t become the primary language for literally everything, it’s always close somewhere.

Bash inherits the shell throne and can be found on almost every Linux, including Docker images. This is where most of the background runs. So if you need to write scripts for server application startup, CI/CD steps, or integration test runs, Bash is for you.

Bash is the simplest and most native solution for gluing several commands together, passing output from one command to another, and simply launching some executables. While it makes a lot of sense to write larger, more complex scripts in other languages, you can’t count on Python, Ruby, Fish, or whatever interpreter you think is best, to be everywhere. And you might want to think twice before adding it to a ProD server, Docker image, or CI environment.

Bash, however, is far from perfect. Grammar is a nightmare. Error handling is difficult. There are mines everywhere. And we have to deal with it.

Bash Script Template

Without further ado, it’s right here.

#! /usr/bin/env bash

set -Eeuo pipefail
trap cleanup SIGINT SIGTERM ERR EXIT

script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)

usage() {
  cat <<EOF Usage: $(basename "${BASH_SOURCE[0]}") [-h] [-v] [-f] -p param_value arg1 [arg2...]  Script description here. Available options: -h, --help Print this help and exit -v, --verbose Print script debug info -f, --flag Some flag description -p, --param Some param description EOF
  exit
}

cleanup() {
  trap - SIGINT SIGTERM ERR EXIT
  # script cleanup here
}

setup_colors() {
  if [[ -t 2 ]] && [[ -z "${NO_COLOR-}"[[]] &&"${TERM-}"! ="dumb"]].then
    NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m' PURPLE='\033[0;35m' CYAN='\033[0;36m' YELLOW='\033[1;33m'
  else
    NOFORMAT=' ' RED=' ' GREEN=' ' ORANGE=' ' BLUE=' ' PURPLE=' ' CYAN=' ' YELLOW=' '
  fi
}

msg() {
  echo >&2 -e "The ${1}"
}

die() {
  local msg=The $1
  local code=1} ${2 - # default exit status 1
  msg "$msg"
  exit "$code"
}

parse_params() {
  # default values of variables set from params
  flag=0
  param=' '

  while :; do
    case "The ${1}" in
    -h | --help) usage ;;
    -v | --verbose) set -x ;;
    --no-color) NO_COLOR=1 ;;
    -f | --flag) flag=1 ;; # example flag
    -p | --param) # example named parameter
      param="The ${2}"
      shift;; -? *) die"Unknown option: The $1" ;;
    *) break ;;
    esac
    shift
  done

  args=("$@")

  # check required params and arguments
  [[ -z "${param-}" ]] && die "Missing required parameter: param"
  [[ ${#args[@]} -eq 0 ]] && die "Missing script arguments"

  return 0
}

parse_params "$@"
setup_colors

# script logic here

msg "${RED}Read parameters:${NOFORMAT}"
msg "- flag: ${flag}"
msg "- param: ${param}"
msg "- arguments: ${args[*]-}"
Copy the code

The idea is not to make it too long. I don’t want to scroll 500 lines into script logic. At the same time, I want any script to have some strong foundation. Bash does not make this easy, however, lacking any form of dependency management.

One solution is to have a single script that contains all the templates and utilities and executes it at the beginning. The downside is that you always have to append a second file everywhere, and you lose the idea of a “simple Bash script.” So I decided to put only what I thought was the minimum in the template to keep it as short as possible.

Now let’s look at it in more detail.

Choose to Bash

#! /usr/bin/env bash
Copy the code

Scripts traditionally start with a shebang. For best compatibility, it refers to /usr/bin/env rather than /bin/bash directly. However, even this sometimes fails if you read the comments in the StackOverflow issue.

Fail fast

set -Eeuo pipefail
Copy the code

The set command can change script execution options. For example, Bash usually doesn’t care if certain commands fail and returns a non-zero exit status code. It just jumps happily to the next one. Now look at this little script:

#! /usr/bin/env bash
cp important_file ./backups/
rm important_file
Copy the code

What happens if backups directories don’t exist? Exactly, you will get an error message in the console, but the file will be deleted by the second command before you can react.

For details about the options for set-eeuo Pipefail variations and how they will protect you, I referred to my article in bookmarks, now a few years ago.

As you should know, though, there are some arguments against setting these options.

To obtain position

script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)
Copy the code

This line tries to define the directory where the script is located, and then we CD into it. Why do you do that?

Typically our script operates on a path relative to the script location, copying files and executing commands, assuming that the script directory is also a working directory. And it does, as long as we execute the script from its directory.

But if, our CI configuration execution script looks like this.

/opt/ci/project/script.sh
Copy the code

Instead of running in the project directory, our script will be running in a completely different working directory for the CI tool. We can solve this problem by entering the directory before executing the script.

cd /opt/ci/project && ./script.sh
Copy the code

But it’s better to solve this problem on the script side. So, if the script reads some files from the same directory or executes another program, it can be called like this.

cat "$script_dir/my_file"
Copy the code

Also, the script does not change the location of workdir. If the script is executed in another directory, and the user provides a relative path to a file, we can still read it.

Trying to clean up

trap cleanup SIGINT SIGTERM ERR EXIT

cleanup() {
  trap - SIGINT SIGTERM ERR EXIT
  # script cleanup here
}
Copy the code

Think of trap as the finally block of a script. At the end of the script — normal, caused by an error or external signal — the cleanup() function is executed. For example, here you can try to delete all temporary files created by the script.

Remember that the cleanup() function can be called not only at the end, but after any part of the script is done. Not all the resources you’re trying to clean up will exist.

Displays useful help

usage() {
  cat <<EOF Usage: $(basename "${BASH_SOURCE[0]}") [-h] [-v] [-f] -p param_value arg1 [arg2...]  Script description here. ... EOF
  exit
}
Copy the code

Placing Usage () relatively near the top of the script will work in two ways.

  • Help people who don’t know all the options and don’t want to go through the script to find them.
  • As a basic document, when someone changes the script (like you, 2 weeks later, don’t even remember writing it).

I’m not advocating documenting every feature here. But a short, nice script that uses information is a basic requirement.

Print beautiful messages

setup_colors() {
  if [[ -t 2 ]] && [[ -z "${NO_COLOR-}"[[]] &&"${TERM-}"! ="dumb"]].then
    NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m' PURPLE='\033[0;35m' CYAN='\033[0;36m' YELLOW='\033[1;33m'
  else
    NOFORMAT=' ' RED=' ' GREEN=' ' ORANGE=' ' BLUE=' ' PURPLE=' ' CYAN=' ' YELLOW=' '
  fi
}

msg() {
  echo >&2 -e "The ${1}"
}
Copy the code

First, if you don’t want to use colors in your text, remove the setup_colors() function. I kept it because I knew I’d use color more often if I didn’t have to Google a color code every time.

Second, these colors are only used for the MSG () function, not the echo command.

The MSG () function is used to print all non-scripted output. This includes all logs and messages, not just errors. Articles referencing great 12 factor CLI applications.

In short, stdout is for output, stderr is for passing information.

Jeff Dickey, who knows a thing or two about building CLI applications

That’s why, in most cases, you shouldn’t use colors for STdout.

The information printed in MSG () is sent to the stderr stream, and special sequences, such as colors, are supported. If the stderr output is not an interactive terminal or passes a standard parameter, the color is disabled.

Method of use

msg "This is a ${RED}very important${NOFORMAT} message, but not a script output value!"
Copy the code

To check how stderr behaves when it is not an interactive terminal, add a line like the one above to your script. It is then executed to redirect stderr to STdout and pipe it to CAT. The pipe operation causes the output to be sent not directly to the terminal, but to the next command, so color should now be disabled.

$ ./test.sh 2>&1 | cat
This is a very important message, but not a script output value!
Copy the code

Parsing any parameters

parse_params() {
  # default values of variables set from params
  flag=0
  param=' '

  while :; do
    case "The ${1}" in
    -h | --help) usage ;;
    -v | --verbose) set -x ;;
    --no-color) NO_COLOR=1 ;;
    -f | --flag) flag=1 ;; # example flag
    -p | --param) # example named parameter
      param="The ${2}"
      shift;; -? *) die"Unknown option: The $1" ;;
    *) break ;;
    esac
    shift
  done

  args=("$@")

  # check required params and arguments
  [[ -z "${param-}" ]] && die "Missing required parameter: param"
  [[ ${#args[@]} -eq 0 ]] && die "Missing script arguments"

  return0}Copy the code

If there’s something that makes sense and can be parameterized in a script, I usually do that. Even if the script is only used in one place. This makes it easier to copy and reuse it, often sooner or later. Also, even if something needs to be hard-coded, there are usually better places at a higher level than Bash scripts.

There are three main types of CLI parameters – flags, named parameters, and positional parameters. The parse_params() function supports both of them.

The only common parameter pattern not dealt with here is the join of multiple single-letter flags. To be able to pass the two flags -ab instead of -a -b, you need some extra code.

The while loop is a way to manually parse parameters. In other languages, you should use a built-in parser or available library, but this is Bash.

There is an example flag (-f) and named parameter (-p) in the template. Just change or copy them to add other parameters. And don’t forget to update usage() later.

The important point here is that it is usually ignored when you just take the first Google result to do Bash parameter parsing, which throws an error on an unknown option. The script receives an unknown option, meaning that the user expects it to do something that the script cannot do. So the user’s expectations and the script’s behavior may be completely different. It’s best to prevent execution altogether until something bad happens.

There are two options for parsing parameters in Bash. Getopt and getopts. There are pros and cons to their use. I’ve found that these tools aren’t the best because getopt behaves completely differently on macOS by default, and Getopts doesn’t support long arguments (like –help).

Use the template

Like most code you’ll find on the web, just copy and paste.

Well, actually, that’s pretty honest advice. There is no universal NPM install equivalent for Bash.

After copying, you only need to change four things.

  • usage()Text and script description
  • cleanup()content
  • parse_params()Parameter in — reserved--helpand--no-color, but replace the example with:-fand-p.
  • Script logic

portability

I tested the template on MacOS (using the default, outdated Bash 3.2) and several Docker images. Debian, Ubuntu, CentOS, Amazon Linux, Fedora. It works.

Obviously, it won’t work in environments without Bash, such as Alpine Linux. Alpine, as a minimalist system, uses the very lightweight Ash (Almquist shell).

You can ask whether it would be better to use Bourne shell-compatible scripts that work almost anywhere. In my case, at least, the answer is no. Bash is more secure and powerful (but still not easy to use), so I can accept that some Linux distributions lack support and I rarely have to deal with it.

Further reading

There are some general rules for creating CLI scripts, whether using Bash or a better language. These resources will guide you on how to make your small scripts and large CLI applications reliable.

  • Command line interface guide
  • 12 Factor CLI applications
  • Command line parameter anatomy explanation with examples

conclusion

I am neither the first nor the last person to create a Bash script template. A good option is this project, although it’s a bit too big for my daily needs. After all, I try to keep Bash scripts small (and rare).

When writing Bash scripts, use an IDE that supports ShellCheck Linter, such as JetBrains IDE. It prevents you from doing a bunch of things that backfire.

My Bash script template is also available as GitHub Gist (under license from MIT).

script-template.sh

If you find any problems with the template, or if you think something important is missing — let me know in the comments.

Update the 2020-12-15

After a lot of comments here, on Reddit and HackerNews, I made some improvements to the template. See revision history in gist.


www.deepl.com translation