- How Bash resolves commands
- Text Processing / Text Transformation
2.1 grep command | Searching in files
2.2 tr command | Replace characters
2.3 awk command | columns, fields, logs, CSV-like data
2.4 sed | Search and replace inside files
2.5 find | Search for files and directories - Simple Bash script
- Basic variables
- Exit codes
- Arguments
- User input
- Functions
- If/else conditions
- Case statements
- Loops
11.1 For Loop
11.2 While loop - Arrays
12.1 Indexed Arrays
12.2 Associative Arrays - IFS – Internal Field Separator
- Command Substitution result=$(command)
- Sourcing Code /.script.sh vs source scripts.sh
- Arithmetic expressions
- Logical operators
- Process Substitution | Work in progress, should practice it more!
Tools
- ShellChecker – check your scripts with shellchecker. Finding errors and suggests improvements
- bash -x script.sh
bash in debugging mode. This is my bash debugger.
You can put it inside the script
set -x | enable it
set +x | dissable it
bash -n 01-syntax-error | check bash for errors but it doesn’t run the code
bash -u 02-undefined variables | checks for undefined variables - About strict mode on top of the script. Why to use it and why not
How Bash resolves commands
type : type is a shell built-in command that tells you what a command name refers to — for example whether it is an alias, function, built-in command, or executable file. Difference from which is that which only searches $PATH.
Example:
$ type ls
ls is aliased to `ls --color=auto'
$ type cd
cd is a shell builtin
$ type apache2
apache2 is /usr/sbin/apache2A related command:
# "command" is often used in scripts because it is POSIX-compatible
command -v <command>
$ command -v git
/usr/bin/gittype → best for interactive shell investigation
command -v → best for scripts
which → simple PATH lookup only
Text Processing / Text Transformation
| Command | Purpose |
| ------- | ---------------------------------- |
| `tr` | Translate/delete characters |
| `sed` | Stream editing / substitutions |
| `awk` | Text processing / field processing |
| `cut` | Extract columns/fields |
| `sort` | Sort text |
| `uniq` | Remove/count duplicates |
| `grep` | Search/filter text |
grep command | searching in files
This matches lines that start with root
grep '^root' /etc/passwdIdentifies end of a file. All words that ends with root
grep 'root$' /etc/passwdUseful greps for scripts
## content of file.txt
dave
DAVE
DIMITAR
### show one line after a
grep -A1 a file.txt
### finds both DAVE and dave, which means it is case sensitive
grep -i dave file.txt
## prints only what match the pattern. dot "." here means match any character
grep -o '^d.'
For example:
$ grep -o '^d.' file.txt
da
$ grep -o '^d..' file.txt
dav
$ grep -o '^d...' file.txt
davetr command | Replace characters
Replace characters with tr command – transition
Example: replace lowercase a with A
$ echo "banana" | tr 'a' 'A'
output
bAnAnALowercase → uppercase
echo "hello" | tr 'a-z' 'A-Z'Delete characters
echo "hello123" | tr -d '0-9'Replace spaces with newlines
echo "one two three" | tr ' ' '\n'Squeeze repeated characters
echo "aaabbbccc" | tr -s 'a-z'awk command | columns, fields, logs, CSV-like data
awk is a text processing / data extraction language used in Linux shells. It is especially useful for working with structured text: columns, fields, logs, CSV-like data, command output, etc.
awk reads input line by line:
input line
|
v
split into fields
|
v
check pattern
|
v
run action
Example file: users.txt
john 25 admin
anna 31 user
mark 22 user
$awk '{print $1}' users.txt
Output:
john
anna
mark
$1 = first column
$2 = second column
$3 = third column
$0 = the entire line
Basic syntax
awk 'pattern { action }' fileIf column 2 is greater than 25, print column 1
$ awk '$2 > 25 {print $1}' users.txt
Output:
john
annaPrinting columns
server1 10.0.0.1 running
server2 10.0.0.2 stopped
$ awk '{print $1, $3}' servers.txt
Output:
server1 running
server2 stoppedChanging field separator to “,”. Default separator = spaces/tabs
john,25,admin
anna,31,user
$awk -F',' '{print $1}' users.csvSearching like grep
#Find lines containing "error":
awk '/error/ {print}' logfileAwk can use fields
10:30 INFO started
10:31 ERROR failed
10:32 INFO done
$awk '$2=="ERROR" {print $0}' app.log
output:
10:31 ERROR failedBuilt-in variables
| Variable | Meaning |
| -------- | ---------------- |
| `$0` | whole line |
| `$1` | first field |
| `$NF` | last field |
| `NR` | line number |
| `NF` | number of fields |
Example:
awk '{print NR, $NF}' fileCondition if
awk '{if ($3 > 1000) print $1}' /etc/passwdChange field values
echo "hello world" | awk '{$2="Linux"; print}'Arithmetic
echo "10 20" | awk '{print $1+$2}'Loops
echo "1 2 3" | awk '{
for(i=1;i<=NF;i++)
print $i
}'Real Linux examples
# Find biggest files
ls -lh | awk '{print $5, $9}'
# Check processes
ps aux | awk '{print $1,$11}'Simple bash script – Hello Friend
#!/bin/bash
name='Dimitar'
number=34
system=$(uname -a)
echo "Hello Friend, my name is $name and I am $number years old"
echo "Your kernel is: $system"Sed
sed reads text line by line and transforms it — substitute, delete, insert, print specific lines. Think of it like find and replace but on steroids and scriptable.
sed [options] 'command' file
# or
command | sed [options] 'command'
Most common use — substitution s:
sed 's/old/new/' file
s → substitute
/old/ → find this
/new/ → replace with this
Substitution examples:
# replace first occurrence per line
echo "hello hello hello" | sed 's/hello/world/'
# prints: world hello hello
# replace ALL occurrences per line (global flag g)
echo "hello hello hello" | sed 's/hello/world/g'
# prints: world world world
# case insensitive (i flag)
echo "Hello HELLO hello" | sed 's/hello/world/gi'
# prints: world world world
# replace on specific line number
sed '3s/old/new/' file # only line 3
# replace in line range
sed '1,5s/old/new/' file # lines 1 through 5
Quick reference:
SUBSTITUTION:
sed 's/old/new/' → replace first occurrence
sed 's/old/new/g' → replace all occurrences
sed 's/old/new/gi' → case insensitive replace all
sed 's/old/new/2' → replace 2nd occurrence only
sed -i 's/old/new/g' → edit file in place
sed -i.bak 's/old/new/g' → edit in place with backup
DELETE:
sed '3d' → delete line 3
sed '1,5d' → delete lines 1-5
sed '/pattern/d' → delete lines matching pattern
sed '/^$/d' → delete empty lines
sed '/^#/d' → delete comment lines
PRINT:
sed -n '5p' → print line 5 only
sed -n '1,5p' → print lines 1-5
sed -n '/pattern/p' → print matching lines
INSERT/APPEND:
sed '3i\text' → insert before line 3
sed '3a\text' → append after line 3
sed 's/^/PREFIX: /' → add prefix to every line
sed 's/$/ SUFFIX/' → add suffix to every line
FLAGS:
-i → edit file in place
-i.bak → edit in place with backup
-n → suppress default output
-e → multiple commandsFind
find — Search for files and directories
What it does: find searches for files and directories in a directory tree based on conditions — name, type, size, date, permissions and more. One of the most powerful sysadmin tools.
find [where] [what] [action]
find /var/log -name "*.log" -type f
# ↑ ↑ ↑
# where what action (optional)
find [where] [what] [action]
find /var/log -name "*.log" -type f
# ↑ ↑ ↑
# where what action (optional)
# exact name
find /etc -name "sshd_config"
# wildcard
find /var/log -name "*.log"
# case insensitive
find /home -iname "*.txt"
# by extension
find / -name "*.conf"
Search by type:
find /etc -type f # files only
find /etc -type d # directories only
find /etc -type l # symlinks only
Search by permissions:
find / -perm 777 # exactly 777
find / -perm -777 # at least 777
find / -perm /222 # any write permission set
find / -perm 644 # exactly 644
Actions — what to do with results:
# print (default)
find /etc -name "*.conf" -print
# delete found files — BE CAREFUL
find /tmp -name "*.tmp" -delete
# execute command on each result
find /var/log -name "*.log" -exec ls -lh {} \;
# ↑ ↑
# {} = found file
# \; = end of command
# execute with + (faster — batches files)
find /var/log -name "*.log" -exec ls -lh {} +
Quick reference:
BASIC:
find /path -name "file" → find by exact name
find /path -name "*.log" → find by wildcard
find /path -iname "*.log" → case insensitive
find /path -type f → files only
find /path -type d → directories only
find /path -type l → symlinks only
SIZE:
find /path -size +100M → larger than 100MB
find /path -size -10k → smaller than 10KB
TIME:
find /path -mtime -1 → modified last 1 day
find /path -mtime +30 → modified 30+ days ago
find /path -mmin -60 → modified last 60 minutes
PERMISSIONS/OWNER:
find /path -perm 777 → exact permissions
find /path -user username → owned by user
find /path -group groupname → owned by group
find /path -nouser → orphaned files
ACTIONS:
find /path -print → print results (default)
find /path -delete → delete found files
find /path -exec cmd {} \; → run command on each file
find /path -exec cmd {} + → run command batched
COMBINING:
find /path -type f -name "*.log" → AND
find /path -name "*.log" -o -name "*.txt" → OR
find /path ! -name "*.log" → NOT
DEPTH:
find /path -maxdepth 1 → current dir only
find /path -maxdepth 2 → max 2 levels deep
find /path -mindepth 2 → skip top levels
Basic Variables
syntax
foo='Hello there'quotation marks when exporting variables
#single quotes escape spaces. Doube quotes not
$ foo='hello world'
$ echo $foo
hello world
$ echo "$foo"
hello worldRemove variable
unset fooCreate environment variable from other environment variable
thing=$(uname -a)
or
thing=`uname -a`
# ` is ~ without shift ;)Exit codes
Each shell command returns an exit code when it terminates, whether successfully or not.
By convention, an exit code of zero indicates the command completed successfully, and a non-zero value means an error was encountered.
The special variable $? holds the exit status of the last executed command
date &> /dev/null
echo $?
#The date command completed successfully, so the exit code is zero
#If you try to run ls on a nonexistent directory, the exit code will be non-zero:
ls /nonexistent_dir &> /dev/null
echo $?Exit Code Conventions
| Code | Meaning |
| 0 | Success |
| 1 | General error |
| 2 | Misuse of a shell built-in (wrong arguments, etc.) |
| 126 | Command found but not executable (permission issue) |
| 127 | Command not found |
| 128+N | Command terminated by signal N (for example, 130 = Ctrl+C and 137 = SIGKILL) |
Arguments
#!/bin/bash
count=0
for f in "$1"/*.log; do
(( count++ ))
done
echo "Found $count .log files in $1"$1 is an Argument. It reads first argument after bash scripts for example:
./folder_number_of_logs.bash /var/log/apache2 . /var/log/apache2 is an argument.
You can give more than one argument with $2, $3 etc.
| Variable | Meaning |
| `$#` | Number of arguments |
| `$1` | First argument |
| `$2` | Second argument |
| `$3` | Third argument |
| `$@` | All arguments |
| `$0` | Script name |
Read user input
Syntax
read -p 'Enter your name: ' name
echo "Hello $name"User input with argument. Give argument to the script example: ./name.bash Dimitar. If no argument is provided it will ask for input
#/bin/bash
if [[ -n $1 ]]; then
name=$1
else
read -p 'Enter your name: ' name
fi
echo "Hello $name"Loop with user input
#/bin/bash
## $@ creates array to all in the arguments. Arguments that we give to the script ./scrip.bash ARG1 ARG2 ARG3
for thing in "$@"; do
echo "thing is $thing"
doneWhile Loop with user input
#!/bin/bash
while read -r line; do
echo "we read line: $line"
doneFunctions
You already use functions every day without knowing it. Commands like du, echo, cut — those are essentially functions someone wrote for you.
A function is just a named block of commands you can reuse.
The problem functions solve:
Imagine you need to print a separator line in your script 5 times:
bashecho "-------------------"
echo "Task 1"
echo "-------------------"
echo "Task 2"
echo "-------------------"
echo "Task 3"
echo "-------------------"
What if you want to change --- to ===? You edit 4 lines. Now imagine 50 times. That's a nightmare.
With a function:
bashseparator() {
echo "-------------------"
}
separator
echo "Task 1"
separator
echo "Task 2"
separator
echo "Task 3"
Now to change it you edit one place only.Syntax
function_name() {
# commands go here
}
# call it like this:
function_name
Arguments let you pass data into a function:
greet() {
echo "Hello $1, welcome to the server!"
}
greet "dimitar" # prints: Hello dimitar, welcome to the server!
greet "root" # prints: Hello root, welcome to the server!Argument in functions vs arguments only to bash script itself
When passing to a script:
./myscript.bash Dimitar username
↑ ↑
$1 $2 ← available in the whole script
When passing to a function:
greet "Dimitar" "username"
↑ ↑
$1 $2 ← available inside the function only
Same syntax, different scope. Think of it like this:
SCRIPT LEVEL
├── $1 = "Dimitar" ← what you passed to the script
├── $2 = "username"
│
└── greet "Dimitar" ← calling function with its own argument
└── FUNCTION LEVEL
└── $1 = "Dimitar" ← only exists inside function
This means $1 can mean two different things in the same script:
#!/bin/bash
# $1 here = what you passed to the SCRIPT
greet() {
echo "Hello $1" # $1 here = what you passed to the FUNCTION
}
echo "Script got: $1" # prints what you gave the script
greet "Dimitar" # function gets its own $1
Write a function called backup_multiple that takes multiple filenames as arguments and backs up each one with a .bak extension. If a file doesn’t exist, print an error and skip it.
backup_multiple() {
for file in "$@"; do
if [ -f "$file" ]; then
cp "$file" "$file.bak"
else
echo "File with name $file does not exist!"
fi
done
}
backup_multiple testfile.txt testfile2.txt testfile3.txtWrite a function called check_services that takes multiple service names as arguments and prints whether each one is running or not.
check_services() {
for service in "$@"; do
if systemctl is-active "$service" &>/dev/null; then
echo "Service $service is RUNNING"
else
echo "Service $service is STOPPED"
fi
done
}
check_services snapd.service apache2.service
If/else Conditions
Syntax
#!/usr/bin/env bash
simple syntax
a=2
b=2
if [[ $a == $b ]]; then
echo 'a and b are the same'
fi
c=2
d=3
if [[ $c != $d ]]; then
echo 'c and d are NOT the same'
fi
if [[ -f file.txt]]; then
echo 'file.txt exists and is a file'
fi
if true; then
echo "if statement was true"
else
echo "if statement was false"
fi
You can replace true with whatever command you want!
Case statements
Syntax
case "$variable" in
pattern1)
# commands
;;
pattern2)
# commands
;;
*)
# default — matches anything
;;
esac
;; means "stop here, don't check other patterns"
* is the default case — like else
esac is case spelled backwards — it closes the block
case also supports patterns:
case "$answer" in
yes|y|YES) # matches any of these
echo "You said yes"
;;
no|n|NO)
echo "You said no"
;;
esac
Write a script that takes a day name as argument ($1) and prints whether it is a weekday or weekend.
case "$1" in
monday)
echo "Monday is weekday"
;;
tuesday)
echo "Tuesday is weekday"
;;
wednesday)
echo "Wednesday is weekday"
;;
thursday)
echo "Thursday is weekday"
;;
friday)
echo "Friday is weekday"
;;
saturday)
echo "Saturday is weekend"
;;
sunday)
echo "Sunday is weekend"
;;
*)
echo "This is not a day from the week"
;;
esac
## Better Solution
## instead of 5 separate weekday cases:
case "${1,,}" in
monday|tuesday|wednesday|thursday|friday)
echo "$1 is a weekday"
;;
saturday|sunday)
echo "$1 is a weekend"
;;
*)
echo "$1 is not a valid day"
;;
esac
Write a script that takes a service name as argument and an action (start, stop, restart, status). Perform the correct systemctl command based on the action. If the action is unknown print
an error.
case "${2,,}" in
start)
systemctl start "$1"
;;
stop)
systemctl stop "$1"
;;
status)
systemctl status "$1"
;;
restart)
systemctl restart "$1"
;;
*)
echo "Error. Argument is not recognized. Choose from start stop status restart"
;;
esacMenu with user input
#!/bin/bash
## User choses something from menu. Program return result
while true; do
echo "1) Check disk usage"
echo "2) Check memory usage"
echo "3) Check who is logged in"
echo "4) Exit"
read choice
case "$choice" in
1)
df -h
;;
2)
free -h
;;
3)
who
;;
4)
break
;;
*)
echo "You must choose something from 1 .. 4"
;;
esac
doneWrite a script called server_health.sh that checks the health of a server and prints a report. It must include:
A function called check_disk that warns if any partition is above 80%
A function called check_memory that warns if memory usage is above 80%
A function called check_services that checks if ssh and cron are running
A menu using case that asks the user what to check:
1) Check disk
2) Check memory
3) Check services
4) Run all checks
5) Exit
#!/bin/bash
## check_service function. Important, when comparing strings we are using [[ ... ]] and the env is with $ and "".
check_service() {
ssh_check=$(systemctl is-active ssh)
cron_check=$(systemctl is-active cron)
if [[ "$ssh_check" == active && "$cron_check" == active ]]; then
echo -e "\n Both service are up and runnig! sshd is $ssh_check. cron is $cron_check \n"
else
echo -e "\n Bro, you have problem with a service! sshd is $ssh_check. cron is $cron_check \n"
fi
}
## check memory function
#towa mi dawa kolko memory se polzva v momenta v procenti. free | awk '/Mem:/ {printf("%.2f%%\n", $3/$2*100)}'
#towa bez procenti free | awk '/Mem:/ {printf("%.2f\n", $3/$2*100)}'
check_memory() {
total_memory_usage=$(free | awk '/Mem:/ {printf("%.f\n", $3/$2*100)}')
if (( total_memory_usage > 80 )); then
echo -e "\n Warning. You are using more than 80% of your memory \n"
else
echo -e "\n You are using $total_memory_usage% of your total memory. It's ok \n"
fi
}
###check disk function
#checker=0 in programming is called flag variable.
check_disk() {
checker=0
for partition_percentage in $(df -h --output=source,fstype,size,used,avail,pcent,target -x tmpfs -x devtmpfs | grep ^/dev | awk '{ print $6 }' | tr -d '%'); do
if (( partition_percentage > 80 )); then
checker=1
break
fi
done
if (( checker == 0 )); then
echo -e "\n All partitions are fine. \n"
else
echo -e "\n WARNING!!! One or more partitions are more than 80% full. \n"
fi
}
### menu part
while true; do
echo "1) Check disk usage"
echo "2) Check memory usage"
echo "3) Check service ssh and cron status"
echo "4) Run all checks"
echo "5) Exit"
read choice
case "$choice" in
1)
check_disk
;;
2)
check_memory
;;
3)
check_service
;;
4)
check_disk; check_memory; check_service
;;
5)
break
;;
*)
echo "You must choose something from 1 .. 5"
;;
esac
doneFor Loop
In Bash, the for loop syntax has a few common forms. Loop over a list is the most common.
for var in item1 item2 item3; do
commands
done
Example:
for name in Alice Bob Charlie; do
echo "Hello $name"
done
for item in a b c d; do
echo "$item"
done
item is just a variable — and on each iteration it gets assigned the next a b c d from the list.
iteration 1: item="a"
iteration 2: item="b"
... and so on
for [variable] in [list of things]; do
↑ ↑
gets assigned one item at a time
each item
The "list of things" can be:
numbers → {1..10}
words → a b c d
files → /var/log/*.log
command output → $(cat somefile)
for thing in {a..f}; do
echo "thing is a $thing"
done
max=5
for (( i = 0; i < max ; i++)); do
echo "Printing message 5 times in C style"
done
Classic FizzBuzz — numbers 1 to 20. Multiples of 3 → “Fizz”, multiples of 5 → “Buzz”, multiples of both 3 and 5 → “FizzBuzz”, otherwise the number.
for i in {1..20}; do
if (( i % 3 == 0 && i % 5 != 0)); then
echo "Fizz"
elif (( i % 5 == 0 && i % 3 != 0)); then
echo "Buzz"
elif (( i % 5 == 0 && i % 3 == 0 )); then
echo "FizzBuzz"
else
echo "$i"
fi
done
## more elegant
echo "More elegant sollution \n"
for i in {1..20}; do
if (( i % 3 == 0 && i % 5 == 0 )); then
echo "FizzBuzz"
elif (( i % 3 == 0 )); then
echo "Fizz"
elif (( i % 5 == 0 )); then
echo "Buzz"
else
echo "$i"
fi
doneLoop over all .log files in /var/log and print each filename and its size in a human readable format.
for i in /var/log/*.log; do
size=$(du -sh "$i" | cut -f1)
echo "File: $i | Size: $size"
doneWrite a script that loops over all users in /etc/passwd and prints only users with a UID greater than 1000 (those are typically real human users on Linux).
for userid in $(cut -d ':' -f3 /etc/passwd); do
if (( userid >= 1000 && userid < 65534)); then
echo "The only real human on this linux server is with UID $userid"
fi
doneWhile loop
It keeps running as long as the condition is true. When condition becomes false – it stops.
while [ condition ]; do
# commands
done3 most common patterns
1. Counter based:
count=0
while (( count < 5 )); do
echo "count is $count"
(( count++ ))
done
2. Read a file line by line:
while read line; do
echo "$line"
done < /etc/passwd
command > file # output OF command goes INTO file
command < file # content OF file goes INTO command
3. Infinite loop — runs until you break it:
while true; do
echo "running..."
sleep 1
done
Break and continue work exactly like in for
while true; do
if [ something ]; then
break # exit the loop completely
fi
doneWhile and until
# while — runs WHILE condition is true
while (( count < 10 )); do
# until — runs UNTIL condition becomes true
until ping -c1 google.com; doWrite a script that counts from 1 to 10 using a while loop and prints each number.
count=1
while (( count < 11 )); do
echo "counting number $count"
(( count++ ))
doneWrite a script that keeps pinging google.com until it gets a successful response, then prints “Network is up!” and stops.
until ping -c1 google.com &>/dev/null; do
echo "Network is down, retrying..."
sleep 2
done
echo "Network is up!"
##this is with while
while ! ping -c1 google.com &>/dev/null; do
echo "Network is down, retrying..."
sleep 2
done
echo "Network is up!"
Infinite loop
#!/bin/bash
## infinite loop
while true; do
echo "running..."
sleep 1
done
Read a file line by line
while read line; do
echo "$line"
done < /etc/passwd
Write a script that reads /etc/passwd line by line and prints only the username and their home directory in this format:
User: root | Home: /root
User: dimitar | Home: /home/dimitar
while read line; do
username=$(echo "$line" | cut -d: -f1)
homedir=$(echo "$line" | cut -d: -f6)
echo "User: $username | Home: $homedir"
done < /etc/passwd
Write a function called monitor_disk that keeps checking disk usage of a directory every 3 seconds and warns when it goes above a threshold. It stops when you press Ctrl+C.
monitor_disk() {
folder="$1"
threshold="$2"
while true; do
result=$(df -h "$1" | awk 'NR==2 {print $5}' | tr -d '%')
if (( result >= threshold )); then
echo "WARNING: $folder is at ${result}% — above threshold of ${threshold}%"
else
echo "OK: $folder is at ${result}% — below threshold of ${threshold}%"
fi
sleep 3
done
}
monitor_disk "$1" "$2"While with read and If statement
check_disk() {
checker=0
while read -r partition percentage; do
pct=$(echo "$percentage" | tr -d '%')
if (( pct > 80 )); then
echo "WARNING: $partition is at ${pct}%"
checker=1
fi
done < <(df -h --output=source,pcent -x tmpfs -x devtmpfs | grep ^/dev)
if (( checker == 0 )); then
echo -e "\nAll partitions are fine.\n"
fi
}
check_diskFor vs. While
| For | While |
| Use when: you know how many iterations | Use when: you don’t know how many iterations |
| Example: loop over 10 files | Example: keep trying until server responds |
The rule:
looping over words/files/numbers -> for
reading structured lines with columns -> while read
reading /etc/passwd, logs, df output -> while read
Arrays
================================================================
BASH ARRAYS - REGULAR vs ASSOCIATIVE
================================================================
REGULAR ARRAY
-------------
Declaration: servers=("web01" "web02" "db01")
Index type: numbers starting from 0
Access one: ${servers[0]}
Access all: ${servers[@]}
Get indexes: ${!servers[@]}
Count: ${#servers[@]}
Add element: servers+=("web04")
Use when: ordered list of items
Example:
servers=("web01" "web02" "db01")
for server in "${servers[@]}"; do
echo "$server"
done
----------------------------------------------------------------
ASSOCIATIVE ARRAY
-----------------
Declaration: declare -A servers
servers["production"]="web01"
Or all at once: declare -A servers=(
["production"]="web01"
["staging"]="web02"
)
Index type: named keys (strings)
Access one: ${servers["production"]}
Access all: ${servers[@]}
Get all keys: ${!servers[@]}
Count: ${#servers[@]}
Add element: servers["newkey"]="newvalue"
Use when: key-value pairs / dictionary-like data
Example:
declare -A server_ips=(
["web01"]="192.168.1.10"
["web02"]="192.168.1.11"
["db01"]="192.168.1.20"
)
for server in "${!server_ips[@]}"; do
echo "$server → ${server_ips[$server]}"
done
----------------------------------------------------------------
KEY DIFFERENCES
---------------
| Feature | Regular | Associative |
|----------------|------------------|---------------------|
| Index type | numbers 0 1 2 | named keys |
| Declare | servers=() | declare -A servers |
| Preserves order| yes | no |
| Use when | ordered list | key-value pairs |
| Example | list of servers | server → IP mapping |
----------------------------------------------------------------
COMMON SYNTAX (works for BOTH types)
-------------------------------------
${array[@]} → all values
${!array[@]} → all indexes/keys
${#array[@]} → count elements
array+=() → append element
================================================================
The pattern to remember for associative arrays:
for key in "${!array[@]}"; do
value="${array[$key]}"
# now you have both key and value
doneLocals in arrays. Important, with locals you can skip some nasty bugs
The problem without local:
'
name="global"
change_name() {
name="inside function"
}
change_name
echo $name # prints: "inside function"
'
The function accidentally changed the variable outside itself. In small scripts this is fine, but in big scripts this causes nasty bugs that are hard to find.
local keeps variables inside the function:
`name="global"
change_name() {
local name="inside function"
}
change_name
echo $name # prints: "global"
`
The function has its own name — the outside name is untouched.
local with arrays:
my_function() {
local my_array=("a" "b" "c") # only exists inside function
echo "${my_array[@]}"
}
my_function
echo "${my_array[@]}" # prints nothing — array is gone
For associative arrays:
my_function() {
local -A my_map=(["key"]="value") # -A for associative
echo "${my_map["key"]}"
}
Rules:
local var="value" → local regular variable
local array=("a" "b") → local regular array
local -A map=(["k"]="v") → local associative array
declare -A map → global associative array (no local)
Indexed Arrays
Syntax
Define:
servers=("web01" "web02" "db01")
Access one element:
echo "${servers[0]}" # web01
echo "${servers[1]}" # web02
Access all elements:
echo "${servers[@]}" # web01 web02 db01
Count elements:
echo "${#servers[@]}" # 3
Add an element:
servers+=("db02")
Loop over array:
for server in "${servers[@]}"; do
echo "$server"
done
fruits=("apple" "banana" "cherry")
echo "${fruits[0]}"
echo "${fruits[@]}"
echo "${#fruits[@]}"
Show all keys
echo "${!servers[@]}" #Output will be : 0 1 2 3 4 5
Create an array of 5 server names. Loop over them and print each one with its index number like this:
Server 0: web01
Server 1: web02
servers=("web01" "web02" "web03" "web04" "web05")
for server_number in "${!servers[@]}"; do
server_name=${servers[$server_number]}
echo "Server $server_number : $server_name"
doneCreate an array of services (ssh, cron, ufw, rsyslog). Loop over them, check if each one is running and store the failed ones in a second array called failed_services. At the end print how many services failed and which ones.
Expected output:
Checking ssh… OK
Checking cron… OK
Checking ufw… FAILED
Checking rsyslog… OK
Failed services: 1
Failed: ufw
#Create an array of services
services=("ssh" "cron" "ufw" "rsyslog")
#loop over an array, if service is active show ok, else show failed and create new array, which stores failed services.
for service_name in "${services[@]}"; do
if systemctl is-active "${service_name}" &> /dev/null; then
echo "Checking '${service_name}'... OK"
else
echo "Checking '${service_name}'... FAILED"
failed_services+=("$service_name")
#echo "Checking ${failed_services[@]}... FAILED"
fi
done
echo ""
echo "Failed services: ${#failed_services[@]}"
echo "Failed : ${failed_services[@]}"Associative arrays
Syntax
Associative arrays — instead of numeric indexes, you use named keys.
Regular array — numeric index:
servers[0]="web01"
servers[1]="web02"
Associative array — named key:
servers["production"]="web01"
servers["staging"]="web02"
servers["database"]="db01"
Think of it like a dictionary:
key → value
"production" → "web01"
"staging" → "web02"
"database" → "db01"
syntax:
Define — must use declare -A first:
declare -A servers
servers["production"]="web01"
servers["staging"]="web02"
Or all at once:
declare -A servers=(
["production"]="web01"
["staging"]="web02"
["database"]="db01"
)
Access one value:
echo "${servers["production"]}" # web01
Get all keys:
echo "${!servers[@]}" # production staging database
Get all values:
echo "${servers[@]}" # web01 web02 db01
Loop over keys and values:
for key in "${!servers[@]}"; do
echo "$key → ${servers[$key]}"
done
Loop associative array:
declare -A server_ips=(
["web01"]="192.168.1.10"
["web02"]="192.168.1.11"
["db01"]="192.168.1.20"
)
for server in "${!server_ips[@]}"; do
echo "$server → ${server_ips[$server]}"
done
## loop over the keys
for key in "${!arr[@]}"; do
echo "got $key"
done
Create an associative array that maps service names to their config file paths. Loop over it and print whether each config file exists or not.
Example
[“ssh”]=”/etc/ssh/sshd_config”
[“cron”]=”/etc/crontab”
[“rsyslog”]=”/etc/rsyslog.conf”
[“ufw”]=”/etc/ufw/ufw.conf”
expected output:
ssh → /etc/ssh/sshd_config → EXISTS
cron → /etc/crontab → EXISTS
rsyslog → /etc/rsyslog.conf → EXISTS
ufw → /etc/ufw/ufw.conf → EXISTS
declare -A service_name_and_paths=(
["ssh"]="/etc/ssh/sshd_config"
["cron"]="/etc/crontab"
["rsyslog"]="/etc/rsyslog.conf"
["ufw"]="/etc/ufw/not_existing.conf"
)
for service in "${!service_name_and_paths[@]}"; do
path="${service_name_and_paths[$service]}"
if [ -f "$path" ]; then
echo "$service → $path → EXISTS"
else
echo "$service → $path → DOES NOT EXIST"
fi
done
Write a script called server_inventory.sh that uses an associative array to store server names and their IPs. The script must have:
An associative array with at least 4 servers and their IPs
A function called ping_servers that loops over the array, pings each server once and stores online servers in a regular array and offline servers in another regular array
A function called print_report that prints a summary like this:
================================
SERVER INVENTORY REPORT
================================
ONLINE SERVERS: 2
– web01 (192.168.1.10)
– web02 (192.168.1.11)
OFFLINE SERVERS: 2
– db01 (192.168.1.20)
– db02 (192.168.1.21)
================================
declare -A server_name_and_ip=(
["google"]="8.8.8.8"
["cloudflare"]="1.1.1.1"
["slop1"]="192.168.64.100"
["slop2"]="192.168.64.200"
["slop3"]="192.168.64.201"
)
#explicitly declaring empty arrays at the top is cleaner and safer
online_servers=()
offline_servers=()
ping_servers() {
for server_name in "${!server_name_and_ip[@]}"; do
server_ip="${server_name_and_ip[$server_name]}"
if ping -c1 $server_ip &>/dev/null; then
#echo "Server with name ${server_name} and IPv4: ${server_ip} is UP and running"
online_servers+=("$server_name")
else
#echo "Server with name ${server_name} and IPv4: ${server_ip} is DOWN!"
offline_servers+=("$server_name")
fi
done
#echo "Online servers are: ${online_servers[@]}"
#echo "Offline servers are: ${offline_servers[@]}"
}
#ping_servers
print_report() {
##populate ping_servers function first
ping_servers
echo "================================"
echo " SERVER INVENTORY REPORT "
echo "================================"
echo "ONLINE SERVERS: ${#online_servers[@]}"
for online_server_name in "${online_servers[@]}"; do
echo "$online_server_name : ${server_name_and_ip[$online_server_name]}"
done
echo ""
echo "OFFLINE SERVERS: ${#offline_servers[@]}"
for offline_server_name in "${offline_servers[@]}"; do
echo "$offline_server_name : ${server_name_and_ip[$offline_server_name]}"
done
}
print_report
IFS
IFS is a special bash variable that tells bash “how to split text into pieces”. By default IFS contains three characters: space, tab, newline.
line="web01 web02 web03"
for server in $line; do
echo "$server"
done
# prints:
# web01
# web02
# web03
Changing IFS:
IFS=":"
line="root:x:0:0:root:/root:/bin/bash"
for field in $line; do
echo "$field"
done
# prints:
# root
# x
# 0
# 0
# root
# /root
# /bin/bash
## using array
array=(
foo
bar
baz
)
#IFS=.
echo "array is ${array[*]}"Quick reference
IFS → Internal Field Separator
default value → space, tab, newline
IFS=":" → permanent change — avoid
IFS=":" command → temporary change — preferred
OLD_IFS=$IFS → save before changing
IFS=$OLD_IFS → restore after changing
common use → while IFS=: read -r col1 col2
Command Substitution
What is command substitution?
It means "run this command and give me its output as a value":
result=$(command)
common uses in sysadmin scripts:
Store command output:
current_date=$(date +%Y-%m-%d)
hostname=$(hostname)
disk_usage=$(df -h / | awk 'NR==2 {print $5}')
Use directly in strings:
echo "Server $(hostname) started at $(date)"
echo "You are logged in as $(whoami)"
Use in conditions:
if [ $(whoami) == "root" ]; then
echo "You are root"
fi
Use in filenames:
backup="backup_$(date +%Y-%m-%d).tar.gz"
# creates: backup_2024-01-15.tar.gzImportant: Command substitution creates subshell. Can create bug if you do not know it
i=5
my-func() {
i=6
echo "hi"
}
thing=$(my-func)
echo "thins is $thing"
echo "i is $i"
#the result of i is 5, even the funchtion changed it 6, because () creates a subshellReference:
$(command) → capture output of command
$(command1 | command2) → capture output of pipeline
$(command1 $(command2)) → nested substitution
var=$(command) → store in variable
echo "text $(command) text" → inline in string
file_$(date +%Y%m%d).log → dynamic filenames
#The rule
#subshell can send data UP only through stdout — echo
#subshell CANNOT send variable changes UP to parent
# ALL of these create subshells:
result=$(command) # command substitution
(command) # explicit subshell
command | while read # pipe creates subshell!
Sourcing code
Source reads and executes a file in the current shell — not a subshell. Variables, functions and settings defined in that file become available in your current session.
SYNTAX:
source file.sh → load file into current shell
. file.sh → same thing, shorthand
VS EXECUTING:
./script.sh → runs in subshell, variables die
source script.sh → runs in current shell, variables survive
COMMON USES:
source functions.sh → load reusable functions
source config.sh → load configuration variables
source ~/.bashrc → reload shell config after editing
BEST PRACTICES:
# always use full path in scripts
source /etc/myapp/config.sh
# check file exists before sourcing
[ -f config.sh ] && source config.sh
# common pattern — config + main script
config.sh → variables only
functions.sh → functions only
main.sh → sources both, runs logic
FILE NAMING CONVENTIONS:
config.sh → configuration variables
functions.sh → reusable functions
.env → environment variables (docker style)
lib.sh → library of functions
Common use case:
Splitting a big script into files
# functions.sh — just your functions
check_disk() { ... }
check_memory() { ... }
ping_servers() { ... }
# main.sh — your main script
source functions.sh # load all functions
check_disk # now available here
ping_servers # now available hereArithmetic Expression
Strange behavior when using set -e. Be careful with exit codes when performing arithmetics.
thing=$(( 2 + 2 ))
echo "thing is "$thing""
a=2
b=3
echo $(( a + b ))
p=10
if ((p % 2)); then
echo "p is even" #chetno
else
echo "p is odd" #nechetno
fi
#In bash, ((expression)) is an arithmetic evaluation, and its exit status depends on the numeric result:
#If the arithmetic result is non-zero → exit status 0 (true)
#If the arithmetic result is zero → exit status 1 (false)
d=10
if (( d % 2 == 0 )); then
echo "d number is even"
else
echo "d number is odd"
fi
##the code below will return i is 0, becayse ((i++)) while doint artithemetics i=0 returns error, so $? = 1 and set -e stops the execution
set -e
i=0
while ((i<10)); do
echo "i is $i"
((i++))
done
~ Logical Operators
BASH LOGICAL OPERATORS: QUICK REFERENCE
=======================================
1. COMMAND CHAINING (based on exit status)
------------------------------------------
cmd1 && cmd2 AND run cmd2 only if cmd1 succeeded (exit 0)
cmd1 || cmd2 OR run cmd2 only if cmd1 failed (non-zero)
! cmd NOT invert the exit status of cmd
cmd1 ; cmd2 sequence run cmd2 always, regardless of result
cmd1 & cmd2 background cmd1 goes to background, cmd2 starts now
cmd1 | cmd2 pipe stdout of cmd1 feeds cmd2 (not a logical op)
Examples:
mkdir -p /tmp/x && cd /tmp/x # cd only if mkdir worked
ping -c1 host || echo "host down" # echo only on failure
! grep -q foo file && echo "no foo" # negate, then AND
cmd1 && cmd2 || cmd3 # pseudo if/else (see gotcha)
GOTCHA: in "A && B || C", C also runs if A succeeds but B fails.
Use a real if/else when that matters.
GROUPING:
{ cmd1; cmd2; } && cmd3 runs in the current shell
( cmd1; cmd2 ) && cmd3 runs in a subshell