CRE8LEARN INSTITUTE

C6-LIE-19 Linux Essentials
Section B Techniques Reference

The NALEDI system in shell scripting (40 marks)

Instructor: Teboho Mpholo (Mr.), Founder and Lead Instructor
CP2 is Monday 26 October: B1 to B4 working

How to use this page. Every example here uses a made-up library system (loans.dat, books, members), not NALEDI. Learn the technique, then build your own NALEDI modules with your own structure, names and wording. Section B is marked by running your system, and near-identical scripts between students are referred for collusion. The examples were tested with GNU bash, awk and date.

Module map and the four whole-section rules

bin/naledi                 entry point: loads config, parses getopts, shows the menu
lib/config.sh              naledi_load_config (from A3)
lib/audit.sh               audit, with_lock, cleanup
lib/validate.sh            valid_date, in_set, valid_nid, ...
lib/registry.sh            register, search, transfer
lib/permits.sh             issue, close, sweep
lib/compliance.sh          monthly run, defaulters
lib/report.sh              district report, CSV export
Rule in the paperWhat it means in code
ModularityOne entry point, functions grouped by concern. A single monolithic script is capped at 60% of Section B.
Safetyset -euo pipefail, every expansion quoted, mktemp plus a trap, every data write under flock.
AuditabilityEvery state-changing action appends one line to logs/audit.log: ISO time, OS user, action, target key, outcome.
IdempotencyRe-running a processed period is detected and refused with a clear message.

1. Safety header, lock, temp files and audit all of B

#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d)"
cleanup() { rm -rf "$TMP"; }
trap cleanup EXIT                                   # runs on every exit path
trap 'echo "interrupted" >&2; exit 130' INT TERM    # exit triggers the EXIT trap

with_lock() { ( flock -x 9; "$@" ) 9>"$ROOT/data/.lock"; }   # serialise data writes

audit() {   # audit ACTION TARGET OUTCOME [DETAIL]
  printf '%s|%s|%s|%s|%s|%s\n' "$(date -Is)" "$(id -un)" "$1" "$2" "$3" "${4:-}" >> "$ROOT/logs/audit.log"
}

2. Validation recipes B2, B4

A real calendar date, not in the future

valid_date() {   # dd-mm-yyyy
  local s=$1 iso
  [[ $s =~ ^([0-9]{2})-([0-9]{2})-([0-9]{4})$ ]] || return 1
  iso="${BASH_REMATCH[3]}-${BASH_REMATCH[2]}-${BASH_REMATCH[1]}"
  [ "$(date -d "$iso" +%F 2>/dev/null)" = "$iso" ] || return 1   # round trip rejects 31-02
  [[ "$iso" > "$(date +%F)" ]] && return 1                      # not in the future
  return 0
}

Tested: 31-02-2025 and 29-02-2027 are rejected, 29-02-2024 is accepted, 1-1-2020 is rejected by the format check. The round trip works because date either refuses an impossible date or rolls it over, and either way the output no longer equals the input. For B4, also compare against the date of birth: two ISO strings compare correctly with [[ "$a" < "$b" ]].

Closed sets and patterns

in_set() { local x=$1; shift; local i; for i in "$@"; do [ "$i" = "$x" ] && return 0; done; return 1; }
in_set "$sex" M F || echo "Sex must be M or F" >&2
[[ $surname =~ ^[A-Za-z][A-Za-z' -]*$ ]] || echo "Surname has invalid characters" >&2
cut -d'|' -f1 "$ROOT/data/districts.dat" | grep -qx "$district" || echo "Unknown district" >&2

Decide the national identity number format yourself and state it in the report. Validation never writes anything: finish every check first, then write once.

3. Sequential IDs that are never reused B2

next_seq() {   # call inside with_lock
  local f="$ROOT/data/.tag_seq" n
  n=$(( $(cat "$f" 2>/dev/null || echo 0) + 1 ))
  echo "$n" > "$f"
  printf '%s%05d\n' "$TAG_PREFIX" "$n"
}

Do not use "highest tag in the file plus one". When an animal moves to the Mortality Register, that method hands its tag out again. A counter that only goes up never does.

Defining a duplicate

is_duplicate() {   # key built from the fields YOU decide identify "the same item"
  local key=$1 file=$2
  awk -F'|' -v k="$key" '($2 "|" $5 "|" $6 "|" $7)==k {f=1} END{exit !f}' "$file"
}

The paper gives you the fields (owner NID, DOB, sex, breed, within the same village). Your report must say how you defined it and why, so explain what you chose not to match on, such as the tag, which is always new.

4. Search and age arithmetic B3, B5

# exact match on field 1
awk -F'|' -v k="$tag" '$1==k' "$file"

# partial, case-insensitive on field 2, aligned table, with a count and a not-found message
awk -F'|' -v q="$term" '
  BEGIN { printf "%-10s %-14s %s\n", "TAG", "SURNAME", "VILLAGE" }
  index(tolower($2), tolower(q)) { printf "%-10s %-14s %s\n", $1, $2, $3; n++ }
  END   { if (n) printf "%d record(s) found\n", n; else print "Nothing found" }' "$file"

Tested: searching mok returns both Mokoena and MOKGOSI. Use index() rather than a regex so a user typing a dot or bracket cannot break the search.

Age in whole months at a date you supply

age_months() {   # age_months BIRTH ASOF, both YYYY-MM-DD
  local by bm bd ay am ad
  IFS=- read -r by bm bd <<<"$1"; IFS=- read -r ay am ad <<<"$2"
  echo $(( (10#$ay-10#$by)*12 + 10#$am-10#$bm - (10#$ad < 10#$bd ? 1 : 0) ))
}
age_months 2024-08-25 2026-02-25    # 18
age_months 2024-08-26 2026-02-25    # 17

The 10# prefix stops bash reading 08 and 09 as bad octal numbers. Age is calculated from the date of birth each time and is never stored.

5. Atomic transfer between two files B4

move_record() {   # run under with_lock. Moves one line from SRC to DST
  local key=$1 src=$2 dst=$3 ns nd
  grep -q "^$key|" "$src" || { echo "Not found: $key" >&2; return 1; }
  ns=$(mktemp "$src.XXXXXX"); nd=$(mktemp "$dst.XXXXXX")        # same directory, so mv is a rename
  awk -F'|' -v k="$key" '$1!=k' "$src" > "$ns"                  # source without the record
  { cat "$dst"; grep "^$key|" "$src"; } > "$nd"                 # destination plus the record
  [ -z "${NALEDI_FAIL_TEST:-}" ] || { rm -f "$ns" "$nd"; echo "forced failure" >&2; return 1; }
  chmod --reference="$src" "$ns"; chmod --reference="$dst" "$nd" # mktemp makes files mode 600
  mv "$nd" "$dst" && mv "$ns" "$src"                           # destination first
}

6. Non-interactive mode and the menu B1

# Translate long options, then use getopts. The clerk menu and the scheduler call the SAME functions.
args=(); for a in "$@"; do
  case $a in --run-compliance) args+=(-r);; --sweep-permits) args+=(-s);; *) args+=("$a");; esac
done
set -- ${args[@]+"${args[@]}"}
while getopts ":sr:h" o; do case $o in
  s) with_lock sweep_permits; exit $? ;;
  r) with_lock run_compliance "$OPTARG"; exit $? ;;
  h) echo "usage: naledi [--sweep-permits] [--run-compliance YYYY-MM]"; exit 0 ;;
  :) echo "Option -$OPTARG needs a value" >&2; exit 2 ;;
  *) echo "Unknown option" >&2; exit 2 ;;
esac; done

while true; do
  clear; show_counts                                   # live counts on every redraw
  read -rp "Choice: " c || break                       # end of input must leave the loop
  case $c in
    1) if ! register_animal; then echo "Not saved. See message above."; fi ;;
    q|Q) break ;;
    *) echo "Invalid choice" ;;
  esac
  read -rp "Press Enter" _ || break
done

Trap: under set -e a failing function would drop the clerk out of the menu. Calling it inside if ! fn; then prevents that. A count such as active animals is one line of awk: awk -F'|' '$10=="ACTIVE"{n++} END{print n+0}' file (check the field number in Appendix A).

7. Permits and the expiry sweep B6

sweep_loans() {   # dd-mm-yyyy due date in field 4, status in field 5
  local today out; today=$(date +%F); out=$(mktemp "$ROOT/data/loans.dat.XXXXXX")
  awk -F'|' -v OFS='|' -v t="$today" '
    { split($4, d, "-"); iso = d[3] "-" d[2] "-" d[1] }
    $5=="OPEN" && iso < t { $5="EXPIRED"; print $1, $2 > "/dev/stderr" }
    { print }' "$ROOT/data/loans.dat" > "$out"
  chmod --reference="$ROOT/data/loans.dat" "$out"; mv "$out" "$ROOT/data/loans.dat"
}

Only OPEN rows are changed, so a second sweep on the same day finds nothing and changes nothing. Compare file hashes before and after the second run to prove it. For the expiry date, add the validity from config with date -d "$iso + $PERMIT_DAYS days" +%F, then write it back as dd-mm-yyyy with date -d "$iso" +%d-%m-%Y. Each refusal must say its own reason: not ACTIVE, already holds an OPEN permit, or which district is under QUARANTINE.

8. The monthly run B7

run_compliance() {   # run_compliance 2026-11
  local ym=$1 f="$ROOT/data/inspection_${1/-/}.dat" run_date="$1-25"
  [[ $ym =~ ^[0-9]{4}-(0[1-9]|1[0-2])$ ]] || { echo "Month must be YYYY-MM" >&2; return 2; }
  if [ -e "$f" ]; then echo "Month $ym was already processed. An officer must re-open it." >&2; return 3; fi
  # ... qualify animals using age_months "$dob_iso" "$run_date" -ge "$INSPECT_AGE_MONTHS" ...
}

months_in_default() {   # consecutive months, a first offence is 1
  local tag=$1 ym=$2 n=1 prev
  prev=$(date -d "$ym-01 -1 month" +%Y%m)
  while [ -f "$ROOT/data/defaulters_$prev.dat" ] && grep -q "^$tag|" "$ROOT/data/defaulters_$prev.dat"; do
    n=$((n+1)); prev=$(date -d "${prev:0:4}-${prev:4:2}-01 -1 month" +%Y%m)
  done
  echo "$n"
}
penalty=$(( PENALTY_BWP * $(months_in_default "$tag" "$ym") ))

reopen_month() {   # deliberate, confirmed and logged
  read -rp "Type REOPEN $1 to confirm: " c
  [ "$c" = "REOPEN $1" ] || { echo "Cancelled"; return 1; }
  audit REOPEN "$1" OK; mv "$ROOT/data/inspection_${1/-/}.dat" "$ROOT/data/inspection_${1/-/}.dat.reopened"
}

Quarantine on the 25th means checking EffectiveFrom and EffectiveTo against that date, not against today. Prevent a double inspection by checking the Inspected flag before you set it.

9. Report once, show it twice B8

build_report_data() {   # one pass, writes district|qualifying|inspected|defaulted|pct|penalty
  awk -F'|' -v OFS='|' '
    { q[$3]++; if ($6=="Y") i[$3]++ }
    END { for (d in q) { df=q[d]-i[d]; printf "%s|%d|%d|%d|%.1f\n", d, q[d], i[d]+0, df, 100*(i[d]+0)/q[d] } }
  ' "$1" | sort > "$TMP/report.psv"
}
# table view and CSV both read $TMP/report.psv, so nothing is computed a second time
column -t -s'|' "$TMP/report.psv"
{ echo "District,Qualifying,Inspected,Defaulted,Compliance,Penalty"; tr '|' ',' < "$TMP/report.psv"; } > "$out.csv"

The tested output looks like D1|2|1|50.0. Add the penalty total from the defaulters file and a totals row in awk. Round with %.1f, and guard against a district with zero qualifying animals before you divide.

What the marker will test

ItemLikely checks
B1 [7]Menu never crashes on junk input or Ctrl+C. Counts refresh. --sweep-permits and --run-compliance 2026-11 work from the same code. No temp files left behind.
B2 [5]31-02-2025 and 29-02-2027 rejected, 29-02-2024 accepted, future dates rejected. Tags sequential, never reused after a transfer. Duplicate refused with a reason.
B3 [6]All five searches. mok finds Mokoena and MOKGOSI. Empty result says so. Age range as at a supplied date. Table with a count.
B4 [5]Only a tag is typed. Details fetched, not retyped. Forced failure loses and duplicates nothing. Second death and a permit for a dead animal are refused.
B5 [3]Retrieve by tag, by cause, by date range, with counts per cause for the range.
B6 [5]Three refusals with specific reasons. Closing a permit updates the herd district. Two sweeps on one day give identical files.
B7 [6]Right animals qualify on the 25th. Handler NID captured. No double inspection. Penalty is unit times consecutive months. Re-run refused, re-open confirmed and logged.
B8 [3]awk report with totals row and one decimal. CSV exported without recomputing.

Write your own report notes as you build: your duplicate definition and why, the failure demonstration for B4, the locking and idempotency strategy, and every decision where you changed Appendix A (documented and justified, or markers test against it).