Shell ScriptingLesson 3 of 4
Repeating work: for, while and until
Do the same thing to a list of names, files or lines, and stop or skip when you need to.
forwhileuntilreadbreakcontinueseq{1..5}${var%.txt}
After this lesson you can
- Loop over a list, a glob, a range and the arguments of a script with for.
- Read a file line by line, splitting fields, with while read.
- Control a loop with break and continue, and wait for a condition with until.
The idea
Administration is repetition: the same change on ten users, the same check on every file in a directory, the same step until something is ready. Bash has three loops.
| Loop | Runs | Use it for |
|---|---|---|
for x in LIST | Once per item in the list | Names, files, arguments, a range |
while CONDITION | While the condition succeeds | Reading lines, counters |
until CONDITION | Until the condition succeeds | Waiting for something to become ready |
Inside any loop, break leaves it immediately and continue skips to the next round.
What a for loop can iterate over
for u in alice bob carol; do ...; done # a literal list
for i in {1..5}; do ...; done # a range
for f in /etc/*.conf; do ...; done # a glob, expanded by the shell
for a in "$@"; do ...; done # the script's arguments
for u in $(cut -d: -f1 /etc/passwd); do ... # words of command output
The last form splits output on whitespace, which is fine for user names and breaks on file names with spaces. For line-oriented data prefer while read.
Reading a file line by line
read takes one line from standard input and splits it into variables. Feed the file in at the end of the loop:
while IFS=: read -r name uid shell; do
echo "$name -> $shell"
done < users.txt
IFS=: makes the colon the separator (for this one read), -r stops backslashes being interpreted, and the last variable receives the rest of the line. This is exactly how you walk /etc/passwd, which is colon-separated.
Try it
for u in alice bob carol; do echo "user: $u"; done
for i in {1..3}; do echo -n "$i "; done; echo
user: alice
user: bob
user: carol
1 2 3
mkdir -p ~/lab/ren && cd ~/lab/ren
touch a.txt b.txt c.txt
for f in *.txt; do mv "$f" "${f%.txt}.bak"; done
ls
a.bak b.bak c.bak
${f%.txt} removes the shortest match of .txt from the end of $f. ${f#prefix} does the same at the start. They are built into Bash, so no sed or basename process is started for each file.
A dry run before a destructive loop
Whenever a loop will change or delete things, write it first so it only prints what it would do. Run that, read it, then swap the echo for the real command.
cat > adduse.sh <<'EOT'
#!/bin/bash
for u in "$@"; do
if id "$u" &>/dev/null; then
echo "skip $u (exists)"
else
echo "would create $u"
fi
done
EOT
chmod +x adduse.sh
./adduse.sh root zed
skip root (exists)
would create zed
Splitting fields while reading
printf 'alex:1001:/bin/bash\nbob:1002:/sbin/nologin\n' > users.txt
while IFS=: read -r name uid shell; do
echo "$name -> $shell"
done < users.txt
alex -> /bin/bash
bob -> /sbin/nologin
Counters, skipping and stopping
for i in 1 2 3 4 5; do
[ $i -eq 2 ] && continue
[ $i -eq 4 ] && break
echo $i
done
1
3
$(( ... )) does integer arithmetic, so a counter is n=$((n+1)), and until is the mirror of while: until [ -f /tmp/ready ]; do sleep 1; done waits for a file to appear. Always put a limit on a waiting loop, or a missing file hangs the script forever.
Going further
for ((i=0; i<5; i++)); do ...; done is the C-style form. Arrays hold lists safely: arr=(one "two words" three), "${arr[@]}" expands each element as its own word, and ${#arr[@]} is the count. select builds a numbered menu. For a command applied to many files, find ... -exec or xargs are often shorter than a loop and handle huge lists better, but a loop is easier to read, debug and extend, which is what matters in an exam script.
Practice
0 of 5 done
Do these on your own system. They get harder. Check each result, then tick it off.
- Warm-up
Loop over a list and a range
In one script print
user: alice,user: bob,user: carolusing aforover a list, then print the numbers 1 to 5 on one line using a brace range.Check your result
Three
user:lines, then1 2 3 4 5.Hint
for u in alice bob carol; do ... done, andfor i in {1..5}.echo -nsuppresses the newline.Solution
for u in alice bob carol; do echo "user: $u"; done for i in {1..5}; do echo -n "$i "; done; echo - Practice
Rename a batch of files
In an empty directory create
a.txt,b.txt,c.txt. With a loop rename every.txtfile to.bak, keeping the base name. Do not use any external command exceptmv.Check your result
lsshowsa.bak b.bak c.bakand no.txtfiles.Hint
${f%.txt}strips the suffix from$f. Quote"$f"in case a name has a space.Solution
mkdir -p ~/lab/ren && cd ~/lab/ren touch a.txt b.txt c.txt for f in *.txt; do mv "$f" "${f%.txt}.bak"; done ls - Practice
Check several accounts in one run
Write
~/lab/adduse.shthat takes any number of user names. For each one it printsskip NAME (exists)if the account exists, otherwisewould create NAME. It must not create anything: this is a dry run. Run it withrootand a name that does not exist.Check your result
./adduse.sh root zedprintsskip root (exists)andwould create zed.Hint
for u in "$@"; do if id "$u" &>/dev/null; then ... fi; done.Solution
cat > ~/lab/adduse.sh <<'EOT' #!/bin/bash for u in "$@"; do if id "$u" &>/dev/null; then echo "skip $u (exists)" else echo "would create $u" fi done EOT chmod +x ~/lab/adduse.sh ~/lab/adduse.sh root zed - Practice
Read a file line by line
Create
~/lab/users.txtwith the linesalex:1001:/bin/bashandbob:1002:/sbin/nologin. With awhile readloop printNAME -> SHELLfor each line, splitting on the colon.Check your result
Output is
alex -> /bin/bashandbob -> /sbin/nologin.Hint
while IFS=: read -r name uid shell; do ... done < users.txt.IFS=:sets the field separator for thatreadonly.Solution
printf 'alex:1001:/bin/bash\nbob:1002:/sbin/nologin\n' > ~/lab/users.txt while IFS=: read -r name uid shell; do echo "$name -> $shell" done < ~/lab/users.txt - Challenge
Wait for something, but not forever
Write
~/lab/wait-for.sh FILEthat checks once a second whether FILE exists, for at most 10 tries. It printsfound FILEand exits 0 as soon as it exists, orgave up on FILEand exits 1 after 10 failed tries. Test both outcomes, creating the file from a second terminal (or with( sleep 3; touch ... ) &) in the first.Check your result
With the file created after 3 seconds the script prints
foundandecho $?shows0. With no file it printsgave upafter about 10 seconds and the status is1.Hint
Use a counter with
$((n+1)),sleep 1, andbreakor an earlyexit 0when found.Solution
cat > ~/lab/wait-for.sh <<'EOT' #!/bin/bash file="$1" for try in $(seq 1 10); do if [ -e "$file" ]; then echo "found $file" exit 0 fi sleep 1 done echo "gave up on $file" >&2 exit 1 EOT chmod +x ~/lab/wait-for.sh ( sleep 3; touch /tmp/ready ) & ~/lab/wait-for.sh /tmp/ready; echo $? rm -f /tmp/ready ~/lab/wait-for.sh /tmp/ready; echo $?