Learn Docker With My Newest Course

Dive into Docker takes you from "What is Docker?" to confidently applying Docker to your own projects. It's packed with best practices and examples. Start Learning Docker →

How Would You Improve This Shell Script?

how-would-you-improve-this-shell-script.jpg

The script handles calling another script with an optional position argument and 0 or more command line flags.

Quick Jump:

Recently I was tasked to write a wrapper script which calls another script with a few default flags plus whatever was passed into the wrapper script.

Here’s a few examples of how the wrapper script could be called. In reality this script has a better name, it’s only called “wrapper” for the sake of this post:

$ ./wrapper
$ ./wrapper Dockerfile
$ ./wrapper Dockerfile --bump-minor
$ ./wrapper Dockerfile --bump-minor -fail
$ ./wrapper --bump-minor
$ ./wrapper --bumpr-minor --fail

The call patterns can be described like this:

  • No arguments
  • Just a file path to a Dockerfile
  • A file path and 1 flag
  • A file path and more than 1 flag
  • Just 1 flag
  • More than 1 flag

The only rule is if a path to a Dockerfile isn’t set then it will default to Dockerfile. In other words, the file path is optional.

# Solution 1: Brute Force

Often times I’ll throw something together in the quickest way possible just to see it work. Sometimes that ends up being the final version because it turns out to be quite readable and reasonably performant.

However, if I find myself needing to write more comments than code to explain what’s happening or it just feels dirty I’ll look to refactor it as soon as possible.

The code below is an example of where it works but I don’t like it. It feels too convoluted and I think without the comments it’s hard to see what’s really going on:

#!/usr/bin/env bash

set -o errexit
set -o pipefail
set -o nounset

dockerfile_default_path="Dockerfile"
dockerfile_path="${1:-}"

if [[ -f "${dockerfile_path}" ]]; then
  # We have a Dockerfile path that's valid so the rest of our args must either
  # be empty or will be set as other args down below.
  shift
else
  # Is our first argument a flag such as: --fail or -f
  if [[ "${dockerfile_path}" =~ ^-.+$ ]]; then
    # We know a custom Dockerfile path isn't used, so let's use the default.
    dockerfile_path="${dockerfile_default_path}"
  else
    if [[ "${#}" -eq 0 ]]; then
      # No arguments were passed in so let's use the default.
      dockerfile_path="${dockerfile_default_path}"
    else
      # We're using a custom Dockerfile path so let's keep it and shift.
      shift
    fi
  fi
fi

other_args=("${@:-}")

# shellcheck disable=2068
echo "${dockerfile_path}" ${other_args[@]} --save --add-digest

I’m using echo as a placeholder for the real script that was being called, that’s not important for the sake of this post.

Here’s a bunch of usage outputs which demonstrates that the script works based on how the script can be called:

$ ./wrapper
Dockerfile --save --add-digest

$ ./wrapper Dockerfile
Dockerfile --save --add-digest

$ ./wrapper Dockerfile --bump-minor
Dockerfile --bump-minor --save --add-digest

$ ./wrapper Dockerfile --bump-minor --fail
Dockerfile --bump-minor --fail --save --add-digest

$ ./wrapper --bump-minor
Dockerfile --bump-minor --save --add-digest

$ ./wrapper --bump-minor --fail
Dockerfile --bump-minor --fail --save --add-digest

All of these outputs are the expected outputs. The default flags get added and there’s always a file path present as the first argument.

# Solution 2: Getting Loopy

A while back I made a post about parsing positional arguments and flags with a while loop.

Once I realized what I was doing in my original implementation, I figured I could replace my rat’s nest of if conditions with a while loop and case statement.

That looks like this:

#!/usr/bin/env bash

set -o errexit
set -o pipefail
set -o nounset

dockerfile_default_path="Dockerfile"
dockerfile_path=
other_args=()

while [[ "${#}" -gt 0 ]]; do
  case "${1}" in
    # Is it any type of flag such as: --fail or -f
    -*)
      other_args+=("${1}")

      shift
      ;;
    # This is the file path being set, although it's possible it's empty.
    *)
      dockerfile_path="${1}"

      shift
      ;;
  esac
done

# If the file path is empty then we'll want to fall back to using the default.
dockerfile_path="${dockerfile_path:-${dockerfile_default_path}}"

# shellcheck disable=2068
echo "${dockerfile_path}" ${other_args[@]} --save --add-digest

The above script has an added benefit of allowing the file path to exist in any order. For example you can call ./wrapper --fail Dockerfile and it will still produce the following output Dockerfile --fail --save --add-digest. The original script does not support that.

This is the real code I ended up shipping. In my opinion it’s much more readable. I didn’t commit it with this many comments. I added most of the comments for the sake of the post so it’s crystal clear on what’s going on.

# Solution 3: What Would You Do?

While the above solution isn’t bad, it’s not perfect.

If you have a custom Dockerfile named -Dockerfile or anything that starts with a - then it will fail as seen below:

$ ./wrapper -Dockerfile
Dockerfile -Dockerfile --save --add-digest

The expected output we want is -Dockerfile --save --add-digest.

The root problem is our strategy parses flags by checking to see if the argument starts with - followed by any character(s). There’s no way to differentiate a real flag like --fail or a custom file named --my-file.

One way to get around that would be to explicitly define each supported flag in the wrapper script which matches the script it calls. This way we never use -* but instead we have a separate case option for each flag.

That would work but I don’t like the idea of needing to keep the wrapper script and the script it calls in sync. There’s quite a few flags.

Realistically the above problem will never come up in the project I made it for but I do like to make things more robust if I can.

Perhaps I’m stuck looking at the trees instead of the forest as a whole. Is there a completely different strategy to be found? What would you do?

Also in case you’re wondering, I do realize the while loop solution doesn’t check to see if the file path exists with -f. That wasn’t an accidental regression. The script that gets called by the wrapper script will check to make sure the path exists. I only performed the check in the original wrapper script because it was how to differentiate a file path from a flag.

# Demo Video

Timestamps

  • 0:43 – The use case for the wrapper script
  • 3:37 – Going over the inputs and expected outputs
  • 6:51 – Looking at the code for the first version
  • 7:03 – I’m an exploratory type of developer
  • 9:00 – v1: Is it a file path or flag?
  • 11:12 – v1: Further testing if it’s a file or flag
  • 13:05 – v1: Yep, that was confusing
  • 13:37 – Making sure the second version works the same
  • 14:55 – v2: High level overview
  • 16:44 – v2: Handling the case with no arguments
  • 17:55 – v2: Checking if it’s a file or flag in a loop
  • 19:52 – Is v2 better or worse?
  • 21:00 – One edge case with v2
  • 22:29 – Is a much better v3 possible? Let us know

What would you do to make this script better? Let me know below.

Never Miss a Tip, Trick or Tutorial

Like you, I'm super protective of my inbox, so don't worry about getting spammed. You can expect a few emails per year (at most), and you can 1-click unsubscribe at any time. See what else you'll get too.



Comments