1. Courses
  2. Engineering Roles
  3. TechOps: Linux, Networks and IT Operations
  4. Stop 2

35 minBeginner

Shell scripting for daily jobs

Every store box needs the same small jobs done every night: check the disk, clear old logs, report problems. This lesson teaches you to write those jobs as short Bash scripts that stop when something goes wrong, never delete the wrong file, and run on a schedule without anyone watching.

By the end you can

  • Write a Bash script with a shebang, quoted variables, arguments and functions
  • Make a script stop at the first failure with exit codes and set -euo pipefail, and know where that safety net has holes
  • Delete old files safely with find, a printed list first and a dry-run flag
  • Schedule a job with cron or a systemd timer, and find its output the next morning
  • Check a script with ShellCheck before it goes to 320 store boxes

The everyday picture

Think of the closing checklist taped inside the office door of a store. Count the till. Switch off the bakery oven. Throw away bread older than two days. Lock the back door. A good checklist is precise: "bread older than two days" means the same thing to every person who closes the shop.

A good checklist also says when to stop. "If the till is short by more than $20, do not leave. Call the store manager." Without that line, a tired new staff member finishes the list, locks up and goes home, and the problem is found the next morning when it is much harder to fix.

And the best checklists say "look before you throw": put the old bread on the counter, check the dates, then bin it. A shell script is that checklist written so exactly that a computer can follow it at 2 a.m. The precision, the stop rule and the look-before-you-throw step are what this lesson is about.

A script is a file of commands, read word by word

In The terminal without fear you typed commands one at a time, and in Linux from the command line you used them on a store box. A script is a text file with those same commands in order. The first line is the , which tells Linux which program reads the file:

Terminal
#!/usr/bin/env bash
# hello-store: print who and where we are
store=$(hostname)                 # run a command, keep its output
echo "Running on ${store} as $(whoami)"

Save it as hello-store, make it executable with chmod +x hello-store, and run it with ./hello-store. On a store box it prints Running on store-0142 as techops. Two details matter on Ubuntu. First, sh is a smaller shell called dash, so sh hello-store ignores the shebang and runs the file with dash, and Bash-only features such as [[ ]] fail. Run scripts as ./name or bash name. Second, store=$(hostname) has no spaces around the equals sign. store = x would try to run a program called store.

$(hostname) is a : Bash runs the command and pastes its output into the line. This is how a script reads the result of one command and decides what to do next.

Now the most important rule in shell scripting. When Bash meets an unquoted variable like $dir, it does not paste the value in as one piece. It first splits the value at every space, tab and newline (), then treats any * or ? in the pieces as a and expands it into file names. Only then does the command run. So:

Terminal
dir="/srv/till exports"            # a folder name with a space
rm -rf $dir/old                    # rm receives TWO paths: /srv/till and exports/old
rm -rf "$dir/old"                  # rm receives one path: /srv/till exports/old

The first rm deletes the wrong things. The fix is to put double quotes around every variable: "$dir", "$1", "$@". Inside double quotes Bash still replaces the variable, but it keeps the value as one word and does not expand globs. Single quotes stop the replacement completely, so '$dir' is the four characters $dir.

Quotes do not protect you from an empty variable. If dir is empty, rm -rf "$dir"/* becomes rm -rf /*. Write "${dir:?}" in any destructive command: if the variable is unset or empty, Bash prints an error and stops the script before the command runs.

Quick check

backup_dir="/srv/till backups", then the script runs ls $backup_dir without quotes. What does ls receive?

Exit codes: how a script knows something failed

Every command ends with an . Zero means success, and any other number means failure. grep, for example, returns 0 when it finds a match, 1 when it finds none, and 2 when it cannot read the file. Bash keeps the last exit code in $?, and if, && and || all decide on it:

Terminal
if grep -q "PRINTER OFFLINE" /var/log/store-print/print.log; then
  echo "printer problem seen today"
fi
mkdir -p /srv/reports && cd /srv/reports   # cd only if mkdir worked
systemctl is-active --quiet store-print || echo "print service is down" >&2

By default Bash ignores failures and carries on with the next line. On a store box that is dangerous: if cd /srv/reports fails, the next rm runs in whatever folder the script happened to be in. Three settings, written together at the top of almost every script, change that:

  • set -e stops the script when a command fails.
  • set -u treats an unset variable as an error, so a typo like $LOG_DRI stops the script instead of becoming an empty string.
  • set -o pipefail changes what a reports. Normally a pipeline's exit code is the last command's. With pipefail, it is the exit code of the last command that failed, so a failure anywhere in the pipe is seen.
Without pipefail With pipefail grep ERROR gone.log exit 2: no file grep ERROR gone.log exit 2: no file empty pipe empty pipe wc -l prints 0, exit 0 wc -l prints 0, exit 0 status 0 status 2 Script carries on reports 0 errors from a missing log set -e stops it exit code 2 and grep's error message
The same pipeline, grep ERROR gone.log | wc -l, on a log file that does not exist. Without pipefail the successful wc hides the failed grep, and the script reports "0 errors". With pipefail the pipeline fails, and set -e stops the script.

set -e is a safety net with holes, and you need to know where they are. It does not fire for a command inside an if or while condition, for any command in an && or || list except the last one, or for a command after !. That is on purpose: those are places where you are already handling the result. Two holes catch people more often. local count=$(cmd) inside a function always succeeds, because the exit code you get is the one from local, not from cmd. Declare first and assign on the next line. And a command that "fails" in a normal way, like grep -c finding no matches, stops the whole script. When no match is a fine answer, say so: count=$(grep -c "PRINTER OFFLINE" print.log || true).

Quick check

A script starts with set -euo pipefail and runs failed=$(grep -c "PRINT FAIL" /var/log/store-print/print.log). Tonight the log has no PRINT FAIL lines. What happens?

Decisions, loops, functions and arguments

Real jobs need to choose and repeat. Bash's [[ ]] tests a condition and returns 0 or 1, so it plugs straight into if:

Terminal
if [[ -d "$LOG_DIR" ]]; then echo "folder exists"; fi      # -d folder, -f file
if [[ -z "${ALERT_URL:-}" ]]; then echo "no alert URL"; fi  # -z empty, -n not empty
if (( use > 85 )); then echo "disk too full"; fi           # numbers: use (( )) or -gt

The last line hides a trap. Inside [[ ]], the > sign compares text, letter by letter, so [[ 9 > 10 ]] is true because "9" sorts after "1". For numbers, use (( a > b )) or [[ $a -gt $b ]].

A for loop walks over a list. Loop over a glob directly, never over the output of ls, which breaks on spaces. If nothing matches, Bash leaves the pattern as literal text, so shopt -s nullglob makes an empty match an empty list. A while read loop walks over lines of a file or of a command's output:

Terminal
shopt -s nullglob
for f in /var/log/store-print/*.log; do
  echo "$(basename "$f"): $(wc -l < "$f") lines"
done

# check a list of store boxes from the TechOps jump host
while IFS= read -r box; do
  ssh -n "$box" df -h /          # -n: do not let ssh swallow the rest of the list
done < stores-north.txt

IFS= read -r reads each line exactly as written, keeping leading spaces and backslashes. The -n on ssh matters: without it, ssh reads from the same input as the loop, eats the remaining lines, and the loop stops after the first store.

A names a group of commands. Inside it, $1, $2 are its own arguments, and local keeps a variable from leaking into the rest of the script. The script itself gets its arguments the same way: $1 is the first, $# is how many there are, and "$@" is all of them, each kept as one word. A while loop with case and shift reads options like --dry-run:

Terminal
DRY_RUN=0
while [[ $# -gt 0 ]]; do
  case "$1" in
    --dry-run) DRY_RUN=1; shift ;;                     # shift drops $1, the rest move up
    *) echo "unknown option: $1" >&2; exit 2 ;;
  esac
done

Quick check

A script has use=9 and limit=85, then if [[ $use > $limit ]]; then alert; fi. What happens?

Deleting files safely and running twice safely

Clean-up scripts are where a typo costs the most. The tool for "old files" is find. It walks a folder and checks each file against a list of tests, left to right, and runs an action on the files that pass every test:

Terminal
find /var/log/store-print -xdev -type f -name '*.log.*' -mtime +14 -print

Read it as: in this folder, stay on this disk (-xdev), only regular files (-type f), only rotated logs whose name has something after .log, like print.log.3.gz (-name '*.log.*', quoted so the shell does not expand it), only files last changed more than 14 days ago (-mtime +14), and print the name. That name pattern is your protection for the live print.log, the file the print service is writing right now.

-mtime has a rule that surprises people: find works out a file's age in whole days and drops the fraction. A file that is 14 days and 20 hours old counts as 14, and 14 is not "more than 14". So -mtime +14 matches files at least 15 full days old, and -mtime 14 (no plus) means "exactly 14", which is rarely what you want.

3 files in the folder -type f -name '*.log.*' kept: print.log the live file 2 rotated logs -mtime +14 kept: .log.3.gz 5 days old print.log.9.gz, 20 days Action, set by the flag --dry-run: -print real run: -print -delete names printed Journal keeps the list
Each test drops the files that fail it, and only files that pass every test reach the action. Run with -print first and read the list; the real run adds -delete after the same tests, so it deletes exactly the list you read.

Three habits make deletes safe. Print first. Run the exact command with -print, read the list, then add -delete at the end. The order matters: find runs left to right, so find /var/log/store-print -delete -name '*.gz' deletes every file in the folder before the name test is ever checked. Build the dry run into the script as a --dry-run flag that prints instead of deleting, so the next person can check it on a new store box too. Keep the printed list in the real run (-print -delete), so the log says what went.

One more fact about deletes on Linux. If a program still has a file open, deleting it removes the name, but the disk space comes back only when the program closes the file. Delete the live print.log and df still shows the disk full until the print service restarts. Rotated logs are closed, which is one more reason the script only touches those.

A daily job should also be . A retry, a missed night caught up at boot, or a second engineer running it by hand must not do harm. Use mkdir -p (no error if the folder exists), rm -f (no error if the file is gone), "delete files older than 14 days" instead of "delete the oldest 5 files", and a lock so two copies never run at once: flock -n on a lock file exits straight away if another copy holds it.

Quick check

Today a rotated log is 7 days and 20 hours old. Does find /var/log/store-print -name '*.log.*' -mtime +7 match it?

Logging and scheduling: cron and systemd timers

A job that runs at 2 a.m. has nobody watching it, so it must write down what it did. The simplest rule: print normal progress to standard output with echo, print problems to standard error with echo "…" >&2, and exit with 0 on success and a non-zero code on failure. The scheduler decides where that output goes.

is the classic scheduler. A line has five time fields (minute, hour, day of month, month, day of week) and a command. 30 2 * * * means 02:30 every day. Cron runs jobs with a very small environment: on Ubuntu the PATH is only /usr/bin:/bin, there is no terminal, and output that you do not redirect is mailed to the user if a mail system exists, or lost. So in cron, use full paths, send output somewhere on purpose, and remember that % means "new line" in a crontab, so date +%F must be written date +\%F.

A is the modern choice on Ubuntu, and it fixes most of cron's gaps. You write two small files: a .service unit that runs the script once, and a .timer unit that says when. Output goes to the journal, so journalctl -u disk-check shows every night's run, the same way you read service logs in Linux from the command line. A failed run marks the unit as failed, which systemctl --failed lists. Persistent=true runs a missed job at boot if the box was off at the planned time, which happens when a store loses power overnight. RandomizedDelaySec= adds a random wait, so 320 store boxes do not all hit the data centre in the same second. And systemd does not start the service again while the last run is still going.

disk-check.timer 02:00 + up to 30 min starts disk-check.service oneshot: run once runs disk-check script clean, measure, decide exit code Journal stdout, stderr Unit state exit 1: failed TechOps alert curl if over 85%
The timer only decides when. The service runs the script once, and systemd collects the three things you need the next morning: the output in the journal, the exit code as the unit's state, and any alert the script sends itself.

Before a script goes to every store, run on it. It reads the script without running it and points at missing quotes, masked exit codes, globs in the wrong place and dozens of other traps, each with a code like SC2086 that has its own explanation page. Treat its warnings like a review comment from a senior colleague: fix them, or add a comment saying why the line is fine.

Build it

We will write, check, install and schedule disk-check: a nightly job for an Ubuntu 24.04 store box that cleans old print logs and alerts when the root disk is over 85% full.

  1. Write the settings, options and helpers

    Save this as disk-check.sh. The settings sit at the top so they are easy to find. The options loop reads --dry-run and --threshold. ALERT_URL comes from the environment, so the same script works on every store box.

    Terminal
    #!/usr/bin/env bash
    # disk-check: nightly disk check and print-log clean-up for a store box
    set -euo pipefail
    
    THRESHOLD=85                      # alert when / is fuller than this (percent)
    KEEP_DAYS=14                      # delete rotated logs older than this
    LOG_DIR=/var/log/store-print      # the print service writes its logs here
    DRY_RUN=0
    
    usage() { echo "usage: disk-check [--dry-run] [--threshold N]" >&2; exit 2; }
    
    while [[ $# -gt 0 ]]; do
      case "$1" in
        --dry-run)   DRY_RUN=1; shift ;;
        --threshold) [[ "${2:-}" =~ ^[0-9]+$ ]] || usage; THRESHOLD=$2; shift 2 ;;
        *)           usage ;;
      esac
    done
    
    disk_use_pct() {                  # "87%" from df becomes 87
      df --output=pcent "$1" | tail -n 1 | tr -dc '0-9'
    }
    
    alert() {
      echo "ALERT: $*" >&2
      if [[ -n "${ALERT_URL:-}" ]]; then
        # a failed send is logged, but must not hide the disk problem
        curl -fsS --max-time 10 --data-urlencode "host=$(hostname)" \
          --data-urlencode "msg=$*" "$ALERT_URL" >/dev/null || echo "alert send failed" >&2
      fi
    }
  2. Write the clean-up and the main part

    Add this below the helpers. The lock stops a second copy, ${LOG_DIR:?} refuses to run with an empty folder name, and the dry run uses exactly the same tests as the real run.

    Terminal
    clean_old_logs() {
      local dir="${1:?}" days="$2"
      if (( DRY_RUN )); then
        echo "dry run, would delete:"
        find "$dir" -xdev -type f -name '*.log.*' -mtime +"$days" -print
      else
        find "$dir" -xdev -type f -name '*.log.*' -mtime +"$days" -print -delete
      fi
    }
    
    exec 9>/run/lock/disk-check.lock           # open the lock file as descriptor 9
    flock -n 9 || { echo "disk-check is already running" >&2; exit 0; }
    
    [[ -d "${LOG_DIR:?}" ]] || { echo "no folder $LOG_DIR" >&2; exit 1; }
    
    echo "disk use before: $(disk_use_pct /)%"
    clean_old_logs "$LOG_DIR" "$KEEP_DAYS"
    after=$(disk_use_pct /)
    echo "disk use after: ${after}%"
    
    if (( after > THRESHOLD )); then
      alert "root disk at ${after}% (limit ${THRESHOLD}%)"
      exit 1                                     # the unit shows as failed
    fi
    echo "ok"
  3. Check it with ShellCheck, then dry-run it

    Install ShellCheck once with sudo apt install shellcheck. A clean script prints nothing. To see what a warning looks like, here is the output for an older draft that had find $dir without quotes:

    Terminal
    shellcheck disk-check.sh
    sudo bash disk-check.sh --dry-run
    Output
    In disk-check.sh line 34:
        find $dir -xdev -type f -name '*.log.*' -mtime +"$days" -print
             ^--^ SC2086 (info): Double quote to prevent globbing and word splitting.
    
    disk use before: 93%
    dry run, would delete:
    /var/log/store-print/print.log.15.gz
    /var/log/store-print/print.log.16.gz
    /var/log/store-print/print.log.17.gz
    disk use after: 93%
    ALERT: root disk at 93% (limit 85%)

    The second half is the dry run on a full test box: three files would go, and since nothing was deleted the alert still fires. Read the list. Only when it holds exactly the files you expect do you move on.

  4. Install the script and write the two units

    install copies the file and sets its owner and mode in one step. The service runs the script once; the timer says when. Comments in unit files must be on their own lines.

    Terminal
    sudo install -o root -g root -m 0755 disk-check.sh /usr/local/sbin/disk-check
    echo 'ALERT_URL=https://alerts.example.com/techops' | sudo tee /etc/default/disk-check
    INI
    # /etc/systemd/system/disk-check.service
    [Unit]
    Description=Nightly disk check and print-log clean-up
    
    [Service]
    Type=oneshot
    # the dash means: carry on if the file is missing
    EnvironmentFile=-/etc/default/disk-check
    ExecStart=/usr/local/sbin/disk-check
    INI
    # /etc/systemd/system/disk-check.timer
    [Unit]
    Description=Run disk-check every night
    
    [Timer]
    OnCalendar=*-*-* 02:00:00
    # spread 320 store boxes over half an hour
    RandomizedDelaySec=30min
    # if the box was off at 02:00, run at the next boot
    Persistent=true
    
    [Install]
    WantedBy=timers.target
  5. Turn the timer on and read the next morning's result

    Enable the timer (not the service), run the service once by hand to test the full path, and read its log.

    Terminal
    sudo systemctl daemon-reload
    sudo systemctl enable --now disk-check.timer
    systemctl list-timers disk-check.timer
    sudo systemctl start disk-check.service      # one run now, as the timer would
    journalctl -u disk-check.service --since today
    Output
    NEXT                        LEFT LAST PASSED UNIT             ACTIVATES
    Wed 2026-10-21 02:17:42 UTC 9h   -    -      disk-check.timer disk-check.service
    
    1 timers listed.
    
    Oct 20 16:41:05 store-0142 systemd[1]: Starting disk-check.service - Nightly disk check and print-log clean-up...
    Oct 20 16:41:05 store-0142 disk-check[5120]: disk use before: 93%
    Oct 20 16:41:05 store-0142 disk-check[5120]: /var/log/store-print/print.log.15.gz
    Oct 20 16:41:05 store-0142 disk-check[5120]: /var/log/store-print/print.log.16.gz
    Oct 20 16:41:05 store-0142 disk-check[5120]: /var/log/store-print/print.log.17.gz
    Oct 20 16:41:05 store-0142 disk-check[5120]: disk use after: 71%
    Oct 20 16:41:05 store-0142 disk-check[5120]: ok
    Oct 20 16:41:05 store-0142 systemd[1]: disk-check.service: Deactivated successfully.
    Oct 20 16:41:05 store-0142 systemd[1]: Finished disk-check.service - Nightly disk check and print-log clean-up.

    The exact wording of these lines depends on the systemd version. To check a calendar expression before you trust it, systemd-analyze calendar '*-*-* 02:00:00' prints the next time it will fire.

  6. If a box only has cron

    The same job as one file in /etc/cron.d. Files there have a user field after the five time fields. The full path avoids cron's short PATH, and logger sends the output to the journal under the tag disk-check. Unlike systemd, cron will start a new run while an old one is still going, which is why the script takes a lock.

    Terminal
    # /etc/cron.d/disk-check: minute hour day month weekday user command
    ALERT_URL=https://alerts.example.com/techops
    30 2 * * * root /usr/local/sbin/disk-check 2>&1 | logger -t disk-check

Bring it to your team

Ask these in every review of a script that will run on store boxes.

  • "Does it start with set -euo pipefail, and where does it handle a command that may fail on purpose?"
  • "Is every variable quoted, and is every path in a delete guarded against being empty?"
  • "Show me the dry-run output from one real store box. Which files would it delete?"
  • "What happens if it runs twice at once, or twice in a row, or after a night the box was off?"
  • "Where do we read its output tomorrow morning, and what tells us it failed?"
  • "Is ShellCheck clean, and if not, why is each warning safe?"

Red flags

  • rm -rf $DIR/* with no quotes and no ${DIR:?} guard
  • -delete on a find command that has never been run with -print on a real box
  • A clean-up that can match the live log file or other services' files
  • A cron job with output sent to /dev/null, so a failure leaves no trace
  • Every store box scheduled at the same minute to send data to the same central service

Practice

Model the clean-up rule

Bash cannot run in the browser, so model the clean-up rule as a JavaScript function. Each file is { name, day }, where day is the whole day number it was last changed, and nowDay is today. Return the names to delete, in the same order as the input. A file is deleted when its age, nowDay - day, is more than keepDays (like find -mtime +keepDays), except that the keepNewest newest files are always kept, however old they are, so there is always something to look at after a quiet month. No two files share the same day. The starter has two bugs.

JavaScript exerciseRuns in your browser with tests
function filesToDelete(files, nowDay, keepDays, keepNewest) {
  // delete files older than keepDays
  return files
    .filter((f) => nowDay - f.day >= keepDays)
    .map((f) => f.name);
}
Ctrl + Enter runs
Show a solution
JavaScript
function filesToDelete(files, nowDay, keepDays, keepNewest) {
  // the keepNewest files with the highest day are protected
  const newest = [...files]
    .sort((a, b) => b.day - a.day)
    .slice(0, keepNewest)
    .map((f) => f.name);
  return files
    .filter((f) => nowDay - f.day > keepDays)   // strictly more, like -mtime +N
    .filter((f) => !newest.includes(f.name))
    .map((f) => f.name);
}

The starter uses >=, so it deletes a file that is exactly 14 days old, which -mtime +14 keeps. It also has no "keep the newest" rule, so after a quiet month it would delete every log and leave nothing to investigate. Copying the list before sorting keeps the original order for the result.

Pass the exercise to finish this task.

Review a clean-up script before it ships

A teammate wants to put this on every store box, run nightly, to remove till export files older than 30 days from the folder given as the first argument. Find at least four problems and write a safer version.

Terminal
#!/usr/bin/env bash
# remove till exports older than 30 days
DIR=$1
cd $DIR
rm -rf $DIR/*.csv
find . -mtime 30 -delete
echo "done"
  • You spot that there is no set -euo pipefail, so if cd fails, the next lines run in the wrong folder
  • You spot that $DIR is unquoted and unchecked: empty, it makes the cd go home and the rm target /*.csv
  • You spot that rm -rf $DIR/*.csv deletes every export, whatever its age
  • You spot that -mtime 30 means exactly 30 days, and that the find has no -type f or name test, so it can delete any file
  • Your version prints before deleting (a dry-run flag or -print) and logs what it deleted
Show a model answer

Problems: no strict mode, so a failed cd is ignored; unquoted and unchecked $DIR; the rm line deletes all CSV files, new ones included; -mtime 30 matches only files exactly 30 days old; find . with no type or name test can delete anything in the folder; nothing is printed, so nobody can tell what went. A safer version:

Terminal
#!/usr/bin/env bash
# clean-exports DIR [--dry-run]: remove till export CSVs older than 30 days
set -euo pipefail
dir="${1:?usage: clean-exports DIR [--dry-run]}"
[[ -d "$dir" ]] || { echo "no folder $dir" >&2; exit 1; }

action=(-print -delete)
[[ "${2:-}" == "--dry-run" ]] && action=(-print)

# no cd needed: find works on the path directly
find "$dir" -xdev -maxdepth 1 -type f -name '*.csv' -mtime +30 "${action[@]}"

Check yourself

A nightly script works every time you run it by hand. From cron at 02:30 it fails with logrotate: command not found. logrotate is installed in /usr/sbin. What is the most likely cause?

All 320 store boxes run a health script at 02:00 that posts a short report to one central TechOps service. Every night a few dozen posts are refused and those boxes show the job as failed. What is the best fix?

A store box disk is at 97%. A teammate deletes the live print.log (6 GB) by hand, but df -h still shows 97%. Why, and what next?

Flashcards

Open my review

Key takeaways

  • Quote every variable and guard destructive paths with ${var:?}; Bash splits and expands anything unquoted.
  • Start scripts with set -euo pipefail, and know its holes: conditions, && and || lists, local x=$(cmd), and commands like grep that return 1 for a normal "no".
  • Delete with find, tests first and action last: print the list, add a dry-run flag, and only match closed, rotated files.
  • Make daily jobs idempotent and take a lock, so retries and catch-up runs are harmless.
  • Prefer systemd timers on Ubuntu: output in the journal, failures visible, Persistent=true and a random delay. Run ShellCheck before anything ships.

Sources

Episode transcriptThe script that never deletes the wrong file · about 12 minutes · tap any line to play from there
  1. NadiaImran, picture a Friday evening. The tills in one store start printing receipts slowly, then stop printing at all. What do you guess is wrong?
  2. ImranPrinter cable? Paper jam? That's what the store staff would check first.
  3. NadiaThat's what they checked. The real cause was the store box, the small Linux server in the back office. Its disk was one hundred percent full of old print logs.
  4. ImranSo nobody was cleaning up the logs. And I bet nobody was watching the disk either.
  5. NadiaExactly. Today's episode is about the small nightly scripts that do both jobs, and about writing them so they never make things worse.
  6. NadiaStart with a picture. Think of the closing checklist taped inside a store's office door. Count the till, switch off the oven, throw away bread older than two days, lock up.
  7. ImranAma has one of those in her store. It's laminated.
  8. NadiaA good one has three qualities. It's precise, so two days means two days for everyone. It says when to stop, like: if the till is short, don't go home, call the manager. And it says look before you throw: put the old bread on the counter and check the dates first.
  9. ImranAnd a shell script is that checklist, written so exactly that a computer can follow it at two in the morning.
  10. NadiaYes. Precision, a stop rule, and look before you throw. Those three ideas carry the whole lesson.
  11. ImranLet's start with precision then. What's the first thing that goes wrong for beginners?
  12. NadiaQuotes. When Bash sees a variable without double quotes around it, it doesn't paste the value in as one piece. It splits the value at every space, and it treats stars and question marks as file name patterns.
  13. ImranSo if a folder is called till exports, with a space, and I delete that folder's old files without quotes...
  14. NadiaThe delete command receives two paths instead of one, and deletes the wrong things. The rule is easy to remember: put double quotes around every variable, every time.
  15. ImranI'd have guessed that quotes also save you when the variable is empty.
  16. NadiaThat's the trap. An empty folder name plus slash star becomes slash star, which is every folder on the machine. Bash has a guard for that: a special form of the variable that stops the script with an error if the value is empty. Use it in every delete.
  17. ImranOkay. Now the stop rule. How does a script even know that something failed?
  18. NadiaEvery command ends with an exit code. Zero means success, anything else means failure. Grep, for example, returns zero when it finds a match, one when it finds nothing, and two when it can't read the file.
  19. ImranAnd by default Bash just carries on to the next line whatever the code was?
  20. NadiaIt does. So almost every script starts with set dash e u o pipefail. The e stops the script when a command fails. The u stops it when you use a variable that was never set, which catches typos. And pipefail is about pipelines.
  21. NadiaIf you can see your screen, the first diagram has two columns. If not, picture the same pipeline twice: grep looks for errors in a log file that doesn't exist, and its output goes into a line counter.
  22. ImranGrep fails because the file is missing. The counter gets nothing, prints zero, and succeeds.
  23. NadiaWithout pipefail, the pipeline reports the last command's code, so it reports success. Your script happily says zero errors today, about a log that isn't there. With pipefail, the failure in the middle shows up, and the script stops.
  24. ImranSo with those three settings I'm safe.
  25. NadiaMuch safer. But the stop rule has holes. It doesn't fire inside an if condition, or for commands chained with and or or, except the last one. That's on purpose, because there you're handling the result yourself.
  26. ImranAny holes that bite people in real life?
  27. NadiaTwo. Grep counting matches returns one when there are none, so the script stops on a normal night. If no match is fine, add or true. And a local variable filled from a command on the same line hides the command's failure. Declare first, fill it next.
  28. ImranThere's also the number comparison trap from the lesson. Nine versus eighty-five.
  29. NadiaRight. Inside double square brackets, the greater-than sign compares text, letter by letter. Nine sorts after eight, so a disk at nine percent would raise an alarm. For numbers, use double round brackets or the dash g t test.
  30. ImranNow the part I care most about. Look before you throw. How do you delete old files without deleting the wrong ones?
  31. NadiaThe tool is find. It walks a folder and checks each file against a list of tests, left to right. Only the files that pass every test reach the action at the end.
  32. NadiaThe second diagram shows this as a funnel. Three files go in: the live print log, a rotated log that's five days old, and a rotated log that's twenty days old.
  33. ImranLet me try. The name test only allows rotated logs, the ones with something after dot log. So the live file drops out first.
  34. NadiaGood. That matters, because the print service is writing that file right now.
  35. ImranThen the age test, older than fourteen days, drops the five-day file. Only the twenty-day file reaches the action.
  36. NadiaAnd the action depends on a flag. In a dry run, the script only prints the names. In a real run, it prints them and deletes them, so the journal keeps a list of what went.
  37. ImranWhy does the order of the tests and the action matter so much?
  38. NadiaBecause find really does run left to right. Put the delete before the name test, and it deletes every file before the name is ever checked. Delete always goes last, after the exact tests you already ran with print.
  39. ImranAnd the age test, older than fourteen days. Is that straightforward?
  40. NadiaIt has a surprise. Find counts age in whole days and drops the fraction. A file that's fourteen days and twenty hours old counts as fourteen, and fourteen isn't more than fourteen. So you need at least fifteen full days.
  41. ImranHere's my guess at another trap. If the disk is full, I can just delete the big live log file and get the space back.
  42. NadiaThat's the classic wrong guess. On Linux, if a program still has the file open, deleting it removes the name, but the space only comes back when the program closes it. The disk stays full until you restart the print service. That's another reason the script only touches rotated, closed logs.
  43. NadiaOne more word for this section: idempotent. It means running the job twice gives the same result as running it once. A retry or a second engineer running it by hand must not cause harm. Age-based rules help, and so does a lock, so two copies never run at the same time.
  44. ImranSo what happened at the store with the full disk?
  45. NadiaA printer firmware update made it report a harmless warning many times a second. An old rotation job compressed the log every night but never deleted anything, and in about three weeks the disk filled. The print service stopped, and so did the queue that keeps till sales safe when the internet drops.
  46. ImranAnd the fix was a script.
  47. NadiaYes, tested carefully. It ran in dry-run mode in Ama's store for a week, and the team read each morning's list. On the third morning the list included a log from the price cache. The name pattern was too wide, and they fixed it before a single real delete.
  48. ImranThat's look before you throw, in real life.
  49. NadiaNow scheduling. The third diagram is a stack: a timer at the top, a service under it, the script under that, and three boxes at the bottom for where the results go.
  50. ImranWhy not just use cron? It's been around forever.
  51. NadiaCron works, but it has sharp edges. Its search path is tiny, so some system programs aren't found. There's no terminal, and output you don't redirect is mailed or lost. Even the percent sign means new line in a cron entry.
  52. ImranAnd a systemd timer fixes those?
  53. NadiaMost of them. The script's output goes to the journal, so you can read last night's run the next morning. A failed run marks the unit as failed, so it shows up in the list of failed units. A persistent timer runs a missed job at boot, for stores that had a power cut overnight. And a random delay spreads the boxes out.
  54. ImranWhy does the spread matter?
  55. NadiaThe first version ran on every store box at exactly two o'clock and reported to one central alert service. About three hundred and twenty requests arrived in the same second, some were refused, and boxes with healthy disks showed failed jobs. A thirty-minute random delay fixed it.
  56. ImranSo walk me through the Build it section, in words.
  57. NadiaYou write the script: settings at the top, a fourteen-day keep rule, an eighty-five percent alert limit, a dry-run option, a function that reads the disk use as a number, and an alert function that posts to the TechOps alert service.
  58. NadiaThen the main part: take a lock, refuse to run if the log folder name is empty, measure the disk, clean old rotated logs, measure again, and if it's still over eighty-five percent, alert and exit with a failure.
  59. ImranAnd before it goes anywhere, you run ShellCheck on it, and a dry run on a real box.
  60. NadiaRight. ShellCheck reads the script without running it and flags missing quotes and the other traps. Then you install the script, write a service file and a timer file, enable the timer, run the service once by hand, and read its log in the journal.
  61. ImranFor my team, I'd ask three questions in a review. Show me the dry-run output from one real store box. What happens if it runs twice, or after a night the box was off? And where do we read its result tomorrow morning?
  62. NadiaGood list. I'd add one: is every path in a delete quoted and guarded against being empty?
  63. NadiaHere's one to think about. A cleanup uses find with dash m time plus seven. Today a log is seven days and twenty hours old. Is it deleted tonight? Take a second.
  64. ImranI'll say no, because find drops the twenty hours and seven isn't more than seven.
  65. NadiaCorrect. It goes tomorrow night, once it counts as eight days.
  66. ImranLet me sum up. One: quote every variable and guard deletes against empty names. Two: start with set dash e u o pipefail, but know where it doesn't stop you. Three: find with print first, delete last, and a dry-run flag.
  67. NadiaI'd add a nuance to your second point. A command like grep that returns one for a normal no will stop the script, so decide on purpose which failures are fine. And prefer systemd timers, with the output in the journal.
  68. ImranNext time?
  69. NadiaNext time we leave the store box and follow a till's network traffic all the way to the data centre, one hop at a time.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Utility script

A small script with its options at the top, input checks, a summary line and tests.

Open in the Code Lab
Next stopNetworks from the till to the data centre
Was this lesson helpful?
Questions and discussion

Loading the discussion…