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
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 paper | What it means in code |
|---|---|
| Modularity | One entry point, functions grouped by concern. A single monolithic script is capped at 60% of Section B. |
| Safety | set -euo pipefail, every expansion quoted, mktemp plus a trap, every data write under flock. |
| Auditability | Every state-changing action appends one line to logs/audit.log: ISO time, OS user, action, target key, outcome. |
| Idempotency | Re-running a processed period is detected and refused with a clear message. |
#!/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"
}
date -Is gives ISO-8601 with the time zone.with_lock register_animal "$tag". Keep the audit call inside the locked function so the log and the data always agree.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" ]].
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.
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.
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.
# 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_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.
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
}
NALEDI_FAIL_TEST=1, then show wc -l and sha256sum of both files before and after. They must match.# 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).
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.
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.
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.
| Item | Likely 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).