dotlinux guide

Building a Modular Shell Script Framework: A Comprehensive Guide

Table of Contents

  1. Fundamentals of Modular Shell Scripting
  2. Core Components of a Modular Shell Framework
  3. Building the Framework: Step-by-Step
  4. Usage Methods
  5. Common Practices
  6. Best Practices
  7. Example Project: System Maintenance Framework
  8. Conclusion
  9. References

1. Fundamentals of Modular Shell Scripting

1.1 What is Modularity?

Modularity in shell scripting refers to breaking large, complex scripts into smaller, self-contained modules—each responsible for a specific task (e.g., logging, configuration, backups). Modules are reusable, loosely coupled, and interact via well-defined interfaces (e.g., function calls).

1.2 Benefits of Modular Shell Scripts

  • Reusability: Modules (e.g., a logging utility) can be shared across projects.
  • Maintainability: Smaller files are easier to debug and update than monolithic scripts.
  • Collaboration: Teams can work on separate modules without conflicts.
  • Testability: Isolated modules simplify unit testing.
  • Scalability: Add new features by plugging in new modules.

2. Core Components of a Modular Shell Framework

2.1 Directory Structure

A consistent directory structure ensures clarity and scalability. Here’s a standard layout:

project-root/
├── bin/           # Entry point scripts (executable)
├── lib/           # Reusable modules (sourced, not executed)
│   ├── logging.sh
│   ├── config.sh
│   └── utils.sh
├── config/        # Configuration files (e.g., .conf, .env)
├── scripts/       # Task-specific scripts (optional)
├── tests/         # Unit/integration tests
└── docs/          # Documentation (e.g., README, module specs)

2.2 Module Definition

Modules are shell scripts containing functions, variables, or aliases. They are sourced (not executed) using source or . to inject their logic into the parent script.

2.3 Configuration Management

Centralize configuration to avoid hardcoding values. Use:

  • Config files: .conf (key-value pairs) or .env files.
  • Environment variables: Override defaults for flexibility.
  • Command-line arguments: For runtime parameters.

2.4 Error Handling

Robust error handling prevents silent failures. Use:

  • set -euo pipefail: Exit on errors, unset variables, or failed pipeline commands.
  • Custom error functions: Log errors and exit gracefully.

3. Building the Framework: Step-by-Step

Step 1: Set Up the Directory Structure

Start by creating the skeleton:

mkdir -p project-root/{bin,lib,config,tests,docs}
touch project-root/{README.md,.gitignore}

Step 2: Create Reusable Modules

Modules live in lib/. Let’s build a logging.sh module for standardized logging:

lib/logging.sh

#!/usr/bin/env bash
# Purpose: Modular logging utilities
# Usage: source lib/logging.sh

# Default log level (INFO, DEBUG, ERROR)
LOG_LEVEL="${LOG_LEVEL:-INFO}"

# Log an info message to stdout
info() {
    echo "[$(date +'%Y-%m-%d %H:%M:%S')] [INFO] $*"
}

# Log a debug message (only if LOG_LEVEL=DEBUG)
debug() {
    if [ "$LOG_LEVEL" = "DEBUG" ]; then
        echo "[$(date +'%Y-%m-%d %H:%M:%S')] [DEBUG] $*"
    fi
}

# Log an error message to stderr and exit
error() {
    echo "[$(date +'%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2
    exit 1
}

Step 3: Implement Configuration

Add a config.sh module to load settings from config/ files:

lib/config.sh

#!/usr/bin/env bash
# Purpose: Load and manage configuration
# Usage: source lib/config.sh && load_config "config/default.conf"

# Load key-value pairs from a config file
load_config() {
    local config_file="$1"
    if [ ! -f "$config_file" ]; then
        error "Config file $config_file not found"
    fi
    # Source the config file (safely skip comments/empty lines)
    while IFS= read -r line; do
        [[ "$line" =~ ^#.*$ || -z "$line" ]] && continue  # Skip comments/empty lines
        export "$line"  # Export key=value pairs as environment variables
    done < "$config_file"
}

Create a default config file:

config/default.conf

# Default configuration
BACKUP_DIR="/var/backups"
CLEANUP_AGE="30"  # Days to retain backups
LOG_LEVEL="INFO"

Step 4: Add Error Handling

Enforce strictness in the entry point with set -euo pipefail and use the error() function from logging.sh to handle failures.

Step 5: Build the Entry Point

The entry point in bin/ ties modules together. Let’s create maintenance for system tasks:

bin/maintenance

#!/usr/bin/env bash
# Purpose: Entry point for system maintenance (backup/cleanup)
# Usage: ./maintenance <command> [args]

# Enable strict error checking
set -euo pipefail

# Define project root (resolve relative paths)
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

# Source core modules
source "$PROJECT_ROOT/lib/logging.sh"
source "$PROJECT_ROOT/lib/config.sh"

# Load configuration
load_config "$PROJECT_ROOT/config/default.conf"

# Main logic: Dispatch commands
main() {
    local command="$1"
    shift  # Remove command from args

    case "$command" in
        backup)
            info "Starting backup..."
            backup "$@"  # Call backup module
            ;;
        cleanup)
            info "Starting cleanup..."
            cleanup "$@"  # Call cleanup module
            ;;
        *)
            error "Unknown command: $command. Use: backup|cleanup"
            ;;
    esac
}

# Backup function (simplified example)
backup() {
    local src="${1:-/etc}"  # Default: backup /etc
    local dest="${BACKUP_DIR}/$(date +'%Y%m%d')_etc_backup.tar.gz"
    
    info "Backing up $src to $dest"
    tar -czf "$dest" "$src" || error "Backup failed"
    info "Backup completed: $dest"
}

# Cleanup function (simplified example)
cleanup() {
    info "Removing backups older than $CLEANUP_AGE days in $BACKUP_DIR"
    find "$BACKUP_DIR" -name "*.tar.gz" -mtime +"$CLEANUP_AGE" -delete || {
        error "Cleanup failed (no backups to delete?)"
    }
}

# Validate input and run main
if [ $# -eq 0 ]; then
    error "No command provided. Usage: $0 <backup|cleanup>"
fi

main "$@"

4. Usage Methods

4.1 Including Modules

Use source or . to load modules into the entry script:

source "$PROJECT_ROOT/lib/logging.sh"  # Full path
. "lib/config.sh"                     # Relative path (if in project root)

4.2 Passing Parameters to Modules

Modules should use function arguments (not global variables) for isolation:

# In backup module (lib/backup.sh)
backup() {
    local src="$1"
    local dest="$2"
    # ... logic ...
}

# In entry point: Call with arguments
backup "/var/log" "/backups/logs.tar.gz"

4.3 Running the Framework

Make the entry point executable and run:

chmod +x bin/maintenance
cd project-root
./bin/maintenance backup  # Run backup
./bin/maintenance cleanup  # Run cleanup

Override configs with environment variables:

LOG_LEVEL=DEBUG BACKUP_DIR="/tmp/backups" ./bin/maintenance backup

5. Common Practices

5.1 Module Naming Conventions

  • Use descriptive names (e.g., logging.sh, database.sh).
  • Prefix private functions with _ (e.g., _validate_input()).
  • Use snake_case for functions/variables (e.g., cleanup_old_files).

5.2 Version Control for Modules

Track modules in Git with:

  • A VERSION variable in each module: MODULE_VERSION="1.0.0".
  • Changelogs in docs/ to document updates.

5.3 Documentation Standards

Add headers to modules explaining:

  • Purpose, usage, authors, and version.
  • Functions, parameters, and return values.

Example module header:

#!/usr/bin/env bash
# Name: logging.sh
# Version: 1.0.0
# Author: Your Name
# Purpose: Centralized logging with info/debug/error levels
# Functions:
#   info(message): Log info message to stdout
#   debug(message): Log debug message (if LOG_LEVEL=DEBUG)
#   error(message): Log error to stderr and exit(1)

6. Best Practices

6.1 Idempotency

Ensure modules can run multiple times without side effects. For example, check if a file exists before creating it:

# Idempotent backup function
safe_backup() {
    local src="$1" dest="$2"
    if [ ! -f "$dest" ] || [ "$src" -nt "$dest" ]; then  # Only backup if needed
        cp "$src" "$dest"
        info "Backed up $src"
    else
        info "$dest is up to date"
    fi
}

6.2 Security Considerations

  • Sanitize inputs: Reject malicious values (e.g., paths with ../).
    validate_path() {
        if [[ "$1" =~ \.\. ]]; then
            error "Invalid path: $1 (contains '..')"
        fi
    }
  • Restrict permissions: Set chmod 700 on sensitive modules/configs.
  • Avoid eval: It executes arbitrary code; use parameter expansion instead.

6.3 Testing Modules

Test modules with frameworks like Bats (Bash Automated Testing System):

tests/logging_test.bats

#!/usr/bin/env bats

# Load the logging module
source "$BATS_TEST_DIRNAME/../lib/logging.sh"

@test "info() logs to stdout" {
    run info "test message"
    [ "$status" -eq 0 ]
    [[ "$output" == *"[INFO] test message"* ]]
}

@test "debug() logs only if LOG_LEVEL=DEBUG" {
    LOG_LEVEL="INFO"
    run debug "hidden message"
    [ "$status" -eq 0 ]
    [ -z "$output" ]  # No output in INFO mode

    LOG_LEVEL="DEBUG"
    run debug "visible message"
    [[ "$output" == *"[DEBUG] visible message"* ]]
}

Run tests with: bats tests/.

6.4 Performance Optimization

  • Avoid subshells: Use builtins (e.g., [[ ]] instead of [ ]).
  • Batch operations: Process files in loops instead of per-file commands.
  • Cache results: Store expensive command outputs in variables.

7. Example Project: System Maintenance Framework

Let’s expand the earlier example into a full framework with:

  • lib/backup.sh: Advanced backup logic (compression, checksums).
  • lib/cleanup.sh: Cleanup old logs and temp files.
  • config/user.conf: User-specific overrides for default.conf.

Directory structure:

maintenance-framework/
├── bin/
│   └── maintenance       # Entry point
├── lib/
│   ├── logging.sh        # Logging utilities
│   ├── config.sh         # Config loader
│   ├── backup.sh         # Backup logic
│   └── cleanup.sh        # Cleanup logic
├── config/
│   ├── default.conf      # Default settings
│   └── user.conf         # User overrides (optional)
└── tests/
    ├── backup_test.bats
    └── cleanup_test.bats

To use:

# Load user config (overrides defaults)
load_config "config/default.conf"
[ -f "config/user.conf" ] && load_config "config/user.conf"

# Run with custom backup dir
BACKUP_DIR="/mnt/external" ./bin/maintenance backup /home

8. Conclusion

A modular shell script framework transforms messy, one-off scripts into scalable, maintainable tools. By breaking logic into reusable modules, centralizing configs, and following best practices like idempotency and testing, you can build robust automation systems that grow with your needs.

Start small: Identify repeated tasks (logging, config) and extract them into modules. Over time, your framework will become a library of battle-tested utilities that accelerate future projects.

9. References