Author | Nejat Hakan |
nejat.hakan@outlook.de | |
PayPal Me | https://paypal.me/nejathakan |
Shell Scripting Basics
Introduction What is Shell Scripting
Welcome to the world of shell scripting! Before we dive into writing scripts, let's understand the fundamental concepts.
What is a Shell?
In Linux and other Unix-like operating systems, the shell is a command-line interpreter. It's the program that takes the commands you type in your terminal, interprets them, and asks the operating system to perform the corresponding action. Think of it as the primary interface between you (the user) and the operating system's kernel. When you open a terminal window, you are interacting with a shell.
There are various shells available, each with slightly different features and syntax, but the core concepts are similar. Some popular shells include:
- Bash (Bourne Again SHell): The most common default shell on Linux distributions. It's powerful, feature-rich, and what we will primarily focus on in this guide due to its ubiquity.
- sh (Bourne Shell): An earlier, simpler shell. Bash is largely compatible with sh but adds many extensions. Scripts written strictly for sh (
#!/bin/sh
) aim for maximum portability across different Unix-like systems. - Zsh (Z Shell): A powerful shell with many advanced features, including improved tab completion, spelling correction, and themeability.
- Ksh (Korn Shell): Developed by Bell Labs, it blends features from the Bourne shell and the C shell.
- Fish (Friendly Interactive SHell): Known for its user-friendliness and features like syntax highlighting and autosuggestions out-of-the-box.
What is a Script?
In computing, a script is simply a file containing a sequence of commands that are meant to be executed by an interpreter. Instead of typing commands one by one into the terminal, you write them down in a file, and then tell the interpreter (in our case, the shell) to run all the commands in that file in order.
What is Shell Scripting?
Putting it together, shell scripting is the practice of writing scripts (files containing sequences of commands) that are executed by a shell. It's a powerful way to automate repetitive tasks, manage system configurations, perform complex operations, and essentially harness the full power of the command-line environment in an automated fashion.
Why Learn Shell Scripting?
- Automation: The primary driver. Automate backups, system updates, file processing, report generation, software deployment, and countless other tasks.
- Efficiency: Execute complex sequences of commands with a single instruction, saving significant time and effort compared to manual execution.
- Consistency: Ensure tasks are performed exactly the same way every time, reducing the chance of human error.
- Customization: Create custom tools and utilities tailored to your specific needs or workflow.
- System Administration: An essential skill for Linux system administrators for managing users, permissions, services, logs, and more.
- Understanding Linux: Writing shell scripts deepens your understanding of how Linux commands work together and how the system operates.
In this section, we will cover the foundational elements you need to start writing your own useful shell scripts using the Bash shell.
1. Your First Shell Script
Let's jump right in and create our very first shell script. It's a tradition in programming to start with a "Hello, World!" example, and shell scripting is no exception.
The Shebang (#!
)
Almost every shell script should begin with a special line called the "shebang". It looks like this:
#
: The hash symbol typically indicates a comment in shell scripts (text ignored by the interpreter). However, when#!
appears as the very first two characters of a file, it has a special meaning.!
: This is colloquially called the "bang"./bin/bash
: This is the absolute path to the program (the interpreter) that should be used to execute the rest of the commands in the file. In this case, we're explicitly telling the system to use the Bash shell located at/bin/bash
.
Why is the shebang important?
When you try to execute a script file directly (e.g., by typing ./my_script.sh
), the operating system looks at the first line. If it finds a shebang, it uses the specified interpreter (/bin/bash
in our example) to run the script. If the shebang is missing, the system might try to run the script using the user's current default shell, which could be Bash, Zsh, or something else. This can lead to unexpected behavior or errors if the script uses features specific to a particular shell (like Bash) but is run by a different one (like sh). Therefore, explicitly setting the interpreter with a shebang is crucial for predictability and portability. You might sometimes see #!/usr/bin/env bash
, which uses the env
command to find the bash
executable in the user's PATH
, offering slightly more flexibility in some environments, but /bin/bash
is very common and reliable on most Linux systems.
Comments
As mentioned, the #
symbol is used for comments. Any text on a line following a #
(unless it's part of the shebang) is ignored by the shell. Comments are essential for explaining what your script does, why certain commands are used, or how different parts of the script work. Good commenting makes your scripts understandable to others and to your future self!
#!/bin/bash
# This is a full-line comment explaining the script's purpose.
echo "Hello, World!" # This is an inline comment explaining this specific command.
Basic Commands (echo
)
The echo
command is one of the simplest and most frequently used commands. It simply displays the text (or arguments) you give it to the standard output (usually your terminal screen).
Creating the Script File
You can use any text editor to create your script file. Common command-line editors include nano
(beginner-friendly) and vim
(powerful but steeper learning curve). Graphical editors like gedit
, kate
, or VS Code also work perfectly well. Let's use nano
.
- Open your terminal.
- Type
nano hello_world.sh
and press Enter. This opens thenano
editor, creating a new file namedhello_world.sh
. -
Type the following lines into the editor:
-
Save the file: Press
Ctrl+O
(Write Out), confirm the filename (hello_world.sh
) by pressing Enter. - Exit
nano
: PressCtrl+X
.
You now have a file named hello_world.sh
in your current directory.
Making the Script Executable
By default, new files you create usually don't have permission to be executed as programs. You need to explicitly grant this permission.
The chmod
(change mode) command is used to change file permissions. The +x
option adds execute permission.
- In your terminal, type:
chmod +x hello_world.sh
- Press Enter.
To verify the permission change, you can use the ls -l
command (list files in long format):
You should see output similar to this:
Notice the x
characters in the permissions block (rwxrwxr-x
). This indicates that the owner (rwx
), group (rwx
), and others (r-x
) now have execute permission (along with read r
and write w
permissions, which might vary slightly based on your system's default settings).
Running the Script
There are a couple of ways to run your script:
-
Using the Path (Recommended for executable scripts): If you are in the same directory as your script, you need to specify its path relative to the current location. The dot (
.
) represents the current directory.Why
./
? For security reasons, the current directory (.
) is typically not included in the system'sPATH
environment variable (a list of directories where the shell looks for executable programs). Explicitly providing./
tells the shell exactly where to find the script. -
Using the Interpreter Explicitly: You can tell the Bash interpreter to execute the script file directly. In this case, the script doesn't strictly need execute permission (
+x
) or even a shebang (though the shebang is still best practice).
Both methods should produce the same output in the terminal:
Congratulations! You've created and executed your first shell script.
Workshop Creating and Running a Simple System Information Script
Goal: Create a script that displays the current date/time, the logged-in user, and the system's hostname.
Theory Recap: You'll need the shebang, comments, and the echo
command. You'll also need a few new commands:
date
: Displays the current date and time.whoami
: Displays the username of the current user.hostname
: Displays the system's network name.
Steps:
- Create the script file: Open your text editor (e.g.,
nano
) to create a new file namedsystem_info.sh
. - Add the Shebang and Comments: Start the script with the Bash shebang and add comments explaining its purpose.
- Use
echo
and Commands: Addecho
commands to print descriptive labels, and directly call the system commands to get the information.Self-correction/Refinement: Initially, I might just call#!/bin/bash # Script to display basic system information # Date: $(date +%Y-%m-%d) echo "---------------------------------" echo "System Information Report" echo "---------------------------------" echo "Current Date and Time : $(date)" # Use command substitution (explained later, works here!) echo "Logged-in User : $(whoami)" echo "System Hostname : $(hostname)" # Alternatively, run commands directly if output format is okay # echo "Current Date and Time:" # date # echo "Logged-in User:" # whoami # echo "System Hostname:" # hostname echo "---------------------------------" echo "Report Complete." echo "---------------------------------"
date
,whoami
,hostname
. But usingecho
with labels makes the output much clearer. Using$(command)
inside the echo string (called command substitution, covered next) is a neat way to embed the output directly. Let's use that improved version. - Save and Exit: Save the file (
Ctrl+O
, Enter) and exit the editor (Ctrl+X
). - Make Executable: Grant execute permission to the script.
- Verify Permissions (Optional): Check the permissions using
ls -l
. - Run the Script: Execute the script from your terminal.
- Observe the Output: You should see a nicely formatted report showing the current date/time, your username, and your computer's hostname, similar to this (details will vary):
This workshop reinforces creating, making executable, and running a script, while also introducing a few useful system commands.
2. Variables and User Input
Static scripts that do the same thing every time are useful, but scripts become truly powerful when they can handle changing data. This is where variables come in. We also need ways to get information into the script, such as from the user running it.
Variables
A variable is essentially a named placeholder for a piece of data (a string of text, a number, etc.). You can assign a value to a variable and then refer to that value later using the variable's name.
Naming Conventions:
- Variable names can contain letters (a-z, A-Z), numbers (0-9), and underscores (
_
). - The first character must be a letter or an underscore.
- Variable names are case-sensitive (
myVar
is different frommyvar
). - Best Practice: Use descriptive names (e.g.,
user_name
,file_count
,TARGET_DIRECTORY
). By convention, environment variables and constants are often written inUPPERCASE
, while local script variables are oftenlowercase
orsnake_case
.
Assigning Values:
You assign a value to a variable using the equals sign (=
). Crucially, there must be no spaces around the =
sign.
# Correct assignment
first_name="Alice"
user_count=10
CONFIG_FILE="/etc/myapp.conf"
# Incorrect assignments (will cause errors)
# first_name = "Alice" # Error: space before =
# user_count= 10 # Error: space after =
Accessing Variable Values:
To use the value stored in a variable, you prefix its name with a dollar sign ($
).
greeting="Hello"
name="Bob"
echo $greeting $name # Output: Hello Bob
# It's often safer to enclose the variable name in curly braces: ${variable_name}
# This avoids ambiguity, especially when the variable name is followed by characters
# that could be part of a variable name.
fruit="apple"
echo "I like ${fruit}s" # Output: I like apples
echo "I like $fruits" # Output: I like (Tries to find a variable named 'fruits', which is likely unset)
Using ${variable_name}
is generally recommended for clarity and safety.
Quoting:
How you enclose the value during assignment or when using echo
matters:
- Double Quotes (
"
): Preserve literal values of most characters, but allow variable expansion ($variable
), command substitution ($(command)
), and arithmetic expansion ($((expression))
). This is the most commonly used type of quoting. - Single Quotes (
'
): Preserve the literal value of every character within the quotes. No expansion occurs. Useful when you want to treat characters like$
literally. - No Quotes: The shell performs word splitting (splitting the value based on spaces, tabs, newlines) and filename expansion (globbing, e.g.,
*
expands to filenames). This can often lead to unexpected behavior, especially if the value contains spaces or special characters. It's generally safest to double-quote variable expansions (echo "$variable"
) unless you specifically need word splitting or globbing.
Command Substitution:
You can capture the output of a command and store it in a variable. There are two syntaxes:
$(command)
: The modern, preferred syntax. It's easier to nest.`command`
(Backticks): The older syntax. Harder to read and nest. Avoid if possible.
current_date=$(date +%Y-%m-%d) # Preferred syntax
echo "Today's date is: $current_date"
# Old syntax (works but less clear)
# kernel_version=`uname -r`
# echo "Kernel: $kernel_version"
# Using preferred syntax
kernel_version=$(uname -r)
echo "Kernel: $kernel_version"
Reading User Input (read
)
The read
command is used to get input from the user typing in the terminal and store it in one or more variables.
echo "Please enter your name:"
read user_name # Waits for user input, stores it in user_name variable
echo "Please enter your city and country (separated by space):"
read city country # Reads first word into city, the rest of the line into country
echo "Hello, $user_name from $city, $country!"
It's often more user-friendly to display a prompt on the same line where the user types. Use the -p
(prompt) option for this:
read -p "Enter your favorite color: " favorite_color
echo "You chose $favorite_color."
# Reading sensitive data (like passwords) without showing it on screen
read -s -p "Enter your password: " user_password
echo # Print a newline after the hidden input for cleaner output
echo "Password received (but not shown)."
Workshop Personalized Greeting Script
Goal: Create a script that asks the user for their name and department, then displays a personalized welcome message including the current time.
Theory Recap: You'll use variable assignment, $variable
access (with ${}
), double quotes for expansion, read -p
for user input, and command substitution ($(date)
).
Steps:
- Create the script file:
- Add Shebang and Initial Comments:
- Get User Input: Use
read -p
to prompt the user for their name and department and store the input in variables. - Get Current Time: Use command substitution to store the current time (in a specific format) in a variable. The
date
command has many formatting options (seeman date
). Let's use Hour:Minute AM/PM format (%r
). - Display Personalized Greeting: Use
echo
with double quotes to combine the variables and static text into a meaningful message. Use${}
for clarity. - Save and Exit: Save (
Ctrl+O
, Enter) and exit (Ctrl+X
). - Make Executable:
- Run the Script:
- Interact and Observe: The script will pause and prompt you for input. Enter the requested details and press Enter after each prompt. Observe the final output.
This workshop provides hands-on practice with variable assignment, accessing variable values within strings, capturing command output, and interacting with the user via read
.
3. Control Flow Conditional Statements
Scripts often need to make decisions based on certain conditions. Should we create a directory if it doesn't exist? Should we process a file only if it's larger than a certain size? Conditional statements allow our scripts to execute different blocks of code depending on whether specific conditions are true or false.
The if
Statement
The most fundamental conditional statement is if
. Its basic structure is:
if [ condition ]; then
# Commands to execute if the condition is true
command1
command2
fi # 'fi' marks the end of the if block
if
: Keyword starting the conditional.[ condition ]
: This is the core of the decision. The square brackets[
and]
are actually a command, synonymous with thetest
command. It evaluates thecondition
and exits with a status code: 0 (true/success) if the condition is met, or a non-zero value (false/failure) if it's not. Crucially, there must be spaces around the brackets and often around operators inside the brackets.; then
: The semicolon;
acts as a command separator, allowingthen
to be on the same line. Alternatively,then
can be on the next line without a preceding semicolon.then
marks the beginning of the code block to execute if the condition is true.commands
: One or more commands to execute if the condition evaluates to true (exit status 0). Indentation here is purely for readability and is highly recommended.fi
: Keyword marking the end of theif
statement (if spelled backward).
Modern Bash [[ ... ]]
:
Bash provides an enhanced version of the test
command using double square brackets: [[ condition ]]
. This is generally preferred over single brackets [ ... ]
when writing Bash-specific scripts because:
- It prevents word splitting and filename expansion issues for variables within the brackets, meaning you often don't need to quote variables inside
[[ ]]
(though it's still good practice). - It supports more advanced features like pattern matching (
=~
) and logical operators (&&
,||
) directly inside the brackets. - It's a keyword, not a command, which can make it slightly more efficient and less prone to certain errors.
We will primarily use [[ ... ]]
in our examples.
The test
Command / Square Brackets ([
or [[
) Conditions:
The condition
inside the brackets is typically an expression that test
(or [[ ]]
) evaluates. Common types of conditions include:
-
String Comparisons:
[[ "$string1" == "$string2" ]]
: True if strings are identical. (Note:=
works too, but==
is clearer for comparison). Inside[[ ]]
,==
also performs pattern matching on the right side if unquoted.[[ "$string1" = "$string2" ]]
: Also checks for string equality.=
is the POSIX standard equivalent for[ ]
.[[ "$string1" != "$string2" ]]
: True if strings are not identical.[[ -z "$string" ]]
: True if the string is empty (zero length).[[ -n "$string" ]]
: True if the string is not empty (non-zero length).[[ "$string1" < "$string2" ]]
: True if string1 sorts lexicographically before string2. (Requires[[ ]]
).[[ "$string1" > "$string2" ]]
: True if string1 sorts lexicographically after string2. (Requires[[ ]]
). Important: Always double-quote variables in string comparisons, especially when using single brackets[ ]
, to handle empty strings or strings with spaces correctly. It's good practice even within[[ ]]
.
-
Numeric Comparisons: (Use these for integers only)
[[ "$num1" -eq "$num2" ]]
: True if numbers are equal (equal
).[[ "$num1" -ne "$num2" ]]
: True if numbers are not equal (not equal
).[[ "$num1" -lt "$num2" ]]
: True if num1 is less than num2 (less than
).[[ "$num1" -le "$num2" ]]
: True if num1 is less than or equal to num2 (less or equal
).[[ "$num1" -gt "$num2" ]]
: True if num1 is greater than num2 (greater than
).[[ "$num1" -ge "$num2" ]]
: True if num1 is greater than or equal to num2 (greater or equal
).
-
File Tests: (Check properties of files/directories)
[[ -e "$path" ]]
: True if the path exists (file or directory or link, etc.) (exists
).[[ -f "$path" ]]
: True if the path exists and is a regular file (file
).[[ -d "$path" ]]
: True if the path exists and is a directory (directory
).[[ -L "$path" ]]
: True if the path exists and is a symbolic link (link
).[[ -r "$path" ]]
: True if the path exists and is readable (readable
).[[ -w "$path" ]]
: True if the path exists and is writable (writable
).[[ -x "$path" ]]
: True if the path exists and is executable (executable
).[[ -s "$path" ]]
: True if the path exists and has a size greater than zero (size > 0
).[[ "$file1" -nt "$file2" ]]
: True if file1 is newer than file2 (newer than
).[[ "$file1" -ot "$file2" ]]
: True if file1 is older than file2 (older than
).
-
Logical Operators: (Combine conditions)
[[ condition1 && condition2 ]]
: Logical AND (both must be true). (Requires[[ ]]
). For[ ]
, use[ condition1 -a condition2 ]
.[[ condition1 || condition2 ]]
: Logical OR (at least one must be true). (Requires[[ ]]
). For[ ]
, use[ condition1 -o condition2 ]
.[[ ! condition ]]
: Logical NOT (inverts the truthiness of the condition). Works in both[[ ]]
and[ ]
.
Example if
statement:
#!/bin/bash
read -p "Enter a number: " number
if [[ "$number" -gt 10 ]]; then
echo "The number $number is greater than 10."
fi
read -p "Enter a filename: " filename
if [[ -f "$filename" && -r "$filename" ]]; then
echo "The file '$filename' exists and is readable."
fi
The if-else
Statement
What if you want to do something else if the condition is false? Use if-else
.
if [[ condition ]]; then
# Commands if condition is true
commands_if_true
else
# Commands if condition is false
commands_if_false
fi
Example if-else
:
#!/bin/bash
read -p "Enter your age: " age
if [[ "$age" -ge 18 ]]; then
echo "You are eligible to vote."
else
echo "You are not yet eligible to vote."
fi
The if-elif-else
Statement
For checking multiple conditions in sequence, use if-elif-else
. elif
is short for "else if".
if [[ condition1 ]]; then
# Commands if condition1 is true
commands1
elif [[ condition2 ]]; then
# Commands if condition1 is false AND condition2 is true
commands2
elif [[ condition3 ]]; then
# Commands if condition1 and condition2 are false AND condition3 is true
commands3
else
# Commands if ALL preceding conditions are false
commands_else
fi
The shell evaluates the conditions in order. As soon as it finds a true condition, it executes the corresponding commands and skips the rest of the elif
/else
blocks. The final else
is optional and acts as a default case if none of the if
or elif
conditions are met.
Example if-elif-else
:
#!/bin/bash
read -p "Enter a score (0-100): " score
if [[ "$score" -ge 90 ]]; then
grade="A"
elif [[ "$score" -ge 80 ]]; then
grade="B"
elif [[ "$score" -ge 70 ]]; then
grade="C"
elif [[ "$score" -ge 60 ]]; then
grade="D"
else
grade="F"
fi
echo "Your grade is: $grade"
The case
Statement
When you need to check a single variable against multiple possible patterns, the case
statement is often cleaner and more readable than a long chain of if-elif
statements comparing the same variable.
case $variable in
pattern1)
# Commands if variable matches pattern1
commands1
;; # Double semicolon terminates the block
pattern2|pattern3) # Use | for OR logic within a pattern
# Commands if variable matches pattern2 OR pattern3
commands2
;;
pattern*) # Use wildcards (* for any chars, ? for single char, [...] for char range/set)
# Commands if variable matches pattern*
commands3
;;
*) # Default case: matches anything not matched above
# Commands if no other pattern matched
default_commands
;;
esac # 'esac' marks the end of the case block (case spelled backward)
case $variable in
: Starts the statement, specifying the variable to check.pattern)
: Defines a pattern to match against the variable's value. Patterns can include literal text and shell wildcards (*
,?
,[...]
).commands
: Code to execute if the pattern matches.;;
: Crucial: Terminates the commands for a specific pattern block. Without;;
, execution would fall through to the next pattern's commands (unlikeswitch
statements in C/Java which requirebreak
).*)
: A pattern that matches anything, typically used as the default case at the end.esac
: Ends thecase
statement.
Example case
Statement:
#!/bin/bash
read -p "Enter 'start', 'stop', or 'status': " action
case $action in
start|START)
echo "Starting the service..."
# Add commands to start service here
;;
stop|STOP)
echo "Stopping the service..."
# Add commands to stop service here
;;
status|STATUS)
echo "Checking service status..."
# Add commands to check status here
;;
*)
echo "Usage: $0 {start|stop|status}" # $0 is the script name
echo "Error: Invalid action '$action'"
;;
esac
Workshop Simple File Analyzer
Goal: Create a script that accepts a single argument (a path to a file or directory). The script should check if the argument was provided, if the path exists, and then report whether it's a regular file, a directory, or something else.
Theory Recap: You'll use if
, elif
, else
. You'll need file test operators (-e
, -f
, -d
). You'll also encounter special shell variables:
$#
: Holds the number of command-line arguments passed to the script.$1
: Holds the value of the first command-line argument ($2
for the second, etc.).$0
: Holds the name of the script itself.exit <number>
: Terminates the script immediately with a specific exit status (0 for success, non-zero for error).
Steps:
- Create the script file:
- Add Shebang and Comments:
- Check Number of Arguments: The script requires exactly one argument. Check
$#
. If it's not equal to 1 (-ne
), print a usage message and exit with an error status (e.g., 1). Error messages should ideally go to standard error (>&2
).#!/bin/bash # Script to analyze a path provided as a command-line argument. # It checks if the path exists and identifies its type (file/directory). # Check if exactly one argument is provided if [[ $# -ne 1 ]]; then echo "Usage: $0 <path>" >&2 echo "Error: Please provide exactly one argument." >&2 exit 1 # Exit with a non-zero status indicating an error fi
- Store Argument in Variable: Store the first argument (
$1
) in a more descriptive variable. It's good practice to quote it in case the path contains spaces. -
Check if Path Exists: Use
if [[ -e "$target_path" ]]
to check for existence.Refinement: Added more detail within the file/directory checks and specific exit codes. Usedecho "Analyzing path: '$target_path'" if [[ -e "$target_path" ]]; then # Path exists, now determine the type echo -n "'$target_path' exists. It is " # -n keeps echo on the same line if [[ -f "$target_path" ]]; then echo "a regular file." # Optional: Add more checks like readability/writability if [[ -r "$target_path" ]]; then echo " - It is readable." fi if [[ -w "$target_path" ]]; then echo " - It is writable." fi if [[ -x "$target_path" ]]; then echo " - It is executable." fi elif [[ -d "$target_path" ]]; then echo "a directory." # Optional: List contents? # echo " - Contents:" # ls -l "$target_path" # Be careful with large directories else echo "neither a regular file nor a directory (e.g., link, device file)." fi else # Path does not exist echo "Error: The path '$target_path' does not exist." >&2 exit 2 # Use a different exit code for different errors fi # If we reached here, analysis was successful (path existed) exit 0 # Explicitly exit with success status
echo -n
to build the output message more smoothly. Redirected error messages tostderr
. -
Save and Exit: Save (
Ctrl+O
, Enter) and exit (Ctrl+X
). - Make Executable:
- Test: Run the script with different inputs:
- No arguments:
- A non-existent path:
- An existing file (e.g., the script itself):
- An existing directory (e.g., your home directory
~
or/tmp
): - A path with spaces (create one first):
This workshop demonstrates decision-making using if/elif/else
, handling command-line arguments ($#
, $1
), checking file attributes (-e
, -f
, -d
, etc.), and controlling script termination (exit
).
4. Control Flow Loops
Loops are fundamental constructs that allow you to execute a block of code repeatedly. This is essential for processing lists of items, reading files line by line, or performing actions a specific number of times. Bash provides several looping mechanisms, primarily for
and while
.
The for
Loop
The for
loop iterates over a list of items (words, filenames, numbers, etc.) and executes a block of code once for each item in the list.
1. Iterating Over an Explicit List:
for variable_name in item1 item2 item3 ... itemN; do
# Commands to execute for each item
# The current item is available in $variable_name
echo "Processing item: $variable_name"
done # 'done' marks the end of the loop block
for variable_name
: Specifies the variable that will hold the current item during each iteration.in item1 item2 ...
: Provides the list of items to iterate over. Items are separated by whitespace.do
: Marks the beginning of the code block to be executed for each item.commands
: The code to be executed in each iteration.done
: Marks the end of thefor
loop.
Example:
#!/bin/bash
echo "Iterating over colors:"
for color in red green blue yellow; do
echo " - Current color: $color"
sleep 1 # Pause for 1 second
done
echo "Finished iterating."
2. Iterating Over Variable Content:
If a variable contains a space-separated list, you can iterate over it. Be mindful of quoting.
#!/bin/bash
file_list="report.txt data.csv image.jpg"
echo "Processing files (unquoted, risky):"
# Unquoted: Shell performs word splitting on $file_list
for file in $file_list; do
echo " -> $file"
done
echo # Blank line for separation
# If a filename contains spaces, the above will fail.
filename_with_space="My Document.pdf"
all_files="$file_list $filename_with_space" # Add the tricky filename
echo "Processing files including spaces (unquoted, breaks):"
for file in $all_files; do
echo " -> $file" # Output: My, Document.pdf (split incorrectly)
done
echo # Blank line
# Better approach: Use arrays (covered later) or handle quoting carefully.
# For simple lists stored in a variable, quoting the expansion often doesn't help
# as the 'in' part expects separate words. Arrays are the robust solution.
# However, let's show iteration over command output which is very common.
3. Iterating Over Command Output:
Use command substitution $(command)
to generate the list.
#!/bin/bash
echo "Listing files in /etc whose names start with 'a':"
# Use command substitution to get the list
# Important: Use 'ls -b' or similar if filenames might contain newlines/special chars,
# or better yet, use find (see below). This is a simple example.
for item in $(ls /etc | grep '^a'); do
echo " - Found: $item"
done
ls
is generally discouraged for complex scripting due to potential issues with filenames containing spaces, newlines, or special characters. Using find
with -print0
and xargs -0
or a while read
loop is often more robust for file processing (see while
loop section).
4. Iterating Over Files (Globbing):
You can use wildcards (globbing patterns like *
, ?
, [...]
) directly in the for
loop. The shell expands the pattern into a list of matching filenames. This is a very common and useful technique.
#!/bin/bash
echo "Processing all .txt files in the current directory:"
# The shell expands *.txt into a list of matching filenames
for textfile in *.txt; do
# Important check: If no .txt files exist, *.txt might be passed literally.
# Check if the item is actually a file before processing.
if [[ -f "$textfile" ]]; then
echo " - Processing '$textfile'..."
word_count=$(wc -w < "$textfile") # Count words in the file
echo " Word count: $word_count"
else
echo " - No .txt files found or '$textfile' is not a file."
# Optionally break the loop if no files were found on the first iteration
# if [[ "$textfile" == "*.txt" ]]; then break; fi # Simple check
fi
done
# More robust check for no matches using nullglob (Bash specific)
shopt -s nullglob # Make globs expand to nothing if no match
log_files=(*.log) # Store matches in an array
shopt -u nullglob # Turn off nullglob
if [[ ${#log_files[@]} -eq 0 ]]; then
echo "No .log files found."
else
echo "Processing log files found:"
for logfile in "${log_files[@]}"; do # Iterate over array elements
echo " - Found log file: '$logfile'"
# Process the log file
done
fi
nullglob
and arrays for safer handling of cases where no files match the glob pattern. Also added the check [[ -f "$textfile" ]]
inside the loop.
5. C-Style for
Loop (Bash Specific):
Bash also supports a C-style for
loop, useful for numeric ranges.
Example:
#!/bin/bash
echo "Counting from 1 to 5:"
for (( i=1; i<=5; i++ )); do
echo " Number: $i"
done
echo "Countdown:"
for (( count=3; count>=0; count-- )); do
echo " T-minus $count..."
sleep 1
done
echo " Liftoff!"
The while
Loop
The while
loop executes a block of code as long as a specified condition remains true (evaluates to an exit status of 0).
while [[ condition ]]; do
# Commands to execute as long as condition is true
commands
# It's crucial that something inside the loop eventually makes the condition false,
# otherwise you create an infinite loop!
done
Example (Counter):
#!/bin/bash
counter=0
max_count=5
echo "While loop counting up:"
while [[ $counter -lt $max_count ]]; do
echo " Counter is $counter"
# Increment the counter (essential to eventually stop the loop)
((counter++)) # Arithmetic expansion for incrementing
done
echo "Loop finished."
Reading Files Line by Line (Common Use Case):
A very common and powerful use of while
is to read a file line by line.
#!/bin/bash
input_file="my_data.txt"
# Create a dummy input file for testing
echo "Line 1: Some data" > "$input_file"
echo "Line 2: More data" >> "$input_file"
echo "Line 3: Final line" >> "$input_file"
echo "Reading '$input_file' line by line:"
# Check if file exists and is readable
if [[ ! -f "$input_file" || ! -r "$input_file" ]]; then
echo "Error: Cannot read file '$input_file'" >&2
exit 1
fi
line_num=0
# 'read line' reads one line, exits with non-zero (false) at EOF
# '< "$input_file"' redirects the file content to the standard input of the while loop
while IFS= read -r line; do
((line_num++))
echo " Line $line_num: -->$line<--"
done < "$input_file"
echo "Finished reading file."
# Clean up dummy file
# rm "$input_file"
while IFS= read -r line; do
: This is the standard, robust way to read lines.IFS=
: Temporarily clears the Internal Field Separator variable. This prevents leading/trailing whitespace from being stripped from the lines read.read -r
: The-r
option prevents backslash interpretation (raw read), ensuring backslashes in the file are treated literally.line
: The variable where each line's content is stored.done < "$input_file"
: This redirects the entire content of$input_file
to the standard input of thewhile
loop. Theread
command then consumes this input line by line.read
returns a non-zero exit status when it reaches the end of the file (EOF), causing thewhile
condition to become false and the loop to terminate.
The until
Loop
The until
loop is similar to while
, but it executes the code block as long as the condition is false. It stops when the condition becomes true.
until [[ condition ]]; do
# Commands to execute as long as condition is false
commands
# Something inside the loop should eventually make the condition true
done
Example:
#!/bin/bash
# Wait until a specific file exists
target_file="wait_for_me.flag"
echo "Waiting for file '$target_file' to appear..."
until [[ -e "$target_file" ]]; do
echo " File not found yet. Waiting 5 seconds..."
sleep 5
# In a real scenario, another process would create this file.
# For testing: touch wait_for_me.flag
done
echo "File '$target_file' found! Proceeding..."
# rm "$target_file" # Clean up
until
is less common than while
, but can be useful for conditions that express a goal to be reached (e.g., "keep trying until successful").
Loop Control (break
and continue
)
You can alter the flow of execution within loops:
break
: Immediately exits the current (innermost) loop entirely. Execution continues with the first command after thedone
keyword.continue
: Skips the rest of the commands in the current iteration of the loop and jumps to the beginning of the next iteration.
Example (break
and continue
):
#!/bin/bash
echo "Looping from 1 to 10, skipping 5, breaking at 8:"
for i in {1..10}; do # {1..10} is brace expansion generating numbers 1 to 10
if [[ "$i" -eq 5 ]]; then
echo " Skipping number 5 (continue)..."
continue # Go to the next iteration (i=6)
fi
if [[ "$i" -eq 8 ]]; then
echo " Reached 8, breaking out of loop..."
break # Exit the for loop completely
fi
echo " Processing number $i"
done
echo "Loop finished."
Workshop Backup Script for Specific File Types
Goal: Create a script that finds all files with a .log
extension in the current directory and copies them into a uniquely named backup subdirectory. The backup directory should be created if it doesn't exist, and its name should include the current date and time.
Theory Recap: You'll use a for
loop with globbing (*.log
), command substitution ($(date)
), file tests (-f
, -d
), the mkdir
command (make directory), and the cp
command (copy).
Steps:
- Create the script file:
- Add Shebang and Comments:
- Define Backup Directory Name: Create a unique directory name using the current date and time. Use
date
with a suitable format (e.g., YearMonthDay_HourMinuteSecond). - Create Backup Directory: Check if the directory already exists (unlikely, but good practice). If not, create it using
mkdir
. Add an informational message. Exit ifmkdir
fails (e.g., due to permissions).Refinement: Added check onecho "Preparing backup directory: '$backup_dir'" if [[ ! -d "$backup_dir" ]]; then if mkdir "$backup_dir"; then echo "Successfully created directory: '$backup_dir'" else echo "Error: Failed to create directory '$backup_dir'. Check permissions." >&2 exit 1 fi fi
mkdir
success. - Find and Copy Log Files: Use a
for
loop with*.log
globbing. Inside the loop, verify that the item found is a regular file (-f
) before attempting to copy it usingcp
. Provide feedback for each file copied.Refinement: Usedecho "Starting backup of .log files..." log_file_count=0 # Use nullglob for safety in case no .log files exist shopt -s nullglob for logfile in *.log; do # Although nullglob helps, checking -f is still good practice in case # the glob matches something unexpected (like a directory named *.log) if [[ -f "$logfile" ]]; then echo " - Copying '$logfile' to '$backup_dir/'..." if cp "$logfile" "$backup_dir/"; then ((log_file_count++)) else echo " Warning: Failed to copy '$logfile'. Skipping." >&2 fi fi done shopt -u nullglob # Reset nullglob echo "Backup process complete." echo "Total log files backed up: $log_file_count"
nullglob
, added check oncp
success, and added a counter for backed-up files. - Add Final Message: Inform the user about the outcome.
if [[ $log_file_count -gt 0 ]]; then echo "Backup successful. Files are in '$backup_dir'." else echo "No .log files were found in the current directory to back up." # Optional: Remove the empty backup directory if nothing was copied # echo "Removing empty backup directory '$backup_dir'." # rmdir "$backup_dir" fi exit 0
- Save and Exit: Save (
Ctrl+O
, Enter) and exit (Ctrl+X
). - Make Executable:
- Test:
- Create dummy log files:
- Run the script:
- Observe: You should see output indicating the creation of the backup directory and the copying of
app_error.log
,access.log
, anddebug_info.log
. Thetricky_dir.log
andnot_a_log_file.txt
should be ignored. - Verify: Use
ls
to see the newlog_backups_YYYYMMDD_HHMMSS
directory. Usels log_backups_*/
to see the copied log files inside it. - Run again: Execute
./backup_logs.sh
again. A new timestamped directory should be created, and the logs copied again into that new directory. - Test with no log files: Remove the dummy log files (
rm *.log
) and run./backup_logs.sh
again. It should report that no log files were found.
This workshop provides practice with loops (for
), globbing (*.log
), command substitution (date
), directory creation (mkdir
), file copying (cp
), and basic error checking within a loop.
5. Functions
As your scripts grow larger and more complex, you'll find yourself repeating blocks of code. Functions allow you to group a sequence of commands under a single name, making your scripts more modular, readable, reusable, and easier to maintain.
What are Functions?
A function is a named block of code that performs a specific task. Once defined, you can "call" the function by its name whenever you need to execute that task, instead of rewriting the code block each time.
Why Use Functions?
- Modularity: Break down a complex script into smaller, manageable, logical units. Each function handles one specific part of the overall task.
- Reusability: Write a piece of code once (inside a function) and call it multiple times from different parts of your script, or even from other scripts (by sourcing the file containing the function).
- Readability: Well-named functions make the main part of your script easier to follow, as it reads more like a sequence of steps rather than a huge block of commands.
- Maintainability: If you need to update the logic for a specific task, you only need to change it in one place (inside the function definition).
- Debugging: It's often easier to test and debug individual functions than one monolithic script.
Defining Functions
There are two common syntaxes for defining functions in Bash:
Syntax 1 (Preferred, POSIX-compatible):
Syntax 2 (Bash keyword):
function_name
: The name you choose for your function. Follow similar naming conventions as variables (letters, numbers, underscores; start with letter or underscore). Choose descriptive names.()
: Parentheses are required after the function name in the first syntax.{ ... }
: Curly braces enclose the body of the function (the commands). There must be a space after the opening brace{
or a newline, and the closing brace}
must be preceded by a semicolon;
or a newline if it's on the same line as the last command (usually, it's placed on its own line).function
keyword: Optional in Bash (Syntax 2). The first syntax (name() { ... }
) is more common and compatible with other shells likesh
. We will use Syntax 1.
Example Definition:
# Function to print a separator line
print_separator() {
echo "-----------------------------------------"
}
# Function to greet a user
greet_user() {
echo "Hello there!"
}
Calling Functions
To execute the code inside a function, simply use its name as if it were a regular command.
#!/bin/bash
# Define the functions first
print_separator() {
echo "========================================="
}
greet_user() {
echo "Welcome to the function example script!"
}
# Now call the functions
print_separator # Calls the print_separator function
greet_user # Calls the greet_user function
print_separator # Call it again!
Important: You must define a function before you call it in the script's execution flow. It's common practice to put all function definitions at the top of the script.
Passing Arguments to Functions
Functions can accept input values, called arguments (or parameters), similar to how scripts accept command-line arguments. Inside the function, these arguments are accessed using the same positional parameters:
$1
: The first argument passed to the function.$2
: The second argument passed to the function, and so on.$#
: The number of arguments passed to the function.$@
: Represents all arguments passed to the function as individual quoted strings ("$1" "$2" ...
). This is usually the preferred way to pass all arguments along to another command within the function.$*
: Represents all arguments passed to the function as a single string, joined by the first character of theIFS
variable (usually a space) ("$1 $2 ..."
). Less commonly used than$@
.
Example with Arguments:
#!/bin/bash
# Function to greet a specific user
personalized_greet() {
local user_name="$1" # Store the first argument in a local variable
local greeting="Hello"
# Check if a name was provided
if [[ -z "$user_name" ]]; then
echo "Usage: personalized_greet <name>" >&2
return 1 # Indicate an error (explained next)
fi
echo "$greeting, $user_name! Welcome."
}
# Function to sum two numbers
add_numbers() {
local num1="$1"
local num2="$2"
local sum=$((num1 + num2)) # Arithmetic expansion
echo "The sum of $num1 and $num2 is: $sum"
}
# Call the functions with arguments
personalized_greet "Alice"
personalized_greet "Bob"
personalized_greet # Call without arguments to see the error handling
echo # Blank line
add_numbers 5 10
add_numbers 100 23
Variable Scope (local
)
By default, variables defined within a script are global, meaning they are accessible anywhere in the script, both inside and outside functions. This can lead to conflicts if a function accidentally modifies a global variable that's used elsewhere.
To create variables that exist only within a function (local scope), use the local
keyword before the variable assignment. This is highly recommended for variables that are only needed inside the function to avoid polluting the global namespace and prevent unintended side effects.
#!/bin/bash
global_var="I am global"
my_function() {
local local_var="I am local to my_function"
echo "Inside function: local_var = '$local_var'"
echo "Inside function: global_var = '$global_var'"
# Modify global_var (possible but often discouraged without clear intent)
global_var="Modified by function"
}
echo "Before function: global_var = '$global_var'"
# echo "Before function: local_var = '$local_var'" # This would cause an error - local_var doesn't exist yet
my_function
echo "After function: global_var = '$global_var'" # Shows the modification
# echo "After function: local_var = '$local_var'" # This still causes an error - local_var is gone
Best Practice: Use local
for all variables inside your functions unless you explicitly intend for the function to modify a global variable.
Returning Values from Functions
Shell functions don't return arbitrary data (like strings or arrays) in the same way as functions in languages like Python or C. They primarily return an exit status (an integer between 0 and 255) using the return
command.
return 0
: Indicates success (this is the default if noreturn
is specified or if the last command in the function succeeded).return <non-zero>
: Indicates failure or a specific error condition (e.g.,return 1
,return 5
).
The exit status of the last executed command in the function is used as the return status if return
is not explicitly used.
You can capture the exit status of a function immediately after calling it using the $?
special variable.
#!/bin/bash
check_file_exists() {
local file_path="$1"
if [[ -e "$file_path" ]]; then
echo "File '$file_path' found."
return 0 # Success
else
echo "File '$file_path' not found."
return 1 # Failure
fi
}
# Call the function and check its exit status
check_file_exists "/etc/passwd"
status=$? # Capture exit status immediately
echo "Exit status for /etc/passwd check: $status"
echo # Blank line
check_file_exists "/non/existent/file"
status=$?
echo "Exit status for non-existent file check: $status"
echo # Blank line
# Using the function directly in an if statement
if check_file_exists "/etc/hosts"; then
echo "/etc/hosts check succeeded (returned 0)."
else
echo "/etc/hosts check failed (returned non-zero)."
fi
How to Get Data Out of a Function:
If you need a function to produce data (like a calculated value or a processed string) rather than just a success/failure status, the standard shell way is to have the function print the data to standard output (echo
) and then capture that output using command substitution $(...)
when you call the function.
#!/bin/bash
# Function that "returns" a string by printing it
get_greeting() {
local name="$1"
if [[ -z "$name" ]]; then
name="Guest"
fi
echo "Welcome, $name!" # Print the result to standard output
}
# Function that "returns" a number by printing it
calculate_square() {
local num="$1"
local square=$((num * num))
echo "$square" # Print the result to standard output
}
# Call the functions and capture their output
greeting_message=$(get_greeting "Alice")
guest_greeting=$(get_greeting)
number_squared=$(calculate_square 8)
echo "Captured greeting 1: $greeting_message"
echo "Captured greeting 2: $guest_greeting"
echo "Captured square: $number_squared"
# Note: Any other 'echo' statements inside the function will also be captured!
# Keep functions intended for data capture focused on printing only the result.
Workshop Refactoring the File Analyzer with Functions
Goal: Take the file_analyzer.sh
script created in Workshop 3 and refactor it to use functions for better organization and clarity.
Theory Recap: Define functions using name() { ... }
, call functions, use local
variables, handle arguments ($1
), and use return
for status / echo
for data capture.
Steps:
- Copy the previous script:
- Open the new script:
-
Identify Code Blocks to Functionalize: Look at the script's logic:
- Checking arguments and printing usage.
- Checking if a path exists.
- Determining the type (file/directory/other) of an existing path.
- Printing file details (read/write/execute).
-
Define Functions: Create functions for these blocks at the top of the script.
-
print_usage()
function: Handles printing the usage message and exiting. -
check_path_existence()
function: Takes a path as an argument, checks existence, prints a message, and returns 0 (success) if it exists, 1 (failure) if not.# Function to check if a path exists # Arguments: $1 = path to check # Returns: 0 if exists, 1 otherwise check_path_existence() { local target_path="$1" if [[ -e "$target_path" ]]; then echo "Status: Path '$target_path' exists." return 0 else echo "Status: Path '$target_path' does NOT exist." >&2 return 1 fi }
-
analyze_path_type()
function: Takes an existing path, determines its type, and prints the details.Refinement: Added a check for symbolic links (# Function to analyze the type and properties of an existing path # Arguments: $1 = path to analyze (assumed to exist) analyze_path_type() { local target_path="$1" echo -n "Type: It is " # Use -n to continue on the same line if [[ -f "$target_path" ]]; then echo "a regular file." # Print permissions (could be another function!) if [[ -r "$target_path" ]]; then echo " - Readable"; fi if [[ -w "$target_path" ]]; then echo " - Writable"; fi if [[ -x "$target_path" ]]; then echo " - Executable"; fi elif [[ -d "$target_path" ]]; then echo "a directory." # Could add directory-specific checks here if needed elif [[ -L "$target_path" ]]; then # Added check for symbolic link local link_target=$(readlink "$target_path") echo "a symbolic link pointing to -> '$link_target'." else echo "neither a file, directory, nor a symbolic link." fi # This function doesn't need to return a status if it just prints info }
-L
andreadlink
). Made permissions printing more concise.
-
-
Rewrite the Main Script Logic: Replace the original code blocks with calls to the new functions. The main part should now be much shorter and easier to read.
6. Save and Exit: Save (#!/bin/bash # Script to analyze a path provided as a command-line argument (Refactored with Functions). # --- Function Definitions --- # Function to print usage information and exit print_usage() { echo "Usage: $0 <path>" >&2 echo "Error: Please provide exactly one argument." >&2 exit 1 } # Function to check if a path exists # Arguments: $1 = path to check # Returns: 0 if exists, 1 otherwise check_path_existence() { local target_path="$1" if [[ -e "$target_path" ]]; then echo "Status: Path '$target_path' exists." return 0 else echo "Status: Path '$target_path' does NOT exist." >&2 return 1 fi } # Function to analyze the type and properties of an existing path # Arguments: $1 = path to analyze (assumed to exist) analyze_path_type() { local target_path="$1" echo -n "Type: It is " # Use -n to continue on the same line if [[ -f "$target_path" ]]; then echo "a regular file." # Print permissions if [[ -r "$target_path" ]]; then echo " - Readable"; fi if [[ -w "$target_path" ]]; then echo " - Writable"; fi if [[ -x "$target_path" ]]; then echo " - Executable"; fi elif [[ -d "$target_path" ]]; then echo "a directory." elif [[ -L "$target_path" ]]; then local link_target=$(readlink "$target_path") echo "a symbolic link pointing to -> '$link_target'." else echo "neither a file, directory, nor a symbolic link." fi } # --- Main Script Logic --- # Check number of arguments if [[ $# -ne 1 ]]; then print_usage # Call the usage function fi # Store argument main_target_path="$1" # Use a different name than inside functions if desired echo "--- Starting Analysis for '$main_target_path' ---" # Check existence by calling the function and checking its return status ($?) if check_path_existence "$main_target_path"; then # Path exists (function returned 0), proceed with type analysis analyze_path_type "$main_target_path" # Script succeeds if path exists and was analyzed exit 0 else # Path does not exist (function returned non-zero) # The function already printed an error message exit 2 # Use the specific exit code for "path does not exist" fi
Ctrl+O
, Enter) and exit (Ctrl+X
). 7. Make Executable: 8. Test: Run the refactored script with the same test cases used forfile_analyzer.sh
(no args, non-existent path, file, directory, path with spaces, symbolic link). The output behavior should be identical, but the code structure is now much cleaner.- Create a symbolic link for testing:
ln -s /etc/passwd my_passwd_link
- Test the link:
./file_analyzer_func.sh my_passwd_link
- Clean up:
rm my_passwd_link
- Create a symbolic link for testing:
This workshop illustrates how functions improve script organization. The main logic becomes a high-level overview, calling functions that encapsulate the detailed implementation of each step.
6. Exit Statuses and Basic Error Handling
Robust scripts don't just execute commands; they also anticipate and handle potential problems. A key part of this is understanding and using exit statuses.
Exit Status ($?
)
Every command or script executed in the shell finishes with an exit status (also called a return code or exit code). This is an integer value indicating whether the command completed successfully or encountered an error.
- Exit Status 0: Conventionally means success. The command completed its task without any problems.
- Exit Status Non-zero (1-255): Conventionally means failure or an abnormal condition. Different non-zero values can signify different types of errors. For example:
1
: General error (very common).2
: Misuse of shell builtins (according to Bash documentation).126
: Command invoked cannot execute (permission problem).127
: Command not found.128+N
: Fatal error signal "N" (e.g.,130
for Ctrl+C/SIGINT).
The shell stores the exit status of the most recently executed foreground command in a special variable: $?
.
You can check this immediately after running a command:
ls /etc/passwd # This file usually exists
echo "Exit status of previous ls: $?" # Should print 0
ls /non/existent/directory # This command will likely fail
echo "Exit status of previous ls: $?" # Should print a non-zero value (e.g., 1, 2)
# Note: The value of $? is updated after *every* command, even 'echo'!
echo "This echo command succeeded."
echo "Exit status of the previous echo: $?" # Should print 0
Using Exit Statuses in Conditionals
The primary use of $?
in scripting is to check if a critical command succeeded before proceeding.
#!/bin/bash
source_file="data.txt"
dest_file="/tmp/data_backup.txt"
echo "Attempting to copy '$source_file' to '$dest_file'..."
cp "$source_file" "$dest_file"
# Check the exit status of the 'cp' command
if [[ $? -eq 0 ]]; then
echo "Copy successful!"
# Proceed with next steps using the backup file...
echo "Processing backup file..."
else
echo "Error: Failed to copy '$source_file'!" >&2
echo "Please check if the source file exists and you have permissions." >&2
exit 1 # Terminate the script because a critical step failed
fi
echo "Script continues..." # This line only runs if cp succeeded
Shortcut: Using Commands Directly in if
The if
statement (and while
/until
) actually works by checking the exit status of the command provided as the condition. If the command exits with 0 (success), the condition is true; otherwise, it's false. This means you often don't need to check $?
explicitly.
#!/bin/bash
source_file="data.txt"
dest_file="/tmp/data_backup.txt"
echo "Attempting to copy '$source_file' to '$dest_file'..."
# Run 'cp' directly as the condition for 'if'
if cp "$source_file" "$dest_file"; then
echo "Copy successful!"
echo "Processing backup file..."
else
# cp failed (returned non-zero), so the else block runs
echo "Error: Failed to copy '$source_file'!" >&2
echo "Please check if the source file exists and you have permissions." >&2
exit 1
fi
echo "Script continues..."
This is much more concise and readable. Similarly for grep
:
if grep "ERROR" /var/log/syslog; then
echo "Found 'ERROR' in syslog."
fi
# Note: grep returns 0 if it finds matches, 1 if not, >1 on error.
# So this 'if' block runs only if 'ERROR' is found.
The exit
Command
We've used exit
before. It terminates the execution of the entire script immediately. You can optionally provide an exit status number as an argument.
exit
: Exits with the status of the last command executed beforeexit
.exit 0
: Exits with a success status (0). Good practice to put at the end of a successfully completed script.exit <non-zero>
: Exits with the specified failure status (e.g.,exit 1
,exit 5
). Use different codes to signify different error conditions if needed.
#!/bin/bash
read -p "Enter filename to process: " filename
if [[ ! -f "$filename" ]]; then
echo "Error: File '$filename' not found or is not a regular file." >&2
exit 3 # Specific code for "file not found" error
fi
if [[ ! -r "$filename" ]]; then
echo "Error: File '$filename' is not readable." >&2
exit 4 # Specific code for "permission denied" error
fi
echo "Processing file '$filename'..."
# ... processing commands ...
echo "Processing complete."
exit 0 # Explicitly indicate success
Basic Error Handling Strategies
- Check Critical Commands: Use
if command; then ... else ... fi
or check$?
after commands whose success is essential for the script's continuation (e.g.,cp
,mv
,mkdir
,rm
, critical data processing steps). Exit the script if they fail. - Validate Input: Check command-line arguments (
$#
), user input (read
), and configuration file values to ensure they are present and reasonable before using them. Exit gracefully with informative messages if validation fails. - Check File/Directory Existence and Permissions: Before attempting to read, write, or execute files, or create files in directories, use file test operators (
-e
,-f
,-d
,-r
,-w
,-x
) to ensure the target exists and has the necessary permissions. - Provide Informative Error Messages: When errors occur, use
echo
to print clear messages explaining what went wrong and, if possible, how to fix it. Redirect error messages to standard error (>&2
) so they can be separated from normal output if needed (./my_script.sh > output.log 2> errors.log
). - Use
exit
with Appropriate Statuses: Terminate the script usingexit
when an unrecoverable error occurs. Use non-zero exit codes to signal failure.
Shell Options for Stricter Error Handling (set
)
Bash provides options (set using the set
command) that can make scripts automatically more sensitive to errors:
-
set -e
(orset -o errexit
): Exit immediately if any command (that isn't part of a test inif
/while
, or part of an&&
or||
list) exits with a non-zero status.- Pros: Can catch errors you might otherwise miss, reduces the need for explicit
if command; else exit; fi
checks. - Cons: Can sometimes be too aggressive, making debugging harder if the script exits unexpectedly. Commands that might "fail" normally (like
grep
not finding a match, which returns 1) will terminate the script unless handled properly (e.g.,grep "pattern" file || true
). Use with caution and understand its implications.
- Pros: Can catch errors you might otherwise miss, reduces the need for explicit
-
set -u
(orset -o nounset
): Treat unset variables (except for special parameters like$@
and$*
) as an error when performing parameter expansion. This helps catch typos in variable names.- Pros: Prevents bugs caused by misspelled variable names resulting in empty strings.
- Cons: Requires careful handling of potentially unset variables (e.g., using
${variable:-default}
or checking with[[ -v variable_name ]]
).
-
set -o pipefail
: Causes a pipeline (e.g.,command1 | command2
) to return the exit status of the last command in the pipeline that exited with a non-zero status, or zero if all commands succeed. By default, a pipeline's status is only that of the very last command.- Pros: Catches errors happening earlier in a pipeline.
- Cons: None significant, generally considered good practice to use when pipelines are critical.
You can combine them: set -euo pipefail
. It's often placed near the top of a script.
#!/bin/bash
set -euo pipefail # Enable strict error handling
echo "This will run."
# ls /non/existent/directory # This would cause immediate exit due to 'set -e'
# echo "This line will NOT be reached if the above command fails."
# Example with pipefail
# Normally, 'false | true' exits with 0 (status of 'true')
# With pipefail, 'false | true' exits with 1 (status of 'false')
if false | true; then
echo "Pipeline succeeded (without pipefail or if both commands succeed)."
else
echo "Pipeline failed (with pipefail if any command fails)."
fi
# Example with nounset
# unset my_variable
# echo "My variable is: $my_variable" # This would cause immediate exit due to 'set -u'
# echo "This line would not be reached."
# Safe way with nounset
unset my_variable
echo "My variable is: ${my_variable:-default value}" # Provides a default if unset
echo "Script finished successfully."
exit 0
Using set -e
, set -u
, and set -o pipefail
encourages writing more robust code but requires a disciplined approach to handle potential failures and unset variables explicitly.
Workshop Enhancing the Backup Script with Error Handling
Goal: Improve the backup_logs.sh
script (from Workshop 4) to be more robust by adding explicit checks for command failures and potentially using set
options.
Theory Recap: Check $?
or use commands directly in if
, use exit
on critical failures, print errors to stderr
(>&2
), consider using set -e
, set -u
, set -o pipefail
.
Steps:
- Copy the previous script:
- Open the new script:
- Add
set
Options (Optional but Recommended): Place this near the top, after the shebang and initial comments. Decide if you want the strict behavior. Let's add it for this workshop to demonstrate. -
Review Existing Checks: We already added a check for
mkdir
failure in the previous version. Since we haveset -e
, the script would exit automatically ifmkdir
fails. We can simplify the check slightly, perhaps just printing the message beforemkdir
is attempted. However, keeping the explicitif mkdir
allows for a custom error message beforeset -e
terminates the script. Let's keep the explicit check for clarity.Modify the
mkdir
block slightly for better messaging:Self-correction: Realized# Define Backup Directory Name (unchanged) timestamp=$(date +%Y%m%d_%H%M%S) backup_dir="log_backups_${timestamp}" # Create Backup Directory echo "Preparing backup directory: '$backup_dir'" # Check if it exists (unlikely, but safe) if [[ -e "$backup_dir" ]]; then echo "Error: Backup destination '$backup_dir' already exists (collision?)" >&2 exit 1 fi # Attempt to create the directory if ! mkdir "$backup_dir"; then # This block might not even be reached if 'set -e' is active and mkdir fails, # but it provides a clear error message if 'set -e' were disabled. # If 'set -e' is active, the script would likely exit on the mkdir line itself. echo "Error: Failed to create directory '$backup_dir'. Check permissions." >&2 # No 'exit 1' needed here if 'set -e' is active. If not active, add 'exit 1'. exit 1 # Keep exit 1 for clarity, works with or without set -e fi echo "Successfully created directory: '$backup_dir'"
set -e
makes the explicitelse exit
redundant aftermkdir
, but the custom message in theif ! mkdir
block is still useful. Added a check if the directory already exists beforehand, which is a different kind of error. -
Improve the Loop/Copy: The
cp
command inside the loop is critical. Withset -e
, ifcp
fails, the script will halt. This might be desired, or you might want to log the error and continue with other files. Let's choose the latter: log the error but continue the loop. To do this withset -e
active, we need to explicitly handle thecp
failure so it doesn't triggerset -e
. We can do this by adding|| <command_that_returns_true>
or by putting thecp
in anif
statement.Modify the loop:
Refinement: Used the array approach properly. Added anecho "Starting backup of .log files..." log_file_count=0 error_count=0 # Use nullglob for safety (already present) shopt -s nullglob log_files=(*.log) # Use an array shopt -u nullglob if [[ ${#log_files[@]} -eq 0 ]]; then echo "Info: No .log files found in the current directory. Nothing to back up." # Optional: Remove the empty backup dir created earlier # echo "Info: Removing empty backup directory '$backup_dir'." # rmdir "$backup_dir" exit 0 # Successful exit, just nothing to do fi for logfile in "${log_files[@]}"; do # Iterate over the array # We assume *.log found actual files because of the array, # but double-checking -f is still safest. if [[ -f "$logfile" ]]; then echo " - Copying '$logfile' to '$backup_dir/'..." # Explicitly handle cp failure to prevent set -e exit if ! cp "$logfile" "$backup_dir/"; then echo " Warning: Failed to copy '$logfile'. Skipping." >&2 ((error_count++)) # The 'if ! cp' structure handles the non-zero exit of cp, # preventing set -e from triggering. We continue the loop. else ((log_file_count++)) fi # else # This case should ideally not happen if using the array properly # echo " - Warning: Item '$logfile' is not a regular file. Skipping." >&2 fi done echo "Backup process complete." echo "Successfully backed up: $log_file_count file(s)." if [[ $error_count -gt 0 ]]; then echo "Encountered errors copying: $error_count file(s)." >&2 # Exit with an error status if any copy failed exit 1 # Indicate partial success / failure fi
error_count
. Made the script exit with status 1 if any copy failed, even if others succeeded. Handled thecp
failure explicitly within anif
to preventset -e
from halting the entire script, allowing the loop to continue. Added a check if no log files were found before the loop. -
Final Exit Status: The script now exits 0 if no files are found, exits 0 if all found files are copied successfully, and exits 1 if any errors occurred during directory creation or file copying.
-
Save and Exit: Save (
Ctrl+O
, Enter) and exit (Ctrl+X
). - Make Executable:
- Test:
- Run with dummy log files (as before). Check output and exit status (
echo $?
should be 0). - Run with no log files (
rm *.log
). Check output and exit status (echo $?
should be 0). - Introduce an error: Make one log file unreadable.
echo "Readable log" > readable.log echo "Unreadable log" > unreadable.log chmod 000 unreadable.log # Remove read permission ls -l *.log ./backup_logs_robust.sh # Expected: Copies readable.log, prints Warning for unreadable.log, # reports 1 success and 1 error. echo $? # Should be 1 (due to the copy error) chmod 644 unreadable.log # Restore permissions rm *.log log_backups_*/ -rf # Clean up
- Introduce a directory creation error (more complex to set up reliably, but imagine
/tmp
being full or having restrictive permissions preventing subdirectory creation). The script should exit early with an error.
- Run with dummy log files (as before). Check output and exit status (
This workshop demonstrates how to make a script significantly more robust by checking the success of critical commands, providing clear feedback, handling errors gracefully (continuing vs. exiting), and optionally using set
options for stricter behavior.
Conclusion
You have now covered the fundamental building blocks of shell scripting in Linux using Bash. We started with the basics of creating and running scripts, understanding the shebang and comments. We then explored how to make scripts dynamic using variables, command substitution, and user input (read
). Control flow was introduced through conditional statements (if
, case
) and loops (for
, while
), allowing scripts to make decisions and repeat actions. Functions (name() { ... }
) were presented as a crucial tool for creating modular, reusable, and readable code. Finally, we emphasized the importance of checking exit statuses ($?
, if command
) and implementing basic error handling (exit
, >&2
, set -e
) to build robust and reliable scripts.
The workshops provided practical, step-by-step projects to reinforce these concepts, moving from simple greetings to file analysis and backup utilities.
Shell scripting is a vast and powerful domain. While this covered the basics, there's much more to explore, including:
- Text Processing Utilities:
grep
,sed
,awk
for powerful text manipulation. - Regular Expressions: Advanced pattern matching used with
grep
,sed
, and within[[ ... ]]
. - Arrays: Handling lists of data more effectively.
- Advanced I/O Redirection: Manipulating standard input, output, and error streams.
- Script Debugging Tools: Using
set -x
orbash -x
to trace execution. - Signal Handling: Using
trap
to catch signals like Ctrl+C.
The key to mastering shell scripting is practice. Try automating tasks you perform regularly, analyze existing scripts to understand how they work, and don't be afraid to consult documentation (man command
) or online resources when you encounter challenges. With the foundation you've built here, you are well-equipped to start writing your own effective shell scripts to leverage the full power of the Linux command line.