Module 6 ended with an uncomfortable promise: knowing what an operating system is counts for nothing at three in the morning if you don't know which command to type first. And all of those commands are typed in the same place: the shell. Before you learn to troubleshoot you need a solid grasp of the interface the troubleshooting flows through, because most of a junior administrator's mistakes are not analysis mistakes but shell mistakes: a filename with spaces that got split into two arguments, a pipe that reported success even though the first command failed, a badly set PATH that ran the wrong binary.

This lesson takes the shell apart from the inside. You will see that it is not part of the operating system: it is an ordinary user program that does exactly what you learned in 02-01fork, execve, wait— and whose only magic is turning text into system calls. You will understand why cd cannot be a program, why 2>&1 > file does not do what it looks like, and why set -euo pipefail should head every script you write. We will finish with a pipeline that pulls the busiest stations out of meteo-api.log and with a real script that checks Meteora's data.

Services and boot are the next lesson, and performance tooling the one after that; here we stay at the interface.

Contents

  1. What a shell really is
  2. Available shells and which one to use
  3. Interactive, non-interactive, login: which file gets read
  4. Anatomy of a command, and builtins versus external commands
  5. The expansion order, step by step
  6. Quoting and escaping: 80% of all failures
  7. Redirection and file descriptors
  8. Pipes, exit status and pipefail
  9. Shell variables, environment variables and PATH
  10. Job control and signals
  11. The essential text tools
  12. A complete pipeline over meteo-api.log
  13. Scripting: from a one-off command to a program
  14. Full script: verifying Meteora's data

What a shell really is

Plenty of people believe the shell "is Linux" or that it is part of the kernel. It isn't. /bin/bash is an ordinary ELF executable, like ls or like meteo-api, running in user mode (01-06) and able to do only what any other program can do: system calls. Its main loop, stripped of everything incidental, is this: print the prompt and read a line; parse the text (split it into words, expand it, detect redirections and pipes); if the command is a builtin, run it in the shell's own process; if it is external, fork() to create a child, apply the redirections in it, execve() the program and waitpid() in the parent; store the status in $? and start over.

You can watch it happen with the tool introduced in 01-06:

strace -f -e trace=clone,execve,wait4 -o /tmp/shell.log bash -c 'ls /etc/meteora'
grep -E 'clone|execve|wait4' /tmp/shell.log
# execve("/bin/bash", ["bash", "-c", "ls /etc/meteora"], 0x7ffd...) = 0
# clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|SIGCHLD, ...) = 4711
# [pid  4711] execve("/bin/ls", ["ls", "/etc/meteora"], 0x55f...) = 0
# wait4(-1, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 4711

What this proves. The whole mechanism, with no mystery left: the shell starts (execve of bash), duplicates itself (clone, which is what implements fork on Linux), the child turns into ls (the second execve) and the parent waits (wait4). -f follows the children and -e trace= cuts out the noise. Everything you type in a terminal ends up as this sequence.

Three practical consequences follow from that nature. A shell cannot change anything in its parent process: if a script runs export PATH=..., that variable dies with the script, which is why configuration files are loaded with source (or .), which runs them in the current shell. Every external command costs a process: a loop that launches grep a million times performs a million fork+execve pairs, and at the tens of microseconds per pair we measured in 02-01 that is minutes of pure fork; hence the text tools are designed to process whole streams in one go. And the shell is replaceable: swapping bash for zsh does not change the operating system one bit.

Available shells and which one to use

Shell Usual path Role Notes
sh /bin/shdash on Debian Minimal POSIX shell Fast, no extensions; runs the system's scripts
bash /bin/bash De facto standard on Linux Arrays, [[ ]], PIPESTATUS, process substitution
zsh /bin/zsh Advanced interactive use Better completion, native recursive globbing
fish /usr/bin/fish Friendly interactive use Non-POSIX syntax: don't use it for scripts
nologin /usr/sbin/nologin None Refuses the login; it is the "shell" of meteora

On Debian, /bin/sh points to dash, not to bash. Real consequence: a script that starts with #!/bin/sh but uses [[ ]] or arrays will fail on Debian and will work wherever /bin/sh is bash. It is the classic "works on my machine".

And remember from module 5 that getent passwd meteora returns meteora:x:990:990:Meteora service:/var/lib/meteora:/usr/sbin/nologin. The last field is the login shell, and nologin is a program that prints a message and exits with status 1. Since the login process does an execve of that shell, nobody can get an interactive interpreter as meteora even if they obtain its password: least privilege from 05-01 applied with a single word.

Interactive, non-interactive, login: which file gets read

Here lives one of the most frustrating beginner mistakes. Bash has two independent axes: interactive or not (does it read commands from a terminal with a human in front of it, or does it run a script?) and login or not (is it the first shell of the session, or one started inside an existing session?). Each combination reads different files:

Situation Login? Interactive? Files it reads
ssh joan@meteo-01 Yes Yes /etc/profile and the first of ~/.bash_profile, ~/.bash_login, ~/.profile
Opening a terminal tab No Yes /etc/bash.bashrc, ~/.bashrc
bash script.sh No No None (except $BASH_ENV)
ssh meteo-01 'command' No No None
su - meteora Yes Yes The login files
A systemd service No No None

The classic mistake, in its exact form: you put export METEORA_HOME=/var/lib/meteora in ~/.bashrc, open a terminal and it works; then ssh meteo-01 'echo $METEORA_HOME' comes back empty and a service fails because the variable does not exist. It is not a problem with the variable: a non-interactive shell does not read ~/.bashrc.

The rule of thumb is simple. Whatever defines the environment (variables, PATH) goes in ~/.profile, which is read at login and inherited by everything you start from there. Whatever defines interactive comfort (aliases, prompt, colors, history) goes in ~/.bashrc, because it makes no sense in a script. And whatever a service needs goes in no shell file at all: it goes in its systemd unit with Environment= or EnvironmentFile= (07-02).

That is why Debian's default ~/.bashrc starts with case $- in *i*) ;; *) return;; esac: $- holds the active flags and the letter i appears only if the shell is interactive. If it isn't, return aborts the read. It prevents a real and hard-to-debug failure: if ~/.bashrc writes anything to standard output —a welcome banner, for example— it breaks scp and rsync, which expect a clean stream on that channel.

Anatomy of a command, and builtins versus external commands

A line is split into words separated by spaces or tabs: the first one is the command, the rest are arguments, and among them there are usually options.

Form Example Detail
Short option -n One letter; groupable: -in = -i -n
Short with a value -o file or -ofile Depends on the program
Long option --color=auto Readable; use it in scripts so they still make sense a year from now
End of options -- Everything that follows is an operand, even if it starts with -

The -- separator is not cosmetic. If you create ./-report.txt, the command rm -report.txt answers rm: invalid option -- 'r', because rm reads the name as a string of options; rm -- -report.txt works. It is a mandatory defense in scripts that handle names of external origin: anyone who can create files in a directory you walk can inject options into your commands.

Builtin versus external. A builtin command is compiled into the shell and creates no process at all; an external one is a file on disk executed with fork+execve.

type cd echo grep ll
# cd is a shell builtin
# echo is a shell builtin
# grep is /usr/bin/grep
# ll is aliased to `ls -alF'
which cd          # (nothing: which only looks for files in the PATH)
type -a echo      # echo is a shell builtin / echo is /usr/bin/echo

What this proves. type knows the shell's entire world —aliases, functions, builtins and external commands— whereas which only walks the PATH and therefore lies about builtins: use type. Notice echo: there are two of them, the builtin and /usr/bin/echo, and they behave differently with options such as -e. That is one of the reasons serious scripts prefer printf.

And why can't cd be an external program? Because the working directory is an attribute of the process, stored in its task_struct and visible in /proc/<pid>/cwd (04-02). If cd were an executable, the shell would fork, the child would call chdir(), change its own directory and die; the parent would stay exactly where it was. The change has to happen in the shell's own process. The same logic explains export, umask, ulimit, exec and source.

The expansion order, step by step

Before running anything, bash transforms the line following a fixed, non-negotiable order, which explains almost all the "weird" behavior:

  1. Brace expansion: {a,b}, {1..5} — 2. Tilde: ~, ~meteora — 3. Parameters and variables: $VAR, ${VAR:-def} — 4. Command substitution: $(command) — 5. Arithmetic: $(( 2 + 2 )) — 6. Word splitting according to IFS — 7. Globbing: *, ?, [...] — 8. Quote removal.
mkdir -p /var/lib/meteora/readings/{2026,2025}   # braces: generate text BEFORE anything exists
echo ~meteora                                    # -> /var/lib/meteora  (looks up /etc/passwd)
DATE=2026-08-31; echo /var/lib/meteora/readings/$DATE.dat
                                                 # -> /var/lib/meteora/readings/2026-08-31.dat
echo "Files: $(ls /var/lib/meteora/readings | wc -l)"      # command substitution
echo "Bytes per reading: $(( 17280000 / 720000 ))"         # -> 24, our constant throughout the course

Why the order matters, with three demonstrations that would fail under any other order:

# (a) Globbing (7) happens AFTER variable expansion (3)
PATTERN='*.dat'
echo $PATTERN     # -> 2026-08-30.dat 2026-08-31.dat   (it expanded!)
echo "$PATTERN"   # -> *.dat                            (literal)

# (b) Brace expansion (1) happens BEFORE variable expansion (3)
N=3
echo {1..$N}      # -> {1..3}   (not a valid range, so it is left as is)
seq 1 "$N"        # -> 1 2 3    (the correct form)

# (c) Word splitting (6) happens AFTER command substitution (4)
DIR=$(echo '/tmp/august report')
ls $DIR           # ls: cannot access '/tmp/august' ... nor 'report'
ls "$DIR"         # correct

Explanation. In (a), substituting $PATTERN gives us the text *.dat and, because globbing comes later, that text is subjected again to filename expansion; with quotes it is neither split nor globbed. In (b), when bash processes the braces it has not yet substituted $N, so it sees an invalid range: that is a structural limit of the order, not a bug. In (c), the result of $(...) contains a space and word splitting breaks it into two arguments; double quotes suppress steps 6 and 7. Measured against real incidents, (c) is the number one cause of broken scripts.

Quoting and escaping: 80% of all failures

Form What it protects What it lets through
'text' Everything, no exceptions Nothing; you cannot even escape a single quote inside
"text" Word splitting and globbing $var, $(cmd), `cmd`, \ and ! (interactively)
\c That single character
No quotes Nothing Everything is expanded and split

The golden rule, with no caveats: quote every variable expansion unless you have an explicit reason not to, and that reason is rare. The case of names with spaces deserves the full example, because that is where the most damage is done:

# BAD: breaks with spaces and with names that start with a dash
for f in $(ls /var/lib/meteora/reports); do rm $f; done

# GOOD: direct glob, quotes and -- as a firewall
for f in /var/lib/meteora/reports/*; do
    [ -e "$f" ] || continue      # covers the "no files at all" case
    rm -- "$f"
done

What changes. The bad version piles up three defects: it parses the output of ls (which formats for humans, not for machines), it loses the full path and, without quotes, it splits every name at its spaces, so that august report.pdf turns into two failed deletions. The good version uses the shell's globbing —which returns full paths and does not split on spaces—, checks that the pattern actually expanded (if the directory is empty, bash leaves the pattern literal) and uses --.

Redirection and file descriptors

We pick up 04-04 again: every process starts with three descriptors open —0 stdin, 1 stdout, 2 stderr— and the shell simply manipulates them between the fork and the execve, with open() and dup2(). That detail of timing is what makes the executed program none the wiser: by the time it starts, fd 1 already points where you said.

Syntax Effect Syntax Effect
> f stdout to f, truncating it < f stdin from f
>> f stdout to f, appending <<< 'txt' here-string as stdin
2> f stderr to f <<EOF here-doc as stdin
2>&1 stderr to wherever stdout points now &> f Both to f (bash)

The counterintuitive order. command > out.log 2>&1 sends everything to the file; command 2>&1 > out.log sends stderr to the screen and only stdout to the file. The reason is that 2>&1 literally means "make fd 2 a copy of wherever fd 1 points at this instant": it is a snapshot, not a permanent link. In the first case fd 1 has already moved to the file by the time 2>&1 is processed; in the second it still points at the terminal. It is the dup2() of 04-04, with no added magic.

# Separate the streams, which is the professional thing to do in an automated script
/usr/local/bin/aggregator > /var/log/meteora/aggregator.out 2> /var/log/meteora/aggregator.err

find / -name 'meteora.conf' 2>/dev/null          # discard the "Permission denied" lines

cat > /etc/meteora/limits.conf <<'EOF'           # here-doc WITHOUT variable expansion
max_readings_hour=720000
data_path=/var/lib/meteora/readings
EOF

What they do. The first one leaves separate traces and lets you alert only on the error file. The second takes advantage of find writing its warnings to stderr and its results to stdout, so discarding fd 2 leaves a clean list. In the third, look at the quotes around the delimiter: <<'EOF' does not expand variables inside the block, whereas <<EOF without quotes does; confusing the two produces configuration files with empty values.

Pipes, exit status and pipefail

A pipe connects one process's stdout to the next one's stdin through the pipe() of 03-03: a 64 KB buffer in the kernel, with automatic blocking when it fills up or empties. The processes are launched all at once, not one after another, so on a multi-CPU machine they really do run in parallel; and when head finishes and closes its end, the previous process receives SIGPIPE and dies, which is why head on a huge pipeline does not wait for everything to finish.

The exit status problem. By default, a pipeline's status is that of the last command, which hides failures:

cat /var/lib/meteora/readings/missing.dat | wc -l
echo $?
# cat: ...missing.dat: No such file or directory
# 0        <-- success! because wc finished fine

echo "${PIPESTATUS[@]}"   # -> 1 0   (status of EACH command)
set -o pipefail           # from here on, the pipeline returns 1

Why this is serious. In a script with set -e, that pipeline does not abort execution: the script goes on believing everything is fine and processes zero lines as if they were valid data. It is the kind of silent failure that ends in "yesterday's report came out empty and nobody noticed". PIPESTATUS is a bash array with the status of every element; pipefail changes the rule so that the pipeline returns the status of the last command that failed. It is what you want in every non-trivial script.

tee duplicates a stream: aggregator 2>&1 | tee -a /var/log/meteora/aggregator.log | grep -i error stores everything in the log (with -a to append, not truncate) and at the same time passes a copy on to grep, which shows only the errors on screen. Without tee you would have to choose between seeing and storing.

Shell variables, environment variables and PATH

Kind How it is created Do children inherit it? Where it lives
Shell variable VAR=value No Only in the shell process
Environment variable export VAR=value Yes In the environment block, copied by execve
LOCAL=here_only; export GLOBAL=inherited
bash -c 'echo "[$LOCAL] [$GLOBAL]"'              # -> [] [inherited]
tr '\0' '\n' < /proc/$$/environ | grep GLOBAL    # the process's REAL environment

What this proves. The child receives a copy of the environment block, which is the third argument to execve(); variables that were not exported never make it in there. And /proc/<pid>/environ02-01 again— shows any process's environment, with the values separated by null bytes, hence the tr. A security note: never pass secrets through the environment, because it is readable by the process owner and shows up in core dumps; Meteora's secrets live in /etc/meteora/meteora.conf with mode 600 for exactly that reason.

PATH is the list of directories, separated by :, where external commands are looked up, left to right, stopping at the first match. Picking up 05-02 again: including . in the PATH, and above all putting it first, is a classic mistake; if an attacker drops a file called ls in /tmp and an administrator with sudo runs cd /tmp and types ls, they execute the attacker's program with root privileges. Rules: never include . or directories writable by others; in automated scripts use absolute paths or set PATH explicitly; and check with type -a what is actually going to run before you assume anything.

Job control and signals

A job is a complete pipeline launched from the interactive shell. The shell assigns it a number and can move it between the foreground (it receives the keyboard) and the background.

Action Effect Signal
command & Starts it in the background
Ctrl-C Interrupts the foreground job SIGINT (2)
Ctrl-Z Suspends it SIGTSTP (20)
Ctrl-\ Terminates it with a core dump SIGQUIT (3)
Ctrl-D No signal at all: closes stdin (end of file)
jobs / fg %1 / bg %1 List, bring to the foreground, resume in the background SIGCONT on fg/bg
kill %1 Terminate the job SIGTERM (15)
gzip /var/lib/meteora/readings/2026-07-*.dat
^Z                    # [1]+  Stopped         gzip ...
bg %1                 # [1]+ gzip ... &
jobs -l               # [1]+ 12934 Running     gzip ... &

What just happened. Ctrl-Z sent SIGTSTP to the foreground process group and the process moved into the T (stopped) state you saw in 02-01; bg sent it SIGCONT and left it running without the terminal. You can confirm it with ps -o pid,stat,cmd -p 12934.

SIGHUP and nohup. When you close an SSH session the terminal disappears and the kernel sends SIGHUP to the associated processes, which by default die. nohup cmd > log 2>&1 & makes the process ignore that signal; setsid cmd </dev/null >log 2>&1 goes further and creates a new session with no controlling terminal, so the signal is never even generated; and tmux is the preferable option in practice because it also lets you reconnect and see the output. For genuinely periodic tasks, none of the three: you use a systemd timer, the subject of 07-02.

The essential text tools

Tool What it is for Most-used options
grep Filter lines -i, -v, -c, -n, -E, -o, -F, -r
sed Substitute and edit line by line s/a/b/g, -n '5,10p', -i
awk Process by fields and compute '{print $5}', -F:, END block
cut Extract columns -d' ' -f2, -c1-10
sort Sort -n, -r, -k2, -u, -t:
uniq Collapse duplicates (sort first!) -c, -d
wc Count -l, -c, -w
head/tail The ends of a stream -n 20, tail -f, tail -F
find Search by criteria -name, -mtime, -size, -exec, -print0
xargs Turn input into arguments -0, -n, -P, -I{}

Basic regular expressions: ^ and $ are start and end of line; . is any character; [0-9] a digit; *, + and ? mean zero or more, one or more and optional (the last two require -E); {3} with -E is exactly three repetitions; and | with -E is alternation.

grep -E ' 5[0-9]{2} ' /var/log/meteora/meteo-api.log | wc -l           # 5xx responses
grep -oE '^[0-9]{1,3}(\.[0-9]{1,3}){3}' /var/log/meteora/meteo-api.log | sort -u
tail -F /var/log/meteora/meteo-api.log | grep --line-buffered 'ERROR'  # live tailing

Details that matter. -E turns on extended expressions and saves you escaping {}, | and +; -o prints only the matching part. tail -F (capital F) reopens the file if it is rotated —essential with the logrotate of 05-04—, whereas tail -f sits staring at an inode nobody writes to any more. And --line-buffered forces grep to write line by line: without it, it uses a 4 KB buffer as soon as it detects that its output is a pipe, and you would see the errors minutes late; it is exactly the buffering of 01-06.

find + xargs, safely:

# BAD: breaks with spaces or newlines in the names
find /var/lib/meteora/readings -name '*.dat' -mtime +90 | xargs rm
# GOOD: null separator at both ends
find /var/lib/meteora/readings -name '*.dat' -mtime +90 -print0 | xargs -0 --no-run-if-empty rm --
# Alternative without xargs, grouping into few invocations
find /var/lib/meteora/readings -name '*.dat' -mtime +90 -exec rm -- {} +

Why. The null byte is the only character that cannot appear in a filename on Linux, so -print0 with xargs -0 is the only safe pairing; --no-run-if-empty stops rm from running with no arguments at all. In the third form, -exec ... + groups many files per invocation, whereas -exec ... \; launches one process per file: with 10,000 files the difference is a handful of processes versus 10,000 fork+execve pairs, that is, seconds versus minutes.

A complete pipeline over meteo-api.log

Line format in /var/log/meteora/meteo-api.log (combined log style, with the duration at the end):

10.20.3.41 - - [31/Aug/2026:03:12:07 +0200] "GET /v1/readings?station=EST-0142 HTTP/1.1" 200 8213 0.043

Goal: the 10 stations that generate the most requests.

grep -F 'GET /v1/readings' /var/log/meteora/meteo-api.log \
  | grep -oE 'station=EST-[0-9]{4}' \
  | cut -d= -f2 | sort | uniq -c | sort -rn | head -n 10
#   48213 EST-0142
#   31904 EST-0007
#   28755 EST-0311
Link What it does Why this way
grep -F Filters the endpoint -F searches for fixed text: faster, and / or ? are not interpreted
grep -oE Extracts only the parameter -o drops the rest; requiring 4 digits rules out malformed values
cut -d= -f2 Keeps EST-0142 Delimiter =, field 2
sort Groups equal lines Mandatory: uniq only collapses adjacent lines
uniq -c Counts each group Returns count value
sort -rn Sorts by count -n numeric (otherwise "9" would come after "48213"); -r descending
head -n 10 Cuts the top 10 It also closes the pipe and aborts the rest via SIGPIPE

The awk version, which does the same in a single process:

awk -F'station=' '/GET \/v1\/readings/ && NF>1 { split($2, p, /[" &]/); count[p[1]]++ }
     END { for (s in count) printf "%8d %s\n", count[s], s }' \
     /var/log/meteora/meteo-api.log | sort -rn | head -n 10

Why it is better. -F'station=' splits each line on that string, so that $2 begins with the identifier, and NF>1 discards the lines without the parameter; split cuts at the first character that is not part of the value and p[1] is left holding EST-0142; the associative array accumulates and the END block dumps the totals. On a 5-million-line log, the six-process pipeline with its two full sorts takes about 40 seconds and writes temporary files to disk; the awk version walks the file exactly once and only sorts a few hundred final lines: between 6 and 8 seconds. The general lesson is that sort over millions of lines is usually a pipeline's bottleneck, and that aggregating before sorting changes the order of magnitude.

Scripting: from a one-off command to a program

A script's first line, the shebang, tells the kernel which interpreter to use in the execve. The kernel reads the two bytes #!, takes the rest as an absolute path and runs interpreter path_to_script. Writing #!/bin/bash demands that bash be exactly there; #!/usr/bin/env bash looks it up in the PATH, which also works where it lives in /usr/local/bin. The trade-off is that dependency on PATH, so in scripts run with sudo or as a service it is wise to pin it.

The mandatory header, option by option:

set -euo pipefail
IFS=$'\n\t'
Option Effect Why
-e Abort if a command returns a status ≠ 0 Stops you working on top of a failure
-u Error when an undefined variable is used Turns rm -rf "$DIR/" with an empty DIR into an error, not a disaster
-o pipefail The pipeline fails if any link fails Without this, -e does not see failures inside pipelines
IFS=$'\n\t' Split only on newline and tab Stops a space from splitting a word

set -e has well-known traps: it does not fire inside a function called in a condition, nor in the last command of a pipeline without pipefail, nor with local var=$(cmd) —the status that counts is local's, which is always 0—. That is why the correct way is to declare first (local output) and assign afterwards (output=$(command)).

if [[ -f "$FILE" && -s "$FILE" ]]; then ... ; fi   # exists and is not empty
[[ "$n" -gt 100 ]]           # numeric comparison
[[ "$s" == EST-* ]]          # pattern (no quotes on the right-hand side)
[[ "$s" =~ ^EST-[0-9]+$ ]]   # regular expression
while IFS= read -r line; do ...; done < file
check_size() {
    local path="$1" expected="$2" actual
    actual=$(stat -c '%s' "$path")
    [[ "$actual" -eq "$expected" ]]   # the last command's status is the return value
}

Key details. [[ ]] is a bash construct that does not perform word splitting or globbing inside, so it is safer than [ ], which follows POSIX rules and fails with empty values. while IFS= read -r line is the canonical way to read lines: IFS= prevents trimming of leading and trailing spaces and -r stops \ from acting as an escape. A function returns the status of its last command, or whatever you give it with return N.

The positional arguments are $1, $2…, with $# for the count, "$@" for all of them each as one word (always use this form and not "$*", which joins them into a single string), $? for the last status and $$ for the PID. The exit status convention is the system's own: 0 success, 1 generic error, 2 usage error, and >2 specific errors that you define.

trap for cleanup, indispensable in any script that creates temporary files or takes a lock:

WORKDIR=$(mktemp -d)
cleanup() { local code=$?; rm -rf -- "$WORKDIR"; exit "$code"; }
trap cleanup EXIT INT TERM

What it does. trap registers a handler for signals —the same ones from 03-03— and for the EXIT pseudo-event, which fires whenever the script ends, successfully or not, so that the temporary directory is removed even if it dies halfway. Saving $? at the top of the function and using it in exit preserves the original status, which the rm would otherwise overwrite.

Full script: verifying Meteora's data

This is the lesson's integration exercise: check that the files in /var/lib/meteora/readings/ are complete and up to date, and warn if the current day's file is missing.

#!/usr/bin/env bash
# check-readings.sh — Integrity and freshness of Meteora's data.
# Exits: 0 = OK | 1 = warnings | 2 = usage error | 3 = critical failure
set -euo pipefail
IFS=$'\n\t'

readonly DATA_DIR="/var/lib/meteora/readings"
readonly EXPECTED_SIZE=17280000       # 720,000 readings x 24 B
readonly TOLERANCE_PCT=2
readonly PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

warnings=0; criticals=0
log() { local l="$1"; shift; printf '%s [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$l" "$*" >&2; }
warn()     { log WARN  "$@"; warnings=$((warnings + 1)); }
critical() { log ERROR "$@"; criticals=$((criticals + 1)); }

WORKDIR=$(mktemp -d -t meteora-check-XXXXXX)
cleanup() { local code=$?; rm -rf -- "$WORKDIR"; exit "$code"; }
trap cleanup EXIT INT TERM

verbose=0; directory="$DATA_DIR"
while getopts ':d:v' o; do
    case "$o" in
        d) directory="$OPTARG" ;;
        v) verbose=1 ;;
        *) log ERROR "Usage: ${0##*/} [-d DIRECTORY] [-v]"; exit 2 ;;
    esac
done

[[ -d "$directory" && -r "$directory" ]] || { critical "Cannot read $directory"; exit 3; }

# --- 1. Is today's file there, and is it being written to? ---
today=$(date '+%Y-%m-%d'); today_file="$directory/$today.dat"
if [[ ! -f "$today_file" ]]; then
    critical "MISSING today's file: $today_file"
else
    age_min=$(( ( $(date +%s) - $(stat -c '%Y' "$today_file") ) / 60 ))
    (( age_min > 10 )) && warn "No writes for $age_min min: is the ingestor stopped?"
fi

# --- 2. Size integrity of the files already closed ---
min=$(( EXPECTED_SIZE * (100 - TOLERANCE_PCT) / 100 ))
max=$(( EXPECTED_SIZE * (100 + TOLERANCE_PCT) / 100 ))
find "$directory" -maxdepth 1 -type f -name '*.dat' ! -name "$today.dat" -print0 \
    | sort -z > "$WORKDIR/files.lst"

while IFS= read -r -d '' file; do
    size=$(stat -c '%s' "$file"); base=$(basename -- "$file")
    if (( size % 24 != 0 )); then
        critical "$base: $size bytes is not a multiple of 24 (truncated file)"
    elif (( size < min || size > max )); then
        warn "$base: $size bytes, outside the range [$min, $max]"
    elif (( verbose )); then
        log INFO "$base: OK ($((size / 24)) readings)"
    fi
done < "$WORKDIR/files.lst"

# --- 3. Gaps in the last 30 days of the series ---
for offset in $(seq 1 30); do
    day=$(date -d "$offset days ago" '+%Y-%m-%d')
    [[ -f "$directory/$day.dat" ]] || warn "Missing the file for day $day"
done

# --- 4. Free space ---
usage_pct=$(df --output=pcent "$directory" | tail -n1 | tr -dc '0-9')
if   (( usage_pct >= 90 )); then critical "The file system is $usage_pct% full"
elif (( usage_pct >= 80 )); then warn     "The file system is $usage_pct% full"
fi

log INFO "Check finished: $criticals criticals, $warnings warnings"
(( criticals > 0 )) && exit 3
(( warnings > 0 ))  && exit 1
exit 0

Design decisions, explained:

  • readonly and a pinned PATH. The script will run from a systemd timer, where there is no inherited PATH and no predictable working directory; pinning it removes the command hijacking of 05-02.
  • log writes to stderr. That leaves standard output free in case the script ever produces data, and journald captures both streams anyway.
  • mktemp -d + trap. Never use a fixed path such as /tmp/work: it is a race condition and a symlink attack vector. mktemp creates a directory with an unpredictable name and mode 700.
  • find -print0 | sort -z with read -r -d ''. The complete safety trio against odd names: the separator is the null byte from end to end.
  • size % 24 != 0. It is the cheapest and most informative integrity check for this format: since each Reading takes exactly 24 bytes, a size that is not a multiple of 24 means a truncated write, most likely due to a power cut during a write() with no subsequent fsync (04-05).
  • A 2% tolerance. A real day rarely has exactly 720,000 readings: a station can lose coverage for a few minutes. Alerting with zero tolerance would produce daily noise and the alert would end up ignored.
  • Distinct exit statuses. They let systemd or the monitoring system tell "look at it tomorrow" (1) apart from "act now" (3).

Before putting any script into production, run the static analyzer over it: shellcheck check-readings.sh catches exactly the failures in this lesson —unquoted variables, $(ls), misused [ ], cd without a check, read without -r— and it is the highest benefit-to-effort tool in the shell ecosystem.

Common Mistakes and Tips

Mistake Why it happens Fix
for f in $(ls) ls formats for humans, not for machines for f in ./*
Unquoted variables Word splitting is step 6 "$var" always
2>&1 > f instead of > f 2>&1 2>&1 copies fd 1's current target Redirect stdout first
A pipeline that "works" with empty data The status is the last command's set -o pipefail
A ~/.bashrc variable invisible over non-interactive SSH That file is not read ~/.profile or the systemd unit
rm $DIR/* with an empty $DIR It becomes rm /* set -u and check [[ -n "$DIR" ]]
[ $a == $b ] fails with empty values [ ] receives fewer arguments than expected [[ $a == $b ]]
find ... -exec cmd \; painfully slow One process per file -exec cmd {} + or xargs -0
uniq groups nothing It only collapses adjacent lines sort first, always
#!/bin/sh with bash syntax On Debian sh is dash #!/usr/bin/env bash
Works by hand and fails when automated Different PATH, cwd and environment Absolute paths and a pinned PATH
tail -f stops showing lines The file was rotated tail -F

Tips that save hours: before a mass rm or mv, replace the action with echo and review the whole output; use Ctrl-R to search the history instead of retyping long commands; debug with set -x or bash -x script.sh, which prints every command fully expanded before running it; comment the why, not the what; and try to make every automated script idempotent, because sooner or later somebody will retry it.

Exercises

Exercise 1: expansion and quoting

Without running anything, predict the exact output and say which expansion step determines it. Then check it.

cd /tmp && mkdir -p demo && cd demo
touch 'august report.dat' 'july-report.dat' '-weird.dat'
A='*.dat'; N=2
echo 1: $A
echo 2: "$A"
echo 3: {1..$N}
for f in *.dat; do echo "4: [$f]"; done

Exercise 2: analyzing the API log

Using the meteo-api.log format from this lesson, write a single pipeline (or one awk) to: (1) count the requests with a 5xx status; (2) get the 5 IPs with the most requests and their counts; (3) compute the mean latency of /v1/readings in milliseconds; (4) list the hours of the day with more than 100,000 requests.

Exercise 3: an old-data rotation script

Write archive-readings.sh that compresses with gzip the .dat files in /var/lib/meteora/readings/ older than 90 days, skips the ones already compressed, never touches the current day's file, takes a lock so it cannot overlap with itself, logs what it did, supports a -n dry-run mode and returns a correct exit status. It must be safe against names with spaces and pass shellcheck.

Solutions

Solution 1

1: august report.dat july-report.dat -weird.dat
2: *.dat
3: {1..2}
4: [-weird.dat]
4: [august report.dat]
4: [july-report.dat]
  • Line 1. $A is substituted in step 3, giving the text *.dat and, because globbing is step 7, that text is expanded again against the directory. That is the trap of storing patterns in variables.
  • Line 2. Double quotes suppress steps 6 and 7, so the literal value is printed.
  • Line 3. Brace expansion is step 1, before variable expansion: bash sees {1..$N}, which is not a valid range, and leaves it as is. The correct form is seq 1 "$N".
  • Line 4. The loop over the glob is correct: each name arrives whole, spaces included, and the order is the locale's (which is why -weird.dat comes first). If you used rm $f inside without quotes, -weird.dat would be read as options: hence rm -- "$f".

Solution 2

# 1. 5xx requests
awk '$9 >= 500 && $9 < 600 {n++} END {print n+0}' /var/log/meteora/meteo-api.log

# 2. Top 5 IPs
awk '{c[$1]++} END {for (ip in c) printf "%8d %s\n", c[ip], ip}' \
    /var/log/meteora/meteo-api.log | sort -rn | head -n 5

# 3. Mean latency in ms of /v1/readings
awk '/GET \/v1\/readings/ {sum += $NF; n++}
     END {if (n) printf "mean=%.1f ms over %d requests\n", sum*1000/n, n; else print "no data"}' \
     /var/log/meteora/meteo-api.log

# 4. Hours with more than 100,000 requests
awk '{split($4, t, ":"); c[t[2]]++}
     END {for (h in c) if (c[h] > 100000) printf "%s:00 -> %d\n", h, c[h]}' \
     /var/log/meteora/meteo-api.log | sort

Comments. In (1), $9 is the status code in the combined format and n+0 forces a 0 to be printed instead of an empty string when there are no matches, a detail that matters if the output feeds an alerting system. In (2) the file is walked exactly once and only the distinct IPs are sorted, not the millions of lines. In (3), $NF is the last field (duration in seconds) and the if (n) guard avoids division by zero, which is fatal in awk; remember that the mean hides the tail, and in 07-03 you will see why p99 is the metric that matters. In (4), field 4 is [31/Aug/2026:03:12:07, so splitting it on : makes t[2] the hour.

Solution 3

#!/usr/bin/env bash
# archive-readings.sh — Compresses readings older than 90 days.
# Exits: 0 = OK | 1 = with incidents | 2 = usage | 3 = critical
set -euo pipefail
IFS=$'\n\t'

readonly DIR="/var/lib/meteora/readings"
readonly DAYS=90
readonly LOCK="/run/meteora/archive.lock"
readonly PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

dry_run=0; failures=0; compressed=0
log() { printf '%s [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$1" "${*:2}" >&2; }

while getopts ':n' o; do
    case "$o" in
        n) dry_run=1 ;;
        *) log ERROR "Usage: ${0##*/} [-n]"; exit 2 ;;
    esac
done

mkdir -p -- "$(dirname -- "$LOCK")"
exec 9>"$LOCK"
flock -n 9 || { log INFO "Another run is already in progress; skipping this one."; exit 0; }
trap 'flock -u 9' EXIT

[[ -d "$DIR" ]] || { log ERROR "$DIR does not exist"; exit 3; }
today=$(date '+%Y-%m-%d')

while IFS= read -r -d '' f; do
    base=$(basename -- "$f")
    [[ "$base" == "$today.dat" ]] && continue     # never the current day's file
    [[ -e "$f.gz" ]] && { log WARN "$base.gz already exists, skipping"; continue; }
    (( dry_run )) && { log INFO "[dry run] gzip $base"; continue; }
    if gzip -9 -- "$f"; then
        compressed=$((compressed + 1)); log INFO "Compressed $base"
    else
        failures=$((failures + 1)); log ERROR "Failed to compress $base"
    fi
done < <(find "$DIR" -maxdepth 1 -type f -name '*.dat' -mtime "+$DAYS" -print0)

log INFO "Finished: $compressed compressed, $failures failures (dry_run=$dry_run)"
(( failures > 0 )) && exit 1
exit 0

Decisions, explained:

  • exec 9>"$LOCK" + flock -n 9. It opens the lock file on the script's own descriptor 9 and takes a non-blocking exclusive lock: the same mechanism from 04-04 that the aggregator uses. If another instance holds it, we exit with status 0, because "it is already running" is not a system failure and must not fire an alert.
  • done < <(find ...) instead of find ... | while. This is crucial: with a pipe, the while would run in a subshell and the counters would be lost when it ended, leaving them permanently at zero.
  • -mtime "+$DAYS" and -print0 delegate the date filtering to find, which reads the inode directly, and guarantee that no name with spaces or newlines breaks the loop.
  • Checking -e "$f.gz" avoids losing data if a previous run was left half-finished; gzip -9 is reasonable because the historical files are written once and read rarely, and on very regular 24-byte binary records it shrinks them by 60 to 70%.
  • A dry-run mode before any destructive operation, and logging to stderr so journald picks it up as is.

Conclusion

The shell has stopped being a black box. You now know it is just another user program whose loop is read, expand, fork, execve, wait, and that this nature explains everything else: why cd has to be a builtin, why unexported variables never reach the children, why a script cannot change its parent's directory and why every external command costs a process.

You have seen the four mechanisms you simply have to master. The expansion order, with its eight steps, which explains why {1..$N} does not work and why a pattern stored in a variable gets expanded twice. Quoting, which is not decoration: it suppresses word splitting and globbing, and its absence is the leading cause of broken scripts. Redirection, which is dup2() with syntactic sugar, and from which the asymmetry of > f 2>&1 versus 2>&1 > f follows. And pipes, which are module 3's pipe() with its 64 KB buffer, its SIGPIPE and its exit-status trap that pipefail fixes.

On that foundation you have built real tools: a pipeline that pulls the busiest stations out of meteo-api.log —and its awk version, five times faster because it aggregates before sorting— and a verification script that applies every defense at once: set -euo pipefail, mktemp with trap, -print0 with read -d '', absolute paths, getopts, distinct exit statuses and integrity checks based on the real 24-bytes-per-reading format.

But a lone script is not a system. The aggregator must run every hour even if the machine was switched off, meteo-api must start only after /var/lib/meteora is mounted and restart if it falls over, and all of that has to survive a server reboot. The shell no longer solves that: the service manager does, the piece that starts as PID 1 and decides what runs, when, and under which limits.

That is the next topic: Services, Boot and systemd, where we will follow meteo-01 from the moment the power button is pressed until meteo-api accepts its first request on port 443.

Operating Systems Fundamentals

Module 1: Introduction to Operating Systems

Module 2: Resource Management

Module 3: Concurrency

Module 4: File Structures

Module 5: System Protection and Security

Module 6: Virtualization and Containers

Module 7: Administration and Troubleshooting in Practice

© Copyright 2026. All rights reserved