02 · Advanced Scripting Patterns¶
Once a script grows past a few dozen lines, "just add another if"
stops working. This module covers two patterns that keep complex scripts
readable as they grow: state machines for scripts that move through
distinct phases, and config-driven scripts that separate what to do
from how to do it.
Why state machines¶
Scripts that install software, run multi-step deployments, or process data pipelines naturally move through phases — download, verify, extract, install, cleanup. Modeling those phases explicitly as a state machine (instead of one long linear script) makes it possible to resume after a failure, log progress meaningfully, and reason about what "currently running" means.
A basic state machine in bash¶
#!/usr/bin/env bash
# installer.sh — a simple state machine
set -euo pipefail
state="start"
while [[ "$state" != "done" ]]; do
case "$state" in
start)
echo "==> checking prerequisites"
command -v curl >/dev/null || { echo "curl required" >&2; exit 1; }
state="download"
;;
download)
echo "==> downloading package"
curl -fsSL -o /tmp/pkg.tar.gz "https://example.com/pkg.tar.gz"
state="verify"
;;
verify)
echo "==> verifying checksum"
# (checksum check would go here)
state="install"
;;
install)
echo "==> installing"
tar -xzf /tmp/pkg.tar.gz -C /opt/myapp
state="cleanup"
;;
cleanup)
echo "==> cleaning up"
rm -f /tmp/pkg.tar.gz
state="done"
;;
*)
echo "Unknown state: $state" >&2
exit 1
;;
esac
done
echo "Install complete."
Each state does one thing and explicitly sets the next state. That explicitness is the whole point — you can log "entering state X", handle errors per-state, or jump straight to a specific state when debugging.
Making state resumable¶
Persist the current state to a file so a failed run can pick up where it left off instead of restarting from scratch:
STATE_FILE="/var/tmp/installer.state"
save_state() { echo "$1" > "$STATE_FILE"; }
load_state() { [[ -f "$STATE_FILE" ]] && cat "$STATE_FILE" || echo "start"; }
state=$(load_state)
while [[ "$state" != "done" ]]; do
save_state "$state" # persist BEFORE running the step
case "$state" in
start) state="download" ;;
download) state="verify" ;;
verify) state="install" ;;
install) state="cleanup" ;;
cleanup) state="done" ;;
esac
done
rm -f "$STATE_FILE" # clean up only once fully done
If the script crashes mid-install, re-running it resumes at install
instead of re-downloading everything.
State transition tables¶
For state machines with conditional transitions (not just a straight line), an associative array can encode the transition table separately from the logic that runs each state:
declare -A NEXT_STATE=(
[start]="check_disk_space"
[check_disk_space:ok]="download"
[check_disk_space:low]="cleanup_old_versions"
[cleanup_old_versions]="check_disk_space"
[download]="install"
[install]="done"
)
state="start"
while [[ "$state" != "done" ]]; do
case "$state" in
check_disk_space)
if [[ $(df / | awk 'NR==2{print $4}') -gt 1000000 ]]; then
result="ok"
else
result="low"
fi
state="${NEXT_STATE[${state}:${result}]}"
continue
;;
*)
echo "-> $state"
state="${NEXT_STATE[$state]:-done}"
;;
esac
done
Config-driven scripts¶
Instead of hardcoding hostnames, paths, and thresholds inside the script body, read them from an external config file. This lets the same script behave differently per environment without editing code.
# deploy.conf
APP_NAME=myapp
DEPLOY_DIR=/opt/myapp
BACKUP_COUNT=5
HEALTHCHECK_URL=http://localhost:8080/health
#!/usr/bin/env bash
set -euo pipefail
CONFIG_FILE="${1:-deploy.conf}"
[[ -f "$CONFIG_FILE" ]] || { echo "Config not found: $CONFIG_FILE" >&2; exit 1; }
# shellcheck source=/dev/null
source "$CONFIG_FILE"
: "${APP_NAME:?APP_NAME must be set in $CONFIG_FILE}"
: "${DEPLOY_DIR:?DEPLOY_DIR must be set in $CONFIG_FILE}"
echo "Deploying $APP_NAME to $DEPLOY_DIR (keeping $((BACKUP_COUNT)) backups)"
source-ing a config file is simple but trusts its contents as shell
code — only load config files you control. The : "${VAR:?message}"
idiom is a cheap required-field check: it exits with message if VAR
is unset or empty.
Config-driven with key=value parsing (no source)¶
When the config file might come from an untrusted or user-editable source, parse it as data instead of executing it:
declare -A config
while IFS='=' read -r key value; do
[[ -z "$key" || "$key" == \#* ]] && continue # skip blanks/comments
config["$key"]="$value"
done < deploy.conf
echo "App: ${config[APP_NAME]}"
echo "Dir: ${config[DEPLOY_DIR]}"
Driving behavior from config: action tables¶
Combine a config file with a dispatch table so adding a new supported action means adding a config line, not editing the script:
declare -A ACTIONS=(
[start]="start_app"
[stop]="stop_app"
[restart]="restart_app"
[status]="status_app"
)
start_app() { echo "starting ${config[APP_NAME]}"; }
stop_app() { echo "stopping ${config[APP_NAME]}"; }
restart_app() { stop_app; start_app; }
status_app() { echo "checking ${config[HEALTHCHECK_URL]}"; }
command="${1:?Usage: $0 <start|stop|restart|status>}"
fn="${ACTIONS[$command]:-}"
[[ -z "$fn" ]] && { echo "Unknown command: $command" >&2; exit 1; }
"$fn"
How It Actually Works¶
Driving behavior from a config-defined "action table" (mapping strings to
function names, then calling "$fn" "$@" or ${!fn}-style indirect
expansion) works because bash treats a function name as just another
lookup key in the same execution-time symbol resolution it always uses:
when bash goes to run a simple command, it checks (in order) whether the
first word matches a defined function, then a builtin, then searches
$PATH for an executable — indirect dispatch just computes that first-word
string dynamically instead of it being literal source text, and then feeds
it through the exact same resolution path.
Associative-array-based dispatch tables and case statements both resolve
purely at run time in bash — there's no ahead-of-time optimization or
jump-table compilation the way a compiled language might do a switch;
case internally is a sequential pattern-match against each )-terminated
pattern using the same glob-matching engine as pathname expansion, tried in
source order until one matches.
Patterns like a state machine implemented with a while loop and a
current_state variable rely entirely on ordinary variable mutation and
loop re-evaluation — there is no separate state-machine primitive in bash;
"state" is just whatever the shell's variable table currently holds, and
transitions are just assignment statements executed as part of normal
sequential command execution.
Cheat sheet¶
| Pattern | Purpose |
|---|---|
case "$state" in ... esac loop |
core of a bash state machine |
save_state / load_state to a file |
make a state machine resumable |
declare -A NEXT_STATE=(...) |
table-driven state transitions |
source config.conf |
load trusted config as shell variables |
: "${VAR:?msg}" |
fail fast if a required config value is missing |
while IFS='=' read -r k v |
parse untrusted config as plain data |
declare -A ACTIONS=([cmd]=fn) |
dispatch table for config-driven commands |
Exercise¶
Build pipeline.sh, a state machine with states fetch → transform →
load → done, where each state prints what it's doing and sleeps 1
second to simulate work. Persist the current state to
/tmp/pipeline.state before each transition. Run it, kill it with
Ctrl-C partway through, then re-run it and confirm it resumes from the
interrupted state instead of starting over.