Idempotency Rules (IDEM001-IDEM006)

Rash includes idempotency rules designed to detect operations that fail when run multiple times. Idempotent scripts can be safely re-run without side effects or failures, making them reliable for automation and recovery.

Overview

Idempotency linting in Rash focuses on operations that fail on second execution:

  • Directory creation without -p (mkdir)
  • File removal without -f (rm)
  • Symlink creation without cleanup (ln -s)
  • Non-idempotent variable appends
  • File creation with > (truncating)
  • Database inserts without existence checks

All IDEM rules are Warning severity by default to indicate improvements without blocking.

Why Idempotency Matters

Non-idempotent scripts cause:

  • Deployment failures: Re-running fails instead of succeeding
  • Recovery problems: Can't safely retry after partial failures
  • Automation issues: Cron jobs and systemd timers break
  • Manual headaches: Operators fear running scripts twice
  • Rollback failures: Can't cleanly undo then redo

Idempotent = Safe to Re-run = Reliable

Core Principle

An operation is idempotent if:

f(x) = f(f(x)) = f(f(f(x))) = ...

Running it once or N times produces the same result.

Implemented Rules (IDEM001-IDEM003)

bashrs currently implements 3 idempotency rules with comprehensive testing. The remaining rules (IDEM004-IDEM006) are planned for future releases.

IDEM001: Non-idempotent mkdir

Severity: Warning

What it Detects

mkdir commands without the -p flag.

Why This Matters

mkdir without -p fails if the directory already exists:

$ mkdir /app/releases
$ mkdir /app/releases  # FAILS with "File exists" error
mkdir: cannot create directory '/app/releases': File exists

This breaks idempotency - the script fails on second run even though the desired state (directory exists) is achieved.

Examples

NON-IDEMPOTENT (BAD):

!/bin/bash
 deploy.sh - FAILS on second run

mkdir /app/releases
mkdir /app/releases/v1.0.0
ln -s /app/releases/v1.0.0 /app/current

Behavior:

First run:  ✅ SUCCESS - directories created
Second run: ❌ FAILURE - mkdir fails with "File exists"

IDEMPOTENT (GOOD):

!/bin/bash
 deploy.sh - SAFE to re-run

mkdir -p /app/releases
mkdir -p /app/releases/v1.0.0
rm -f /app/current && ln -s /app/releases/v1.0.0 /app/current

Behavior:

First run:  ✅ SUCCESS - directories created
Second run: ✅ SUCCESS - no-op (directories exist)
Third run:  ✅ SUCCESS - still safe!

Auto-fix

Auto-fixable with assumptions - automatically adds -p flag.

Assumption: Directory creation failure is not a critical error condition.

If directory creation failure MUST be detected (rare), keep mkdir without -p and explicitly handle errors:

!/bin/bash
 Only use this if you NEED to detect pre-existing directories
if ! mkdir /app/releases 2>/dev/null; then
    echo "ERROR: Directory /app/releases already exists or cannot be created"
    exit 1
fi

Testing for Idempotency

Verify scripts can run multiple times:

!/bin/bash
 test-idempotency.sh

SCRIPT="$1"

echo "Testing idempotency of: $SCRIPT"

 Run once
"$SCRIPT"
RESULT1=$?

 Run twice
"$SCRIPT"
RESULT2=$?

 Both should succeed
if [ $RESULT1 -eq 0 ] && [ $RESULT2 -eq 0 ]; then
    echo "✅ PASS: Script is idempotent"
else
    echo "❌ FAIL: Script is not idempotent"
    echo "First run: exit $RESULT1"
    echo "Second run: exit $RESULT2"
    exit 1
fi

Real-world Example: Application Setup

NON-IDEMPOTENT (BAD):

!/bin/bash
 setup.sh - FAILS on re-run

 Create directory structure
mkdir /opt/myapp
mkdir /opt/myapp/bin
mkdir /opt/myapp/lib
mkdir /opt/myapp/data

 Install application
cp myapp /opt/myapp/bin/
cp lib/*.so /opt/myapp/lib/

 Second run FAILS at first mkdir!

IDEMPOTENT (GOOD):

!/bin/bash
 setup.sh - SAFE to re-run

 Create directory structure (idempotent)
mkdir -p /opt/myapp/bin
mkdir -p /opt/myapp/lib
mkdir -p /opt/myapp/data

 Install application (use -f to force overwrite)
cp -f myapp /opt/myapp/bin/
cp -f lib/*.so /opt/myapp/lib/

 Safe to run multiple times - always succeeds!

IDEM002: Non-idempotent rm

Severity: Warning

What it Detects

rm commands without the -f flag.

Why This Matters

rm without -f fails if the file doesn't exist:

$ rm /app/current
$ rm /app/current  # FAILS with "No such file or directory"
rm: cannot remove '/app/current': No such file or directory

This breaks idempotency - the script fails on second run even though the desired state (file doesn't exist) is achieved.

Examples

NON-IDEMPOTENT (BAD):

!/bin/bash
 cleanup.sh - FAILS on second run

rm /tmp/build.log
rm /tmp/cache.dat
rm /app/old-version

Behavior:

First run:  ✅ SUCCESS - files deleted
Second run: ❌ FAILURE - rm fails with "No such file"

IDEMPOTENT (GOOD):

!/bin/bash
 cleanup.sh - SAFE to re-run

rm -f /tmp/build.log
rm -f /tmp/cache.dat
rm -f /app/old-version

Behavior:

First run:  ✅ SUCCESS - files deleted
Second run: ✅ SUCCESS - no-op (files don't exist)
Third run:  ✅ SUCCESS - still safe!

Auto-fix

Auto-fixable with assumptions - automatically adds -f flag.

Assumption: Missing file is not an error condition.

If file existence MUST be verified (rare), explicitly check before removing:

!/bin/bash
 Only use this if you NEED to ensure file exists
if [ ! -f /app/critical-file ]; then
    echo "ERROR: Expected file /app/critical-file not found"
    exit 1
fi

rm /app/critical-file

When to Use rm Without -f

Very rare cases where missing file indicates a problem:

!/bin/bash
 uninstall.sh - Verify installed before uninstalling

 Check installation exists
if [ ! -f /usr/local/bin/myapp ]; then
    echo "ERROR: myapp not installed (expected /usr/local/bin/myapp)"
    exit 1
fi

 Remove (without -f to detect unexpected deletion)
rm /usr/local/bin/myapp

But even here, idempotent version is usually better:

!/bin/bash
 uninstall.sh - Idempotent version

 Idempotent: remove if exists, succeed if not
rm -f /usr/local/bin/myapp

 Report status
if [ -f /usr/local/bin/myapp ]; then
    echo "ERROR: Failed to remove /usr/local/bin/myapp"
    exit 1
else
    echo "✅ myapp uninstalled (or was already removed)"
fi

Real-world Example: Log Rotation

NON-IDEMPOTENT (BAD):

!/bin/bash
 rotate-logs.sh - FAILS on second run

 Rotate logs
mv /var/log/app.log /var/log/app.log.1
rm /var/log/app.log.2  # FAILS if doesn't exist!

 Restart app to create fresh log
systemctl restart myapp

IDEMPOTENT (GOOD):

!/bin/bash
 rotate-logs.sh - SAFE to re-run

 Rotate logs (idempotent - -f means no error if missing)
rm -f /var/log/app.log.2
mv -f /var/log/app.log.1 /var/log/app.log.2 2>/dev/null || true
mv -f /var/log/app.log /var/log/app.log.1 2>/dev/null || true

 Restart app to create fresh log
systemctl restart myapp

 Safe to run multiple times!

IDEM003: Non-idempotent ln -s

Severity: Warning

What it Detects

ln -s (symbolic link creation) without removing existing link first.

Why This Matters

ln -s fails if the target already exists:

$ ln -s /app/v1.0.0 /app/current
$ ln -s /app/v1.0.0 /app/current  # FAILS
ln: failed to create symbolic link '/app/current': File exists

This is especially problematic for deployment scripts that update symlinks.

Examples

NON-IDEMPOTENT (BAD):

!/bin/bash
 deploy.sh - FAILS on second run

VERSION="$1"
RELEASE_DIR="/app/releases/$VERSION"

 Create symlink (FAILS if exists)
ln -s "$RELEASE_DIR" /app/current

Behavior:

First deploy (v1.0.0):  ✅ SUCCESS - symlink created
Second deploy (v1.0.0): ❌ FAILURE - ln fails with "File exists"
Update deploy (v1.0.1): ❌ FAILURE - ln fails, current still points to v1.0.0!

IDEMPOTENT (GOOD):

!/bin/bash
 deploy.sh - SAFE to re-run

VERSION="$1"
RELEASE_DIR="/app/releases/$VERSION"

 Remove old symlink first (idempotent)
rm -f /app/current

 Create new symlink
ln -s "$RELEASE_DIR" /app/current

Behavior:

First deploy (v1.0.0):  ✅ SUCCESS - symlink created to v1.0.0
Second deploy (v1.0.0): ✅ SUCCESS - symlink recreated (no-op)
Update deploy (v1.0.1): ✅ SUCCESS - symlink updated to v1.0.1!

Auto-fix Options

Not auto-fixable - requires manual choice of strategy.

Option 1: Remove then link (recommended):

rm -f /target && ln -s /source /target

Option 2: ln -sf flag (not always portable):

 Works on Linux, may not work on some Unix systems
ln -sf /source /target

Option 3: Conditional link (explicit):

[ -e /target ] && rm /target
ln -s /source /target

Testing for Idempotency

Verify symlink update works:

!/bin/bash
 test-symlink-idempotency.sh

SCRIPT="./deploy.sh"

echo "Testing symlink idempotency"

 Deploy v1.0.0
"$SCRIPT" v1.0.0
TARGET1=$(readlink /app/current)

 Deploy v1.0.0 again (idempotent)
"$SCRIPT" v1.0.0
TARGET2=$(readlink /app/current)

 Update to v1.0.1
"$SCRIPT" v1.0.1
TARGET3=$(readlink /app/current)

 Verify results
if [ "$TARGET1" = "/app/releases/v1.0.0" ] &&
   [ "$TARGET2" = "/app/releases/v1.0.0" ] &&
   [ "$TARGET3" = "/app/releases/v1.0.1" ]; then
    echo "✅ PASS: Symlink updates are idempotent"
else
    echo "❌ FAIL: Symlink not idempotent"
    echo "Deploy 1: $TARGET1"
    echo "Deploy 2: $TARGET2"
    echo "Deploy 3: $TARGET3"
    exit 1
fi

Real-world Example: Blue-Green Deployment

NON-IDEMPOTENT (BAD):

!/bin/bash
 switch-version.sh - FAILS on re-run

NEW_VERSION="$1"
BLUE_DIR="/srv/app-blue"
GREEN_DIR="/srv/app-green"

 Determine which slot is active
if [ -L /srv/app-current ] && [ "$(readlink /srv/app-current)" = "$BLUE_DIR" ]; then
    INACTIVE_DIR="$GREEN_DIR"
else
    INACTIVE_DIR="$BLUE_DIR"
fi

 Deploy to inactive slot
rsync -a "dist/" "$INACTIVE_DIR/"

 Switch symlink (FAILS if already switched!)
ln -s "$INACTIVE_DIR" /srv/app-current

IDEMPOTENT (GOOD):

!/bin/bash
 switch-version.sh - SAFE to re-run

NEW_VERSION="$1"
BLUE_DIR="/srv/app-blue"
GREEN_DIR="/srv/app-green"

 Determine which slot is active
if [ -L /srv/app-current ] && [ "$(readlink /srv/app-current)" = "$BLUE_DIR" ]; then
    INACTIVE_DIR="$GREEN_DIR"
else
    INACTIVE_DIR="$BLUE_DIR"
fi

 Deploy to inactive slot
rsync -a "dist/" "$INACTIVE_DIR/"

 Switch symlink (idempotent - remove first)
rm -f /srv/app-current
ln -s "$INACTIVE_DIR" /srv/app-current

 Safe to run multiple times!

IDEM004: Non-idempotent Variable Appends (Planned)

Status: Not yet implemented

What it Will Detect

Variable append operations that duplicate values on re-run:

 Non-idempotent
PATH="$PATH:/opt/myapp/bin"
 Second run: PATH has /opt/myapp/bin twice!

Why This Will Matter

Repeated execution causes:

  • PATH pollution with duplicates
  • Growing environment variables
  • Performance degradation (PATH search)

Planned Fix

Use idempotent append pattern:

 Idempotent - only add if not present
if [[ ":$PATH:" != *":/opt/myapp/bin:"* ]]; then
    PATH="$PATH:/opt/myapp/bin"
fi

IDEM005: File Creation with > (Planned)

Status: Not yet implemented

What it Will Detect

File creation with > that truncates existing content:

 Non-idempotent
echo "data" > /var/lib/myapp/config
 Re-run appends "data" again? Truncates? Unclear!

Why This Will Matter

> truncates files, making behavior unclear:

  • Loses existing data on re-run
  • Not obvious if intentional
  • Hard to reason about state

Planned Fix

Use explicit patterns:

 Idempotent - only create if doesn't exist
if [ ! -f /var/lib/myapp/config ]; then
    echo "data" > /var/lib/myapp/config
fi

 Or use >> for append (but check for duplicates)
grep -qF "data" /var/lib/myapp/config || echo "data" >> /var/lib/myapp/config

IDEM006: Database Inserts Without Checks (Planned)

Status: Not yet implemented

What it Will Detect

SQL inserts without existence checks:

 Non-idempotent - fails on second run if unique constraint
mysql -e "INSERT INTO users VALUES (1, 'admin')"

Why This Will Matter

Database operations often fail on duplicate:

  • Unique constraint violations
  • Breaks migration scripts
  • Manual re-runs fail

Planned Fix

Use idempotent SQL patterns:

 Idempotent - upsert pattern
mysql -e "INSERT INTO users VALUES (1, 'admin')
          ON DUPLICATE KEY UPDATE name='admin'"

 Or check first
mysql -e "INSERT INTO users SELECT 1, 'admin'
          WHERE NOT EXISTS (SELECT 1 FROM users WHERE id=1)"

Running Idempotency Linting

Lint a Single File

bashrs lint script.sh

Filter Only Idempotency Rules

bashrs lint --rules IDEM script.sh

Lint All Scripts

find . -name "*.sh" -exec bashrs lint --rules IDEM {} \;

CI/CD Integration

# .github/workflows/lint.yml
name: Idempotency Lint
on: [push, pull_request]
jobs:
  idempotency:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install bashrs
        run: cargo install bashrs
      - name: Check idempotency
        run: |
          find . -name "*.sh" -exec bashrs lint --rules IDEM {} \;

Testing Idempotency

Property-Based Testing

Verify scripts are idempotent:

use proptest::prelude::*;

proptest! {
    #[test]
    fn test_script_idempotent(input in "[a-z]{1,10}") {
        // Run script twice with same input
        let state1 = run_script(&input);
        let state2 = run_script(&input);

        // Final state MUST be identical
        prop_assert_eq!(state1, state2);
    }
}

Manual Testing

Run scripts multiple times and verify success:

!/bin/bash
 test-idempotency.sh

SCRIPT="$1"
RUNS=5

echo "Testing idempotency of: $SCRIPT"

for i in $(seq 1 $RUNS); do
    echo "Run $i..."
    if ! "$SCRIPT"; then
        echo "❌ FAIL: Run $i failed"
        exit 1
    fi
done

echo "✅ PASS: All $RUNS runs succeeded"

State Verification

Verify final state is consistent:

!/bin/bash
 test-state-idempotency.sh

SCRIPT="$1"

echo "Testing state idempotency"

 Run once and capture state
"$SCRIPT"
STATE1=$(get_system_state)

 Run again and capture state
"$SCRIPT"
STATE2=$(get_system_state)

 States should be identical
if [ "$STATE1" = "$STATE2" ]; then
    echo "✅ PASS: System state is idempotent"
else
    echo "❌ FAIL: System state differs"
    diff <(echo "$STATE1") <(echo "$STATE2")
    exit 1
fi

Common Patterns

Pattern 1: Idempotent Directory Setup

Always use -p:

!/bin/bash
 setup-dirs.sh

 Idempotent directory creation
mkdir -p /opt/myapp/{bin,lib,data,logs}
mkdir -p /var/log/myapp
mkdir -p /etc/myapp

Pattern 2: Idempotent Cleanup

Always use -f:

!/bin/bash
 cleanup.sh

 Idempotent file removal
rm -f /tmp/build-*
rm -f /var/cache/myapp/*
rm -rf /tmp/myapp-temp

Remove before linking:

!/bin/bash
 update-links.sh

 Idempotent symlink updates
rm -f /usr/local/bin/myapp
ln -s /opt/myapp/v2.0/bin/myapp /usr/local/bin/myapp

Pattern 4: Idempotent Configuration

Check before modifying:

!/bin/bash
 configure.sh

CONFIG_FILE="/etc/myapp/config"

 Idempotent config line addition
if ! grep -qF "setting=value" "$CONFIG_FILE"; then
    echo "setting=value" >> "$CONFIG_FILE"
fi

Benefits of Idempotency

Reliable Automation

Scripts can run repeatedly:

  • Cron jobs safe to re-run
  • Systemd timers don't accumulate errors
  • CI/CD pipelines are resilient

Easy Recovery

Failed operations can be retried:

  • Partial failures can be re-run
  • No manual cleanup needed
  • Rollbacks work cleanly

Safe Operations

Operators can run without fear:

  • "Did I already run this?" - doesn't matter!
  • Re-running is safe
  • No destructive side effects

Better Testing

Tests are more reliable:

  • Can run tests multiple times
  • No test pollution
  • Easier to debug

Further Reading


Quality Guarantee: All IDEM rules undergo comprehensive testing including multiple-run verification to ensure idempotency detection is accurate.