← Recipes

阅读中文版本

Write Shell Scripts That Are Safe to Run Twice

Make setup and deploy scripts idempotent: guard file creation, detect prior success, and structure mutations so reruns converge instead of duplicate.

~30 minclishellautomation

Task

Harden a setup script so rerunning it after partial failure converges to the correct state without duplicating side effects.

When to use this

A bootstrap script died halfway on a new machine. Rerunning it appended a second PATH export, created backup copies of backups, and crashed on an existing user.

Prerequisites

  • Bash 4+ or POSIX sh
  • A multi-step setup or deploy script to harden
  • A scratch environment to test both fresh and partial states

Steps

1. Switch on strict mode before anything else.

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

# -e: exit on first error
# -u: unset variable is an error
# -o pipefail: pipeline fails if any stage fails
# IFS: word-splitting surprises die here

Expected output: The script now stops at the first real failure instead of continuing in a corrupted half-state.

2. Guard every creation with existence or atomic replace.

# idempotent: add line only if absent
line='export PATH="$HOME/bin:$PATH"'
grep -qxF "$line" ~/.bashrc || echo "$line" >> ~/.bashrc

# idempotent: directory creation
mkdir -p ~/.local/share/app

# idempotent: config from template (never clobber edits)
[ -f ~/.apprc ] || cp skeleton/apprc ~/.apprc

# atomic file write: temp + rename, rerun-safe
render_template() {
  local tmp
  tmp=$(mktemp)
  cat > "$tmp" <<< "$1"
  mv "$tmp" "$2"
}

Expected output: Reruns no longer duplicate PATH lines, and template files never overwrite user edits made after the first run.

3. Detect prior success with marker files instead of guessing.

MARKER_DIR="$HOME/.cache/app-setup"
mkdir -p "$MARKER_DIR"

step() {  # step <name> <command...>
  local name=$1; shift
  if [ -f "$MARKER_DIR/$name.done" ]; then
    echo "skip: $name (already done)"
    return 0
  fi
  "$@"
  touch "$MARKER_DIR/$name.done"
}

step create_user  useradd -m app
step install_deps apt-get install -y jq
step render_conf  render_template "$CONF" /etc/app.conf

Expected output: Each step records completion; a rerun after a mid-script crash resumes at the first unfinished step with clear skip messages.

4. Make state mutations converge instead of append.

# NON-idempotent: appends on every run
echo "server = $HOST" >> /etc/app.conf

# Idempotent: regenerate the whole file from declared state
cat > /etc/app.conf <<EOF
server = $HOST
port = $PORT
log_level = info
EOF

# Idempotent: user exists in desired state
id -u app >/dev/null 2>&1 || useradd -m app
usermod -aG docker app   # adds only if missing

Expected output: Configuration files converge to a declared final state no matter how many times the script runs, rather than growing with each execution.

5. Prove idempotency by running twice and diffing the world.

bash setup.sh > run1.log 2>&1; find /etc/app* "$HOME" -newer marker0 -type f 2>/dev/null | sort > state1.txt; touch marker0; bash setup.sh > run2.log 2>&1; find /etc/app* "$HOME" -newer marker0 -type f 2>/dev/null | sort > state2.txt; diff state1.txt state2.txt && grep -c "^skip:" run2.log

Expected output: The second run changes no files (empty diff) and prints skip counts for every step: idempotency demonstrated, not assumed.

Troubleshooting

Problem

A step is only partially idempotent

Solution

Split it: separate the destructive from the convergent part, make the destructive part conditional on a marker, and let the convergent part always run; database migrations need the same treatment via version tables.

Problem

Markers make stale steps skip after a code change

Solution

Include a content hash in the marker name (step.done vs step.<sha>.done) so changing the step definition invalidates its marker automatically.

Problem

Strict mode breaks an existing script with unquoted vars

Solution

Fix the script, not the mode: set -u is finding real bugs; quote expansions, provide defaults with ${VAR:-}, and treat the failures it surfaces as the review you skipped.

Limitations

  • Idempotency is per-effect: external systems with side effects (emails, charges) need their own dedup keys beyond script structure.
  • Marker files assume a stable host; containerized one-shot runs should derive state from declared inputs instead.

A script you fear to rerun is a script you will fear to run

Half-finished setup scripts are inevitable; duplicating side effects on rerun is optional. Strict mode, guarded creation, completion markers, and converge-not-append mutations turn a fragile bootstrap into one you can rerun with confidence during an incident.

# The rerun test: second run must be a no-op
bash setup.sh > run1.log
find /etc/app* "$HOME" -newer marker -type f | sort > s1.txt
touch marker
bash setup.sh > run2.log
find /etc/app* "$HOME" -newer marker -type f | sort > s2.txt
diff s1.txt s2.txt   # empty = nothing changed

run2.log:
# skip: create_user (already done)
# skip: install_deps (already done)
# render_conf: regenerated (converges, no growth)

Convergence beats append

Appending lines to a config on every run means the file encodes its own history instead of its desired state. Regenerating from a heredoc or template makes the file a function of declared inputs: run it once or fifty times, the result is identical, and diffs in code review are meaningful.

Markers encode progress, hashes encode validity

A plain done marker skips a step forever, even after you fix the bug that made it wrong. Hashing the step definition into the marker name means editing the step invalidates its own marker, and the rerun does the work again exactly when it should.