Laporan di layar habis begitu layar bergulir — dan admin yang sedang makan siang tidak melihat apa pun. Dua kemampuan terakhir proyek ini menjawabnya: log memberi skrip ingatan lintas eksekusi, dan alert memberinya suara saat masalah bukan lagi kilatan sesaat. Bagian ini menambahkan keduanya ke skrip laporan dari Bagian 2: baris log terstruktur berstempel waktu ISO, hitungan berturut-turut di berkas state sebagai dasar keputusan alert, dan rotasi log sederhana agar sang pengawas disk ini tidak diam-diam mengisi disk yang ia pantau. Bagian ini ditutup dengan test suite yang membuktikan seluruh janji desain pada fixture beku.

Log Terstruktur Berstempel ISO #

Setiap metrik, sekali per eksekusi, menambah satu baris ke berkas log dengan format tetap: stempel waktu, status, nama metrik, nilai. Formatnya berupa YYYY-MM-DDTHH:MM:SS±ZZ:ZZ — ISO 8601 — sehingga baris log urut secara kronologis dengan sort biasa dan mudah disaring grep per jam maupun per hari. Fungsi penulisnya kecil sekali:

bash
STAMP="2026-01-15T09:00:00+07:00"
catat() {
    printf '%s %-7s %-11s %s\n' "$STAMP" "$1" "$2" "$3" >> monitor.log
}
catat WASPADA cpu-load 2.50
catat OK disk:/ 48%
catat ALERT memori 84%
cat monitor.log

Output yang Dihasilkan:

text
2026-01-15T09:00:00+07:00 WASPADA cpu-load    2.50
2026-01-15T09:00:00+07:00 OK      disk:/      48%
2026-01-15T09:00:00+07:00 ALERT   memori      84%

Dua keputusan tersembunyi di balik tampilannya. Pertama, stempel waktu diambil dari variabel STAMP, bukan langsung dari date — di skrip final nilainya $(date ...) secara bawaan, tetapi bisa dikunci lewat variabel lingkungan MONITOR_STAMP; itulah sebabnya demo di atas bisa menulis “2026” dengan tenang dan test suite bisa membandingkan isi log persis. Kedua, log ditulis dengan >> (append), bukan > — menimpa, dan sejarah hilang bersama bukti kapan masalah mulai. Status ALERT sengaja menjadi jenis baris keempat di log, terpisah dari status metrik, supaya grep ALERT langsung memberi daftar insiden.

Alert dengan Berkas State #

Alert per-lencana adalah resep spam: backup malam bisa membuat load melonjak lima menit setiap hari, dan alert harian yang sama mengajari penerima untuk mengabaikannya. Skrip kita karena itu hanya bersuara setelah satu metrik melewati ambang beberapa kali berturut-turut, dan hitungannya disimpan di berkas state — kecil, dua kolom, satu baris per metrik yang sedang bermasalah:

bash
mkdir -p latihan
printf 'cpu-load 2\nmemori 2\n' > latihan/monitor.state
awk -v k=cpu-load '$1 == k { print $2; exit }' latihan/monitor.state
awk -v k=cpu-load '$1 != k' latihan/monitor.state > latihan/monitor.state.tmp
printf 'cpu-load 3\n' >> latihan/monitor.state.tmp
mv latihan/monitor.state.tmp latihan/monitor.state
cat latihan/monitor.state

Output yang Dihasilkan:

text
2
memori 2
cpu-load 3

Blok di atas adalah inti mekanismenya dalam empat langkah: baca hitungan lama, salin semua baris kecuali metrik yang sedang diperbarui ke berkas sementara, tambahkan baris baru dengan hitungan naik satu, lalu ganti berkas state dengan mv. Pola tulis-ke-sementara-lalu-ganti membuat berkas state tidak pernah terlihat setengah tertulis — bila skrip terputus di tengah, berkas lama yang utuh tetap terpasang. Hitungan naik setiap kali status bukan OK, tidak dibedakan waspada atau kritis; saat metrik kembali OK, barisnya cukup tidak ditulis ulang sehingga hitungannya kembali nol. Alert terkirim tepat saat hitungan sama dengan ALERT_REPEAT — sekali per episode, bukan setiap eksekusi berikutnya.

Skrip Final: Log, Alert, dan Rotasi #

Skrip final menyatukan semuanya dan menambah dua bagian terakhir: rotasi log berukuran-byte sebelum menulis, dan fungsi periksa yang kini mencatat log serta mengelola hitungan alert. Blok berikut menulis versi final — identik dengan src/monitor.sh — lalu menjalankan satu episode lengkap pada fixture: dua run sunyi, run ketiga yang bersuara, pemulihan yang mereset state, dan rotasi:

bash
cat > monitor.conf <<'CONF'
LOAD_WARN="1.50"
LOAD_KRITIS="3.00"
MEM_WARN_PCT=80
MEM_KRITIS_PCT=90
DISK_WARN_PCT=80
DISK_KRITIS_PCT=90
DISK_MOUNTS="/ /home"
LOG_FILE="monitor.log"
LOG_MAX_BYTES=1048576
LOG_KEEP=3
ALERT_REPEAT=3
CONF

mkdir -p fixture sehat
printf '2.50 2.10 1.60 4/812 34567\n' > fixture/loadavg
cat > fixture/meminfo <<'EOF'
MemTotal:        8000000 kB
MemFree:          950000 kB
MemAvailable:    1280000 kB
Buffers:          210000 kB
Cached:           700000 kB
SwapCached:            0 kB
SwapTotal:       2000000 kB
SwapFree:        2000000 kB
EOF
cat > fixture/df.txt <<'EOF'
Filesystem     1024-blocks      Used Available Capacity Mounted on
/dev/sda1         82043440  36919548  40942056      48% /
tmpfs              4042268         0   4042268       0% /dev/shm
/dev/sda2        412849860 348092000  43741860      89% /home
EOF
sed 's/1280000/4640000/' fixture/meminfo > sehat/meminfo
sed 's| 89% /home| 51% /home|' fixture/df.txt > sehat/df.txt
printf '0.40 0.35 0.30 1/512 21001\n' > sehat/loadavg

cat > monitor.sh <<'BASH'
#!/usr/bin/env bash
# monitor.sh — pemantau resource sederhana untuk admin kecil.
#
# Setiap eksekusi mengumpulkan tiga metrik — load average 1 menit,
# persentase memori terpakai, dan persentase disk terpakai per mount
# point dari konfigurasi — membandingkannya dengan ambang waspada dan
# kritis dari berkas konfigurasi, mencetak laporan berbentuk tabel,
# menulis log terstruktur, dan mengirim alert ke stderr setelah satu
# metrik melewati ambang beberapa kali berturut-turut. Berjalan sebagai
# user biasa — tidak butuh root.
#
# Pemakaian:
#   bash monitor.sh [--data-dir DIR]
#
#   --data-dir DIR  baca loadavg, meminfo, dan df.txt dari DIR alih-
#                   alih dari /proc dan df sungguhan (untuk pengujian).
#
# Variabel lingkungan (berguna untuk pengujian):
#   MONITOR_CONF    berkas konfigurasi yang dibaca (default: monitor.conf)
#   MONITOR_SOURCE  sama dengan --data-dir (opsi menang bila keduanya diisi)
#   MONITOR_STAMP   stempel waktu ISO untuk log (default: waktu sekarang)
#
# Kode keluar:
#   0 semua metrik OK        1 ada metrik WASPADA
#   2 ada metrik KRITIS      3 konfigurasi atau sumber data tidak valid

set -u

CONF="${MONITOR_CONF:-monitor.conf}"
STAMP="${MONITOR_STAMP:-$(date +%Y-%m-%dT%H:%M:%S%:z)}"
data_dir="${MONITOR_SOURCE:-}"

usage() {
    printf 'Pemakaian: bash monitor.sh [--data-dir DIR]\n'
    printf '\n'
    printf 'Memeriksa load average, memori, dan disk menurut ambang di\n'
    printf 'monitor.conf, mencetak laporan, menulis log, dan mengirim\n'
    printf 'alert setelah satu metrik melewati ambang beberapa kali\n'
    printf 'berturut-turut.\n'
    printf '\n'
    printf '  --data-dir DIR  baca data tiruan dari DIR (untuk pengujian)\n'
    printf '  -h, --help      tampilkan bantuan ini\n'
    printf '\n'
    printf 'Kode keluar: 0 semua OK, 1 ada WASPADA, 2 ada KRITIS,\n'
    printf '3 konfigurasi atau sumber data tidak valid.\n'
}

while [ $# -gt 0 ]; do
    case "$1" in
        --data-dir)
            if [ $# -lt 2 ]; then
                printf 'monitor.sh: --data-dir membutuhkan argumen DIR\n' >&2
                exit 3
            fi
            data_dir=$2
            shift 2
            ;;
        --data-dir=*)
            data_dir=${1#--data-dir=}
            shift
            ;;
        -h|--help)
            usage
            exit 0
            ;;
        *)
            printf 'monitor.sh: argumen tidak dikenal: %s\n' "$1" >&2
            exit 3
            ;;
    esac
done

# angka_desimal V: sukses bila V bilangan desimal sederhana seperti
# "1.50" atau "3" — tanpa tanda, tanpa titik ganda, tanpa titik di ujung.
angka_desimal() {
    case "$1" in
        ''|*[!0-9.]*) return 1 ;;
        *.*.*)        return 1 ;;
        .*|*.|*.)     return 1 ;;
    esac
    return 0
}

# angka_persen V: sukses bila V bilangan bulat 1 sampai 99.
angka_persen() {
    case "$1" in
        ''|*[!0-9]*) return 1 ;;
    esac
    [ "$1" -ge 1 ] && [ "$1" -le 99 ]
}

# angka_bulat V: sukses bila V bilangan bulat 1 atau lebih.
angka_bulat() {
    case "$1" in
        ''|*[!0-9]*|0) return 1 ;;
    esac
    return 0
}

# --- baca dan validasi konfigurasi ---------------------------------------
if [ ! -f "$CONF" ]; then
    printf 'monitor.sh: berkas konfigurasi %s tidak ditemukan\n' "$CONF" >&2
    exit 3
fi
# shellcheck source=/dev/null
. "$CONF"

gagal_konfig() {
    printf 'monitor.sh: %s (periksa %s)\n' "$1" "$CONF" >&2
    exit 3
}

angka_desimal "${LOAD_WARN:-}"     || gagal_konfig 'LOAD_WARN harus angka desimal, mis. 1.50'
angka_desimal "${LOAD_KRITIS:-}"   || gagal_konfig 'LOAD_KRITIS harus angka desimal, mis. 3.00'
angka_persen  "${MEM_WARN_PCT:-}"    || gagal_konfig 'MEM_WARN_PCT harus bilangan bulat 1-99'
angka_persen  "${MEM_KRITIS_PCT:-}"  || gagal_konfig 'MEM_KRITIS_PCT harus bilangan bulat 1-99'
angka_persen  "${DISK_WARN_PCT:-}"   || gagal_konfig 'DISK_WARN_PCT harus bilangan bulat 1-99'
angka_persen  "${DISK_KRITIS_PCT:-}" || gagal_konfig 'DISK_KRITIS_PCT harus bilangan bulat 1-99'
angka_bulat   "${ALERT_REPEAT:-}"    || gagal_konfig 'ALERT_REPEAT harus bilangan bulat 1 atau lebih'
angka_bulat   "${LOG_KEEP:-}"        || gagal_konfig 'LOG_KEEP harus bilangan bulat 1 atau lebih'
angka_bulat   "${LOG_MAX_BYTES:-}"   || gagal_konfig 'LOG_MAX_BYTES harus bilangan bulat 1 atau lebih'
[ -n "${DISK_MOUNTS:-}" ] || gagal_konfig 'DISK_MOUNTS belum diisi'
[ -n "${LOG_FILE:-}" ]   || gagal_konfig 'LOG_FILE belum diisi'

if ! awk -v a="$LOAD_WARN" -v b="$LOAD_KRITIS" 'BEGIN { exit (a < b) ? 0 : 1 }'; then
    gagal_konfig 'LOAD_WARN harus lebih kecil dari LOAD_KRITIS'
fi
[ "${MEM_WARN_PCT}" -lt "${MEM_KRITIS_PCT}" ] ||
    gagal_konfig 'MEM_WARN_PCT harus lebih kecil dari MEM_KRITIS_PCT'
[ "${DISK_WARN_PCT}" -lt "${DISK_KRITIS_PCT}" ] ||
    gagal_konfig 'DISK_WARN_PCT harus lebih kecil dari DISK_KRITIS_PCT'

# --- sumber data: /proc sungguhan atau fixture pengujian ------------------
if [ -n "$data_dir" ]; then
    loadavg_file="$data_dir/loadavg"
    meminfo_file="$data_dir/meminfo"
    df_file="$data_dir/df.txt"
    for f in "$loadavg_file" "$meminfo_file" "$df_file"; do
        if [ ! -r "$f" ]; then
            printf 'monitor.sh: sumber data %s tidak terbaca\n' "$f" >&2
            exit 3
        fi
    done
    df_output=$(cat "$df_file")
else
    loadavg_file=/proc/loadavg
    meminfo_file=/proc/meminfo
    df_output=$(df -P)
fi

# --- siapkan berkas log, state, dan rotasi --------------------------------
LOG_DIR=$(dirname "$LOG_FILE")
mkdir -p "$LOG_DIR" || exit 3
STATE_FILE="$LOG_DIR/monitor.state"
STATE_TMP="$STATE_FILE.tmp"

if [ -f "$LOG_FILE" ]; then
    ukuran=$(stat -c %s "$LOG_FILE")
    if [ "$ukuran" -gt "$LOG_MAX_BYTES" ]; then
        i=$LOG_KEEP
        while [ "$i" -gt 1 ]; do
            [ -f "$LOG_FILE.$((i - 1))" ] &&
                mv "$LOG_FILE.$((i - 1))" "$LOG_FILE.$i"
            i=$((i - 1))
        done
        mv "$LOG_FILE" "$LOG_FILE.1"
    fi
fi

# --- fungsi pengumpul data ------------------------------------------------

# ambil_load100: load average 1 menit dikali 100 (2.50 menjadi 250).
ambil_load100() {
    awk '{ printf "%.0f", $1 * 100 }' "$loadavg_file"
}

# ke_ratus ANGKA: kalikan angka desimal dengan 100 menjadi bilangan bulat.
ke_ratus() {
    awk -v v="$1" 'BEGIN { printf "%.0f", v * 100 }'
}

# ambil_mem100: persen memori terpakai. MemAvailable dipakai karena sudah
# memperhitungkan cache yang bisa direklamasi — bukan sekadar MemFree.
ambil_mem100() {
    awk '
        $1 == "MemTotal:"     { total = $2 }
        $1 == "MemAvailable:" { avail = $2; ada = 1 }
        END { if (total > 0 && ada) printf "%.0f", (total - avail) * 100 / total }
    ' "$meminfo_file"
}

# ambil_disk100 MOUNT: persen terpakai untuk MOUNT dari output df.
ambil_disk100() {
    printf '%s\n' "$df_output" |
        awk -v mnt="$1" '
            $6 == mnt { gsub(/%/, "", $5); print $5 + 0; exit }
        '
}

# --- fungsi status ---------------------------------------------------------

# kode_status NILAI WASPADA KRITIS: 0=OK, 1=WASPADA, 2=KRITIS.
# Nilai dan kedua ambang harus bilangan bulat.
kode_status() {
    if [ "$1" -ge "$3" ]; then
        printf '2'
    elif [ "$1" -ge "$2" ]; then
        printf '1'
    else
        printf '0'
    fi
}

# nama_status KODE: bentuk teks dari kode status.
nama_status() {
    case "$1" in
        1) printf 'WASPADA' ;;
        2) printf 'KRITIS' ;;
        *) printf 'OK' ;;
    esac
}

# --- fungsi log, state, dan alert ------------------------------------------

# catat STATUS METRIK NILAI: tambah satu baris log terstruktur.
catat() {
    printf '%s %-7s %-11s %s\n' "$STAMP" "$1" "$2" "$3" >> "$LOG_FILE"
}

# baca_hitung KUNCI: berapa kali berturut-turut KUNCI melewati ambang.
baca_hitung() {
    [ -f "$STATE_FILE" ] || return 0
    awk -v k="$1" '$1 == k { print $2; exit }' "$STATE_FILE"
}

# warna_status STATUS: teks status, diberi warna ANSI hanya bila stdout
# adalah terminal — output yang dialihkan ke berkas tetap polos.
warna_status() {
    if [ -t 1 ]; then
        case "$1" in
            WASPADA) printf '\033[33m%s\033[0m' "$1" ;;
            KRITIS)  printf '\033[31m%s\033[0m' "$1" ;;
            *)       printf '%s' "$1" ;;
        esac
    else
        printf '%s' "$1"
    fi
}

# --- kumpulkan data --------------------------------------------------------

load_tampil=$(awk '{ print $1 }' "$loadavg_file")
load100=$(ambil_load100)
if [ -z "$load100" ]; then
    printf 'monitor.sh: %s tidak berisi load average\n' "$loadavg_file" >&2
    exit 3
fi

mem100=$(ambil_mem100)
if [ -z "$mem100" ]; then
    printf 'monitor.sh: %s tidak berisi MemTotal/MemAvailable\n' "$meminfo_file" >&2
    exit 3
fi

load_warn100=$(ke_ratus "$LOAD_WARN")
load_kritis100=$(ke_ratus "$LOAD_KRITIS")

# Mount yang tidak ada di data df ditolak di sini, sebelum laporan
# dicetak, supaya tabel tidak tercetak setengah jalan.
# shellcheck disable=SC2086  # daftar mount memang dipisah spasi
for mnt in $DISK_MOUNTS; do
    if [ -z "$(ambil_disk100 "$mnt")" ]; then
        printf 'monitor.sh: mount %s tidak ditemukan di data df (periksa DISK_MOUNTS)\n' "$mnt" >&2
        exit 3
    fi
done

# --- periksa tiap metrik: laporan + log + alert ----------------------------

terburuk=0
: > "$STATE_TMP"

# periksa METRIK TAMPIL NILAI WASPADA KRITIS LABEL_AMBANG
periksa() {
    local metrik=$1 tampil=$2 nilai=$3 warn=$4 kritis=$5 label=$6
    local kode status n

    kode=$(kode_status "$nilai" "$warn" "$kritis")
    status=$(nama_status "$kode")
    if (( kode > terburuk )); then
        terburuk=$kode
    fi

    printf '%-10s %7s %-12s %s\n' "$metrik" "$tampil" "$label" \
        "$(warna_status "$status")"
    catat "$status" "$metrik" "$tampil"

    n=$(baca_hitung "$metrik")
    n=${n:-0}
    if [ "$status" != "OK" ]; then
        n=$((n + 1))
        printf '%s %s\n' "$metrik" "$n" >> "$STATE_TMP"
        if [ "$n" -eq "$ALERT_REPEAT" ]; then
            printf 'monitor.sh: ALERT %s = %s melewati ambang %sx berturut-turut\n' \
                "$metrik" "$tampil" "$ALERT_REPEAT" >&2
            catat ALERT "$metrik" "$tampil"
        fi
    fi
}

printf '%-10s %7s %-12s %s\n' 'Metrik' 'Nilai' 'Ambang W/K' 'Status'
printf '%.0s-' {1..39}; printf '\n'

periksa cpu-load "$load_tampil" "$load100" "$load_warn100" "$load_kritis100" \
    "$LOAD_WARN/$LOAD_KRITIS"
periksa memori "${mem100}%" "$mem100" "$MEM_WARN_PCT" "$MEM_KRITIS_PCT" \
    "${MEM_WARN_PCT}%/${MEM_KRITIS_PCT}%"

# shellcheck disable=SC2086  # daftar mount memang dipisah spasi
for mnt in $DISK_MOUNTS; do
    disk100=$(ambil_disk100 "$mnt")
    periksa "disk:$mnt" "${disk100}%" "$disk100" \
        "$DISK_WARN_PCT" "$DISK_KRITIS_PCT" \
        "${DISK_WARN_PCT}%/${DISK_KRITIS_PCT}%"
done

printf 'kesimpulan: %s\n' "$(nama_status "$terburuk")"

mv "$STATE_TMP" "$STATE_FILE"
exit "$terburuk"
BASH

echo '== run 1: waspada pertama, belum alert =='
MONITOR_STAMP=2026-01-15T09:00:00+07:00 bash monitor.sh --data-dir fixture
echo "exit=$?"
echo '== run 2: masih diam =='
MONITOR_STAMP=2026-01-15T09:05:00+07:00 bash monitor.sh --data-dir fixture > /dev/null
echo "exit=$?"
echo '== state setelah dua run =='
cat monitor.state
echo '== run 3: hitungan ketiga, alert terkirim =='
MONITOR_STAMP=2026-01-15T09:10:00+07:00 bash monitor.sh --data-dir fixture 2>&1
echo "exit=$?"
echo '== state setelah run 3 =='
cat monitor.state
echo '== baris ALERT di log =='
grep ALERT monitor.log
echo '== sistem pulih: state direset =='
MONITOR_STAMP=2026-01-15T09:15:00+07:00 bash monitor.sh --data-dir sehat
echo "exit=$?"
cat monitor.state
echo '== rotasi: batas ukuran dikecilkan =='
sed 's/LOG_MAX_BYTES=1048576/LOG_MAX_BYTES=1/' monitor.conf > kecil.conf
MONITOR_CONF=kecil.conf MONITOR_STAMP=2026-01-15T09:20:00+07:00 \
    bash monitor.sh --data-dir sehat > /dev/null
ls -1 monitor.log*
wc -l < monitor.log

Output yang Dihasilkan:

text
== run 1: waspada pertama, belum alert ==
Metrik       Nilai Ambang W/K   Status
---------------------------------------
cpu-load      2.50 1.50/3.00    WASPADA
memori         84% 80%/90%      WASPADA
disk:/         48% 80%/90%      OK
disk:/home     89% 80%/90%      WASPADA
kesimpulan: WASPADA
exit=1
== run 2: masih diam ==
exit=1
== state setelah dua run ==
cpu-load 2
memori 2
disk:/home 2
== run 3: hitungan ketiga, alert terkirim ==
Metrik       Nilai Ambang W/K   Status
---------------------------------------
cpu-load      2.50 1.50/3.00    WASPADA
monitor.sh: ALERT cpu-load = 2.50 melewati ambang 3x berturut-turut
memori         84% 80%/90%      WASPADA
monitor.sh: ALERT memori = 84% melewati ambang 3x berturut-turut
disk:/         48% 80%/90%      OK
disk:/home     89% 80%/90%      WASPADA
monitor.sh: ALERT disk:/home = 89% melewati ambang 3x berturut-turut
kesimpulan: WASPADA
exit=1
== state setelah run 3 ==
cpu-load 3
memori 3
disk:/home 3
== baris ALERT di log ==
2026-01-15T09:10:00+07:00 ALERT   cpu-load    2.50
2026-01-15T09:10:00+07:00 ALERT   memori      84%
2026-01-15T09:10:00+07:00 ALERT   disk:/home  89%
== sistem pulih: state direset ==
Metrik       Nilai Ambang W/K   Status
---------------------------------------
cpu-load      0.40 1.50/3.00    OK
memori         42% 80%/90%      OK
disk:/         48% 80%/90%      OK
disk:/home     51% 80%/90%      OK
kesimpulan: OK
exit=0
== rotasi: batas ukuran dikecilkan ==
monitor.log
monitor.log.1
4

Bagian-bagian baru yang ditambahkan pada tahap ini:

  1. catat() dan STAMP: setiap baris laporan kini juga menulis baris log dengan stempel dari STAMP — variabel yang bawaannya waktu sekarang tetapi bisa dikunci lewat MONITOR_STAMP, seperti terlihat pada seluruh run di atas yang stempelnya kita tentukan sendiri.
  2. baca_hitung() dan STATE_TMP: fungsi periksa membaca hitungan lama, menaikannya bila status bukan OK, dan menuliskannya ke berkas sementara yang di akhir skrip menggantikan berkas state lewat mv — pembaca berikutnya tidak pernah melihat keadaan setengah jadi.
  3. if [ "$n" -eq "$ALERT_REPEAT" ]: alert hanya terkirim pada hitungan yang tepat — perhatikan run keempat pada test suite nanti yang membuktikan tidak ada alert baru meski masalah berlanjut. Alert keluar lewat stderr (>&2) sehingga laporan di stdout tetap bersih untuk dibaca manusia; baris ALERT juga dicatat ke log sebagai jejak insiden.
  4. Rotasi berbasis ukuran: sebelum menulis, skrip memeriksa stat -c %s berkas log; bila melebihi LOG_MAX_BYTES, berkas .2 digeser ke .3 (secara implisit menghapus yang paling tua), .1 ke .2, berkas aktif ke .1, lalu run menulis ke berkas baru. Di sesi di atas, LOG_MAX_BYTES=1 memaksa rotasi pada eksekusi berikutnya dan monitor.log yang aktif kini hanya berisi empat baris terbaru.

Test Suite dengan Assertion #

Sama seperti proyek backup, bukti terakhir proyek ini adalah test suite yang menjalankan seluruh janji desain secara otomatis. Suite menyiapkan folder kerja terisolasi mktemp -d, menyalin monitor.sh dan monitor.conf, membuat tiga fixture dari satu berkas dasar, lalu menguji: parsing nilai, status per tingkat, exit code agregat, isi log, mekanisme state dan alert, rotasi, serta penolakan konfigurasi dan sumber yang tidak valid:

bash
mkdir -p proyek/src proyek/config proyek/tests
cat > proyek/src/monitor.sh <<'BASH'
#!/usr/bin/env bash
# monitor.sh — pemantau resource sederhana untuk admin kecil.
#
# Setiap eksekusi mengumpulkan tiga metrik — load average 1 menit,
# persentase memori terpakai, dan persentase disk terpakai per mount
# point dari konfigurasi — membandingkannya dengan ambang waspada dan
# kritis dari berkas konfigurasi, mencetak laporan berbentuk tabel,
# menulis log terstruktur, dan mengirim alert ke stderr setelah satu
# metrik melewati ambang beberapa kali berturut-turut. Berjalan sebagai
# user biasa — tidak butuh root.
#
# Pemakaian:
#   bash monitor.sh [--data-dir DIR]
#
#   --data-dir DIR  baca loadavg, meminfo, dan df.txt dari DIR alih-
#                   alih dari /proc dan df sungguhan (untuk pengujian).
#
# Variabel lingkungan (berguna untuk pengujian):
#   MONITOR_CONF    berkas konfigurasi yang dibaca (default: monitor.conf)
#   MONITOR_SOURCE  sama dengan --data-dir (opsi menang bila keduanya diisi)
#   MONITOR_STAMP   stempel waktu ISO untuk log (default: waktu sekarang)
#
# Kode keluar:
#   0 semua metrik OK        1 ada metrik WASPADA
#   2 ada metrik KRITIS      3 konfigurasi atau sumber data tidak valid

set -u

CONF="${MONITOR_CONF:-monitor.conf}"
STAMP="${MONITOR_STAMP:-$(date +%Y-%m-%dT%H:%M:%S%:z)}"
data_dir="${MONITOR_SOURCE:-}"

usage() {
    printf 'Pemakaian: bash monitor.sh [--data-dir DIR]\n'
    printf '\n'
    printf 'Memeriksa load average, memori, dan disk menurut ambang di\n'
    printf 'monitor.conf, mencetak laporan, menulis log, dan mengirim\n'
    printf 'alert setelah satu metrik melewati ambang beberapa kali\n'
    printf 'berturut-turut.\n'
    printf '\n'
    printf '  --data-dir DIR  baca data tiruan dari DIR (untuk pengujian)\n'
    printf '  -h, --help      tampilkan bantuan ini\n'
    printf '\n'
    printf 'Kode keluar: 0 semua OK, 1 ada WASPADA, 2 ada KRITIS,\n'
    printf '3 konfigurasi atau sumber data tidak valid.\n'
}

while [ $# -gt 0 ]; do
    case "$1" in
        --data-dir)
            if [ $# -lt 2 ]; then
                printf 'monitor.sh: --data-dir membutuhkan argumen DIR\n' >&2
                exit 3
            fi
            data_dir=$2
            shift 2
            ;;
        --data-dir=*)
            data_dir=${1#--data-dir=}
            shift
            ;;
        -h|--help)
            usage
            exit 0
            ;;
        *)
            printf 'monitor.sh: argumen tidak dikenal: %s\n' "$1" >&2
            exit 3
            ;;
    esac
done

# angka_desimal V: sukses bila V bilangan desimal sederhana seperti
# "1.50" atau "3" — tanpa tanda, tanpa titik ganda, tanpa titik di ujung.
angka_desimal() {
    case "$1" in
        ''|*[!0-9.]*) return 1 ;;
        *.*.*)        return 1 ;;
        .*|*.|*.)     return 1 ;;
    esac
    return 0
}

# angka_persen V: sukses bila V bilangan bulat 1 sampai 99.
angka_persen() {
    case "$1" in
        ''|*[!0-9]*) return 1 ;;
    esac
    [ "$1" -ge 1 ] && [ "$1" -le 99 ]
}

# angka_bulat V: sukses bila V bilangan bulat 1 atau lebih.
angka_bulat() {
    case "$1" in
        ''|*[!0-9]*|0) return 1 ;;
    esac
    return 0
}

# --- baca dan validasi konfigurasi ---------------------------------------
if [ ! -f "$CONF" ]; then
    printf 'monitor.sh: berkas konfigurasi %s tidak ditemukan\n' "$CONF" >&2
    exit 3
fi
# shellcheck source=/dev/null
. "$CONF"

gagal_konfig() {
    printf 'monitor.sh: %s (periksa %s)\n' "$1" "$CONF" >&2
    exit 3
}

angka_desimal "${LOAD_WARN:-}"     || gagal_konfig 'LOAD_WARN harus angka desimal, mis. 1.50'
angka_desimal "${LOAD_KRITIS:-}"   || gagal_konfig 'LOAD_KRITIS harus angka desimal, mis. 3.00'
angka_persen  "${MEM_WARN_PCT:-}"    || gagal_konfig 'MEM_WARN_PCT harus bilangan bulat 1-99'
angka_persen  "${MEM_KRITIS_PCT:-}"  || gagal_konfig 'MEM_KRITIS_PCT harus bilangan bulat 1-99'
angka_persen  "${DISK_WARN_PCT:-}"   || gagal_konfig 'DISK_WARN_PCT harus bilangan bulat 1-99'
angka_persen  "${DISK_KRITIS_PCT:-}" || gagal_konfig 'DISK_KRITIS_PCT harus bilangan bulat 1-99'
angka_bulat   "${ALERT_REPEAT:-}"    || gagal_konfig 'ALERT_REPEAT harus bilangan bulat 1 atau lebih'
angka_bulat   "${LOG_KEEP:-}"        || gagal_konfig 'LOG_KEEP harus bilangan bulat 1 atau lebih'
angka_bulat   "${LOG_MAX_BYTES:-}"   || gagal_konfig 'LOG_MAX_BYTES harus bilangan bulat 1 atau lebih'
[ -n "${DISK_MOUNTS:-}" ] || gagal_konfig 'DISK_MOUNTS belum diisi'
[ -n "${LOG_FILE:-}" ]   || gagal_konfig 'LOG_FILE belum diisi'

if ! awk -v a="$LOAD_WARN" -v b="$LOAD_KRITIS" 'BEGIN { exit (a < b) ? 0 : 1 }'; then
    gagal_konfig 'LOAD_WARN harus lebih kecil dari LOAD_KRITIS'
fi
[ "${MEM_WARN_PCT}" -lt "${MEM_KRITIS_PCT}" ] ||
    gagal_konfig 'MEM_WARN_PCT harus lebih kecil dari MEM_KRITIS_PCT'
[ "${DISK_WARN_PCT}" -lt "${DISK_KRITIS_PCT}" ] ||
    gagal_konfig 'DISK_WARN_PCT harus lebih kecil dari DISK_KRITIS_PCT'

# --- sumber data: /proc sungguhan atau fixture pengujian ------------------
if [ -n "$data_dir" ]; then
    loadavg_file="$data_dir/loadavg"
    meminfo_file="$data_dir/meminfo"
    df_file="$data_dir/df.txt"
    for f in "$loadavg_file" "$meminfo_file" "$df_file"; do
        if [ ! -r "$f" ]; then
            printf 'monitor.sh: sumber data %s tidak terbaca\n' "$f" >&2
            exit 3
        fi
    done
    df_output=$(cat "$df_file")
else
    loadavg_file=/proc/loadavg
    meminfo_file=/proc/meminfo
    df_output=$(df -P)
fi

# --- siapkan berkas log, state, dan rotasi --------------------------------
LOG_DIR=$(dirname "$LOG_FILE")
mkdir -p "$LOG_DIR" || exit 3
STATE_FILE="$LOG_DIR/monitor.state"
STATE_TMP="$STATE_FILE.tmp"

if [ -f "$LOG_FILE" ]; then
    ukuran=$(stat -c %s "$LOG_FILE")
    if [ "$ukuran" -gt "$LOG_MAX_BYTES" ]; then
        i=$LOG_KEEP
        while [ "$i" -gt 1 ]; do
            [ -f "$LOG_FILE.$((i - 1))" ] &&
                mv "$LOG_FILE.$((i - 1))" "$LOG_FILE.$i"
            i=$((i - 1))
        done
        mv "$LOG_FILE" "$LOG_FILE.1"
    fi
fi

# --- fungsi pengumpul data ------------------------------------------------

# ambil_load100: load average 1 menit dikali 100 (2.50 menjadi 250).
ambil_load100() {
    awk '{ printf "%.0f", $1 * 100 }' "$loadavg_file"
}

# ke_ratus ANGKA: kalikan angka desimal dengan 100 menjadi bilangan bulat.
ke_ratus() {
    awk -v v="$1" 'BEGIN { printf "%.0f", v * 100 }'
}

# ambil_mem100: persen memori terpakai. MemAvailable dipakai karena sudah
# memperhitungkan cache yang bisa direklamasi — bukan sekadar MemFree.
ambil_mem100() {
    awk '
        $1 == "MemTotal:"     { total = $2 }
        $1 == "MemAvailable:" { avail = $2; ada = 1 }
        END { if (total > 0 && ada) printf "%.0f", (total - avail) * 100 / total }
    ' "$meminfo_file"
}

# ambil_disk100 MOUNT: persen terpakai untuk MOUNT dari output df.
ambil_disk100() {
    printf '%s\n' "$df_output" |
        awk -v mnt="$1" '
            $6 == mnt { gsub(/%/, "", $5); print $5 + 0; exit }
        '
}

# --- fungsi status ---------------------------------------------------------

# kode_status NILAI WASPADA KRITIS: 0=OK, 1=WASPADA, 2=KRITIS.
# Nilai dan kedua ambang harus bilangan bulat.
kode_status() {
    if [ "$1" -ge "$3" ]; then
        printf '2'
    elif [ "$1" -ge "$2" ]; then
        printf '1'
    else
        printf '0'
    fi
}

# nama_status KODE: bentuk teks dari kode status.
nama_status() {
    case "$1" in
        1) printf 'WASPADA' ;;
        2) printf 'KRITIS' ;;
        *) printf 'OK' ;;
    esac
}

# --- fungsi log, state, dan alert ------------------------------------------

# catat STATUS METRIK NILAI: tambah satu baris log terstruktur.
catat() {
    printf '%s %-7s %-11s %s\n' "$STAMP" "$1" "$2" "$3" >> "$LOG_FILE"
}

# baca_hitung KUNCI: berapa kali berturut-turut KUNCI melewati ambang.
baca_hitung() {
    [ -f "$STATE_FILE" ] || return 0
    awk -v k="$1" '$1 == k { print $2; exit }' "$STATE_FILE"
}

# warna_status STATUS: teks status, diberi warna ANSI hanya bila stdout
# adalah terminal — output yang dialihkan ke berkas tetap polos.
warna_status() {
    if [ -t 1 ]; then
        case "$1" in
            WASPADA) printf '\033[33m%s\033[0m' "$1" ;;
            KRITIS)  printf '\033[31m%s\033[0m' "$1" ;;
            *)       printf '%s' "$1" ;;
        esac
    else
        printf '%s' "$1"
    fi
}

# --- kumpulkan data --------------------------------------------------------

load_tampil=$(awk '{ print $1 }' "$loadavg_file")
load100=$(ambil_load100)
if [ -z "$load100" ]; then
    printf 'monitor.sh: %s tidak berisi load average\n' "$loadavg_file" >&2
    exit 3
fi

mem100=$(ambil_mem100)
if [ -z "$mem100" ]; then
    printf 'monitor.sh: %s tidak berisi MemTotal/MemAvailable\n' "$meminfo_file" >&2
    exit 3
fi

load_warn100=$(ke_ratus "$LOAD_WARN")
load_kritis100=$(ke_ratus "$LOAD_KRITIS")

# Mount yang tidak ada di data df ditolak di sini, sebelum laporan
# dicetak, supaya tabel tidak tercetak setengah jalan.
# shellcheck disable=SC2086  # daftar mount memang dipisah spasi
for mnt in $DISK_MOUNTS; do
    if [ -z "$(ambil_disk100 "$mnt")" ]; then
        printf 'monitor.sh: mount %s tidak ditemukan di data df (periksa DISK_MOUNTS)\n' "$mnt" >&2
        exit 3
    fi
done

# --- periksa tiap metrik: laporan + log + alert ----------------------------

terburuk=0
: > "$STATE_TMP"

# periksa METRIK TAMPIL NILAI WASPADA KRITIS LABEL_AMBANG
periksa() {
    local metrik=$1 tampil=$2 nilai=$3 warn=$4 kritis=$5 label=$6
    local kode status n

    kode=$(kode_status "$nilai" "$warn" "$kritis")
    status=$(nama_status "$kode")
    if (( kode > terburuk )); then
        terburuk=$kode
    fi

    printf '%-10s %7s %-12s %s\n' "$metrik" "$tampil" "$label" \
        "$(warna_status "$status")"
    catat "$status" "$metrik" "$tampil"

    n=$(baca_hitung "$metrik")
    n=${n:-0}
    if [ "$status" != "OK" ]; then
        n=$((n + 1))
        printf '%s %s\n' "$metrik" "$n" >> "$STATE_TMP"
        if [ "$n" -eq "$ALERT_REPEAT" ]; then
            printf 'monitor.sh: ALERT %s = %s melewati ambang %sx berturut-turut\n' \
                "$metrik" "$tampil" "$ALERT_REPEAT" >&2
            catat ALERT "$metrik" "$tampil"
        fi
    fi
}

printf '%-10s %7s %-12s %s\n' 'Metrik' 'Nilai' 'Ambang W/K' 'Status'
printf '%.0s-' {1..39}; printf '\n'

periksa cpu-load "$load_tampil" "$load100" "$load_warn100" "$load_kritis100" \
    "$LOAD_WARN/$LOAD_KRITIS"
periksa memori "${mem100}%" "$mem100" "$MEM_WARN_PCT" "$MEM_KRITIS_PCT" \
    "${MEM_WARN_PCT}%/${MEM_KRITIS_PCT}%"

# shellcheck disable=SC2086  # daftar mount memang dipisah spasi
for mnt in $DISK_MOUNTS; do
    disk100=$(ambil_disk100 "$mnt")
    periksa "disk:$mnt" "${disk100}%" "$disk100" \
        "$DISK_WARN_PCT" "$DISK_KRITIS_PCT" \
        "${DISK_WARN_PCT}%/${DISK_KRITIS_PCT}%"
done

printf 'kesimpulan: %s\n' "$(nama_status "$terburuk")"

mv "$STATE_TMP" "$STATE_FILE"
exit "$terburuk"
BASH
cat > proyek/config/monitor.conf <<'CONF'
LOAD_WARN="1.50"
LOAD_KRITIS="3.00"
MEM_WARN_PCT=80
MEM_KRITIS_PCT=90
DISK_WARN_PCT=80
DISK_KRITIS_PCT=90
DISK_MOUNTS="/ /home"
LOG_FILE="monitor.log"
LOG_MAX_BYTES=1048576
LOG_KEEP=3
ALERT_REPEAT=3
CONF
cat > proyek/tests/run-tests.sh <<'TESTS'
#!/usr/bin/env bash
# run-tests.sh — test suite untuk monitor.sh.
# Suite menyiapkan sendiri folder kerja terisolasi (mktemp) berisi
# fixture /proc dan df yang dibekukan, menyalin src/monitor.sh dan
# config/monitor.conf, lalu menjalankan rangkaian pengujian dengan
# assertion sederhana. Semua eksekusi memakai MONITOR_STAMP tetap
# supaya isi log dan pesan bisa diramalkan.
# Jalankan dari mana saja: bash tests/run-tests.sh

set -u

SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
PROJ_DIR=$(dirname "$SCRIPT_DIR")

PASS=0
FAIL=0

# assert_eq <harapan> <nyata> <pesan>: bandingkan dua nilai.
assert_eq() {
    if [ "$1" = "$2" ]; then
        PASS=$((PASS + 1))
        printf 'OK    %s\n' "$3"
    else
        FAIL=$((FAIL + 1))
        printf 'GAGAL %s: harapan %s, nyata %s\n' "$3" "$1" "$2"
    fi
}

# --- siapkan folder kerja terisolasi ------------------------------------
WORK=$(mktemp -d)
cp "$PROJ_DIR/src/monitor.sh" "$PROJ_DIR/config/monitor.conf" "$WORK/"
cd "$WORK" || exit 1

# --- fixture: tiga keadaan sistem yang dibekukan ------------------------
# waspada: load 2.50, memori 84%, / 48%, /home 89%
mkdir -p waspada
printf '2.50 2.10 1.60 4/812 34567\n' > waspada/loadavg
cat > waspada/meminfo <<'EOF'
MemTotal:        8000000 kB
MemFree:          950000 kB
MemAvailable:    1280000 kB
Buffers:          210000 kB
Cached:           700000 kB
SwapCached:            0 kB
SwapTotal:       2000000 kB
SwapFree:        2000000 kB
EOF
cat > waspada/df.txt <<'EOF'
Filesystem     1024-blocks      Used Available Capacity Mounted on
/dev/sda1         82043440  36919548  40942056      48% /
tmpfs              4042268         0   4042268       0% /dev/shm
/dev/sda2        412849860 348092000  43741860      89% /home
EOF

# sehat: load 0.40, memori 42%, / 48%, /home 51%
mkdir -p sehat
printf '0.40 0.35 0.30 1/512 21001\n' > sehat/loadavg
sed 's/1280000/4640000/' waspada/meminfo > sehat/meminfo
sed 's| 89% /home| 51% /home|' waspada/df.txt > sehat/df.txt

# kritis: load 4.10, memori 95%, / 48%, /home 91%
mkdir -p kritis
printf '4.10 3.20 2.10 9/640 55002\n' > kritis/loadavg
sed 's/1280000/400000/' waspada/meminfo > kritis/meminfo
sed 's| 89% /home| 91% /home|' waspada/df.txt > kritis/df.txt

# --- uji 1: parsing nilai dari fixture ----------------------------------
MONITOR_STAMP=2026-01-15T09:00:00+07:00 bash monitor.sh --data-dir waspada \
    > laporan.txt
assert_eq 1 "$?" "eksekusi waspada keluar 1"
assert_eq 2.50 "$(awk '$1 == "cpu-load" { print $2 }' laporan.txt)" "load 1 menit terbaca 2.50"
assert_eq 84% "$(awk '$1 == "memori" { print $2 }' laporan.txt)" "memori terpakai 84 persen"
assert_eq 48% "$(awk '$1 == "disk:/" { print $2 }' laporan.txt)" "disk / terpakai 48 persen"
assert_eq 89% "$(awk '$1 == "disk:/home" { print $2 }' laporan.txt)" "disk /home terpakai 89 persen"

# --- uji 2: status per metrik pada tiga keadaan --------------------------
MONITOR_STAMP=2026-01-15T09:05:00+07:00 bash monitor.sh --data-dir sehat \
    > sehat.txt
assert_eq 0 "$?" "eksekusi sehat keluar 0"
for m in cpu-load memori disk:/ disk:/home; do
    assert_eq OK "$(awk -v m="$m" '$1 == m { print $NF }' sehat.txt)" "status $m OK saat sehat"
done

MONITOR_STAMP=2026-01-15T09:10:00+07:00 bash monitor.sh --data-dir kritis \
    > kritis.txt
assert_eq 2 "$?" "eksekusi kritis keluar 2"
assert_eq KRITIS "$(awk '$1 == "cpu-load" { print $NF }' kritis.txt)" "status cpu-load KRITIS"
assert_eq KRITIS "$(awk '$1 == "memori" { print $NF }' kritis.txt)" "status memori KRITIS"
assert_eq OK "$(awk '$1 == "disk:/" { print $NF }' kritis.txt)" "status disk / tetap OK"
assert_eq KRITIS "$(awk '$1 == "disk:/home" { print $NF }' kritis.txt)" "status disk /home KRITIS"

# --- uji 3: laporan waspada memuat campuran status -----------------------
assert_eq WASPADA "$(awk '$1 == "cpu-load" { print $NF }' laporan.txt)" "status cpu-load WASPADA"
assert_eq OK "$(awk '$1 == "disk:/" { print $NF }' laporan.txt)" "status disk / OK saat waspada"

# --- uji 4: log terstruktur ditulis --------------------------------------
jumlah=$(wc -l < monitor.log)
assert_eq 12 "$jumlah" "tiga eksekusi menulis 12 baris log"
jumlah=$(grep -c '^2026-01-15T09:00:00+07:00' monitor.log)
assert_eq 4 "$jumlah" "satu eksekusi menulis 4 baris berstempel sama"
jumlah=$(grep -c ' ALERT ' monitor.log)
assert_eq 0 "$jumlah" "belum ada baris ALERT sebelum hitungan cukup"

# --- uji 5: alert terkirim setelah N kali berturut-turut ------------------
# Hitungan dimulai dari nol; tiga bentuk pemanggilan sumber data
# diuji bergantian pada empat eksekusi berikut.
rm -f monitor.state
: > err.txt
MONITOR_STAMP=2026-01-15T10:00:00+07:00 bash monitor.sh --data-dir waspada \
    > /dev/null 2>> err.txt
assert_eq 1 "$?" "run 1 (--data-dir) keluar 1"
MONITOR_STAMP=2026-01-15T10:01:00+07:00 bash monitor.sh --data-dir=waspada \
    > /dev/null 2>> err.txt
assert_eq 1 "$?" "run 2 (--data-dir=DIR) keluar 1"
MONITOR_SOURCE=waspada MONITOR_STAMP=2026-01-15T10:02:00+07:00 bash monitor.sh \
    > /dev/null 2>> err.txt
assert_eq 1 "$?" "run 3 (MONITOR_SOURCE) keluar 1"
MONITOR_STAMP=2026-01-15T10:03:00+07:00 bash monitor.sh --data-dir waspada \
    > /dev/null 2>> err.txt
assert_eq 1 "$?" "run 4 keluar 1 tanpa alert baru"
assert_eq 4 "$(awk '$1 == "cpu-load" { print $2 }' monitor.state)" "hitungan cpu-load naik sampai 4"
jumlah=$(grep -c ALERT err.txt)
assert_eq 3 "$jumlah" "alert terkirim tepat sekali per metrik"
grep -q '^monitor.sh: ALERT cpu-load = 2.50 melewati ambang 3x' err.txt
assert_eq 0 "$?" "isi alert menyebut metrik dan nilainya"
MONITOR_STAMP=2026-01-15T10:04:00+07:00 bash monitor.sh --data-dir sehat \
    > /dev/null
assert_eq 0 "$?" "eksekusi sehat setelah deret waspada keluar 0"
[ ! -s monitor.state ]
assert_eq 0 "$?" "state dikosongkan saat semua metrik kembali OK"

# --- uji 6: rotasi log ----------------------------------------------------
cat > rotasi.conf <<'CONF'
LOAD_WARN="1.50"
LOAD_KRITIS="3.00"
MEM_WARN_PCT=80
MEM_KRITIS_PCT=90
DISK_WARN_PCT=80
DISK_KRITIS_PCT=90
DISK_MOUNTS="/ /home"
LOG_FILE="log/monitor.log"
LOG_MAX_BYTES=1
LOG_KEEP=3
ALERT_REPEAT=3
CONF
for i in 1 2 3; do
    MONITOR_CONF=rotasi.conf MONITOR_STAMP=2026-01-15T11:00:00+07:00 \
        bash monitor.sh --data-dir sehat > /dev/null 2>&1
done
[ -f log/monitor.log.1 ]
assert_eq 0 "$?" "rotasi menghasilkan monitor.log.1"
[ -f log/monitor.log.2 ]
assert_eq 0 "$?" "rotasi kedua menghasilkan monitor.log.2"
[ ! -f log/monitor.log.3 ]
assert_eq 0 "$?" "LOG_KEEP=3 menahan log.3"
jumlah=$(wc -l < log/monitor.log)
assert_eq 4 "$jumlah" "log aktif berisi 4 baris setelah rotasi"

# --- uji 7: konfigurasi dan sumber tidak valid keluar 3 -------------------
MONITOR_CONF=halang.conf bash monitor.sh --data-dir waspada > /dev/null 2>&1
assert_eq 3 "$?" "berkas konfigurasi hilang keluar 3"
bash monitor.sh --data-dir waspada --bogus > /dev/null 2>&1
assert_eq 3 "$?" "argumen tidak dikenal keluar 3"
cat > terbalik.conf <<'CONF'
LOAD_WARN="3.00"
LOAD_KRITIS="1.50"
MEM_WARN_PCT=80
MEM_KRITIS_PCT=90
DISK_WARN_PCT=80
DISK_KRITIS_PCT=90
DISK_MOUNTS="/ /home"
LOG_FILE="monitor.log"
LOG_MAX_BYTES=1048576
LOG_KEEP=3
ALERT_REPEAT=3
CONF
MONITOR_CONF=terbalik.conf bash monitor.sh --data-dir waspada > /dev/null 2>&1
assert_eq 3 "$?" "ambang waspada >= kritis ditolak"
sed 's/MEM_WARN_PCT=80/MEM_WARN_PCT=delapan/' monitor.conf > rusak.conf
MONITOR_CONF=rusak.conf bash monitor.sh --data-dir waspada > /dev/null 2>&1
assert_eq 3 "$?" "ambang bukan angka ditolak"
cat > salah-mount.conf <<'CONF'
LOAD_WARN="1.50"
LOAD_KRITIS="3.00"
MEM_WARN_PCT=80
MEM_KRITIS_PCT=90
DISK_WARN_PCT=80
DISK_KRITIS_PCT=90
DISK_MOUNTS="/ /data"
LOG_FILE="log2/monitor.log"
LOG_MAX_BYTES=1048576
LOG_KEEP=3
ALERT_REPEAT=3
CONF
MONITOR_CONF=salah-mount.conf bash monitor.sh --data-dir waspada > /dev/null 2>&1
assert_eq 3 "$?" "mount yang tidak ada di df ditolak"
mkdir -p rusak
printf '2.50 2.10 1.60 4/812 34567\n' > rusak/loadavg
printf 'MemTotal:        8000000 kB\n' > rusak/meminfo
cp waspada/df.txt rusak/df.txt
bash monitor.sh --data-dir rusak > /dev/null 2>&1
assert_eq 3 "$?" "meminfo tanpa MemAvailable ditolak"

# --- ringkasan --------------------------------------------------------------
if [ "$FAIL" -eq 0 ]; then
    printf 'PASSED: %d/%d test lulus\n' "$PASS" "$PASS"
else
    printf 'FAILED: %d/%d test lulus\n' "$PASS" "$((PASS + FAIL))"
fi
cd / && rm -rf "$WORK"
[ "$FAIL" -eq 0 ]
TESTS
bash proyek/tests/run-tests.sh

Output yang Dihasilkan:

text
OK    eksekusi waspada keluar 1
OK    load 1 menit terbaca 2.50
OK    memori terpakai 84 persen
OK    disk / terpakai 48 persen
OK    disk /home terpakai 89 persen
OK    eksekusi sehat keluar 0
OK    status cpu-load OK saat sehat
OK    status memori OK saat sehat
OK    status disk:/ OK saat sehat
OK    status disk:/home OK saat sehat
OK    eksekusi kritis keluar 2
OK    status cpu-load KRITIS
OK    status memori KRITIS
OK    status disk / tetap OK
OK    status disk /home KRITIS
OK    status cpu-load WASPADA
OK    status disk / OK saat waspada
OK    tiga eksekusi menulis 12 baris log
OK    satu eksekusi menulis 4 baris berstempel sama
OK    belum ada baris ALERT sebelum hitungan cukup
OK    run 1 (--data-dir) keluar 1
OK    run 2 (--data-dir=DIR) keluar 1
OK    run 3 (MONITOR_SOURCE) keluar 1
OK    run 4 keluar 1 tanpa alert baru
OK    hitungan cpu-load naik sampai 4
OK    alert terkirim tepat sekali per metrik
OK    isi alert menyebut metrik dan nilainya
OK    eksekusi sehat setelah deret waspada keluar 0
OK    state dikosongkan saat semua metrik kembali OK
OK    rotasi menghasilkan monitor.log.1
OK    rotasi kedua menghasilkan monitor.log.2
OK    LOG_KEEP=3 menahan log.3
OK    log aktif berisi 4 baris setelah rotasi
OK    berkas konfigurasi hilang keluar 3
OK    argumen tidak dikenal keluar 3
OK    ambang waspada >= kritis ditolak
OK    ambang bukan angka ditolak
OK    mount yang tidak ada di df ditolak
OK    meminfo tanpa MemAvailable ditolak
PASSED: 39/39 test lulus

Beberapa pola suite ini layak dicermati:

  1. Semua eksekusi memakai fixture: tiga keadaan sistem (waspada, sehat, kritis) dibekukan sejak awal, sehingga assertion seperti “status harus WASPADA” membandingkan perilaku skrip, bukan kebetulan mesin sedang ramai.
  2. MONITOR_STAMP dikunci di tiap eksekusi: isi log bisa diramalkan sampai ke karakternya — assertion menghitung baris dengan wc -l dan menyaring dengan grep, keduanya hanya mungkin bila stempelnya pasti.
  3. Episode alert diuji penuh: empat eksekusi berturut-turut pada fixture waspada membuktikan urutan sunyi-sunyi-bersuara-sunyi; run keempat tanpa alert baru adalah pembuktian bahwa alert terkirim sekali per episode, bukan sekali per run.
  4. Pemulihan juga diuji: setelah episode waspada, fixture sehat dijalankan dan suite memastikan berkas state kosong kembali — hitungan yang tidak mereset akan membuat alert berikutnya datang terlalu cepat.
  5. Konfigurasi buruk diuji satu per satu: ambang terbalik, ambang bukan angka, mount tak dikenal, berkas konfigurasi hilang, dan meminfo tanpa MemAvailable — semuanya harus keluar kode 3 dengan pesan yang jelas, bukan menghasilkan laporan menyesatkan.

Menjadwalkan dan Mengirim Notifikasi #

Skrip selesai; tinggal menghidupkannya berkala. Cara paling ringkas adalah cron — jalankan crontab -e lalu tambah satu baris, misalnya setiap lima menit:

text
*/5 * * * * cd /home/budi/monitor && bash monitor.sh

Ganti /home/budi dengan home Anda, dan ingat cron memakai PATH minimal sehingga jalur absolut lebih aman. Dengan interval lima menit dan ALERT_REPEAT=3, notifikasi akhirnya terkirim sekitar lima belas menit setelah masalah mulai — padatkan jadwal atau longgarkan ALERT_REPEAT untuk sensitivitas yang berbeda. Pola penjadwalan ini dibahas lebih lanjut pada Chapter Scheduling Scripts .

Bagaimana alert sampai ke ponsel Anda? Skrip ini sengaja berhenti di stderr — itu titik sambung yang netral. Cron biasanya mengirimkan output sebuah pekerjaan lewat email lokal bila MTA terpasang, sehingga baris alert ikut terkirim tanpa satu baris kode tambahan. Alternatif lain yang umum: systemd timer dengan OnFailure, atau skrip pembungkus yang menyalurkan stderr ke program pengirim notifikasi — misalnya utilitas mail dari MTA lokal, atau curl ke API layanan pesan. Setiap jalur bergantung pada infrastruktur yang Anda miliki, jadi pilihlah yang benar-benar tersedia di lingkungan Anda lalu uji pengirimannya dengan fixture --data-dir sebelum mengandalkannya. Ringkasan praktis seluruh penggunaan ini tersedia di docs/README.txt pada paket berkas halaman ini.

Skrip monitor kini lengkap dan terbukti: data dikumpulkan dari sumber yang bisa diganti, ambang tervalidasi dari konfigurasi, laporan dan exit code konsisten, log terstruktur terisi, alert hanya bersuara untuk masalah yang bertahan, dan seluruhnya dijaga test suite. Kemampuan yang sama — membaca kondisi sistem, memutuskan, mencatat, melaporkan dengan exit code — akan dirangkai menjadi satu pekerjaan pemeliharaan terjadwal pada proyek berikutnya, Otomasi Pemeliharaan Sistem , tempat pemeriksaan-pemeriksaan seperti ini digabung dengan pembersihan dan laporan berkala.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

src/monitor.sh

Unduh berkas
bash
#!/usr/bin/env bash
# monitor.sh — pemantau resource sederhana untuk admin kecil.
#
# Setiap eksekusi mengumpulkan tiga metrik — load average 1 menit,
# persentase memori terpakai, dan persentase disk terpakai per mount
# point dari konfigurasi — membandingkannya dengan ambang waspada dan
# kritis dari berkas konfigurasi, mencetak laporan berbentuk tabel,
# menulis log terstruktur, dan mengirim alert ke stderr setelah satu
# metrik melewati ambang beberapa kali berturut-turut. Berjalan sebagai
# user biasa — tidak butuh root.
#
# Pemakaian:
#   bash monitor.sh [--data-dir DIR]
#
#   --data-dir DIR  baca loadavg, meminfo, dan df.txt dari DIR alih-
#                   alih dari /proc dan df sungguhan (untuk pengujian).
#
# Variabel lingkungan (berguna untuk pengujian):
#   MONITOR_CONF    berkas konfigurasi yang dibaca (default: monitor.conf)
#   MONITOR_SOURCE  sama dengan --data-dir (opsi menang bila keduanya diisi)
#   MONITOR_STAMP   stempel waktu ISO untuk log (default: waktu sekarang)
#
# Kode keluar:
#   0 semua metrik OK        1 ada metrik WASPADA
#   2 ada metrik KRITIS      3 konfigurasi atau sumber data tidak valid

set -u

CONF="${MONITOR_CONF:-monitor.conf}"
STAMP="${MONITOR_STAMP:-$(date +%Y-%m-%dT%H:%M:%S%:z)}"
data_dir="${MONITOR_SOURCE:-}"

usage() {
    printf 'Pemakaian: bash monitor.sh [--data-dir DIR]\n'
    printf '\n'
    printf 'Memeriksa load average, memori, dan disk menurut ambang di\n'
    printf 'monitor.conf, mencetak laporan, menulis log, dan mengirim\n'
    printf 'alert setelah satu metrik melewati ambang beberapa kali\n'
    printf 'berturut-turut.\n'
    printf '\n'
    printf '  --data-dir DIR  baca data tiruan dari DIR (untuk pengujian)\n'
    printf '  -h, --help      tampilkan bantuan ini\n'
    printf '\n'
    printf 'Kode keluar: 0 semua OK, 1 ada WASPADA, 2 ada KRITIS,\n'
    printf '3 konfigurasi atau sumber data tidak valid.\n'
}

while [ $# -gt 0 ]; do
    case "$1" in
        --data-dir)
            if [ $# -lt 2 ]; then
                printf 'monitor.sh: --data-dir membutuhkan argumen DIR\n' >&2
                exit 3
            fi
            data_dir=$2
            shift 2
            ;;
        --data-dir=*)
            data_dir=${1#--data-dir=}
            shift
            ;;
        -h|--help)
            usage
            exit 0
            ;;
        *)
            printf 'monitor.sh: argumen tidak dikenal: %s\n' "$1" >&2
            exit 3
            ;;
    esac
done

# angka_desimal V: sukses bila V bilangan desimal sederhana seperti
# "1.50" atau "3" — tanpa tanda, tanpa titik ganda, tanpa titik di ujung.
angka_desimal() {
    case "$1" in
        ''|*[!0-9.]*) return 1 ;;
        *.*.*)        return 1 ;;
        .*|*.|*.)     return 1 ;;
    esac
    return 0
}

# angka_persen V: sukses bila V bilangan bulat 1 sampai 99.
angka_persen() {
    case "$1" in
        ''|*[!0-9]*) return 1 ;;
    esac
    [ "$1" -ge 1 ] && [ "$1" -le 99 ]
}

# angka_bulat V: sukses bila V bilangan bulat 1 atau lebih.
angka_bulat() {
    case "$1" in
        ''|*[!0-9]*|0) return 1 ;;
    esac
    return 0
}

# --- baca dan validasi konfigurasi ---------------------------------------
if [ ! -f "$CONF" ]; then
    printf 'monitor.sh: berkas konfigurasi %s tidak ditemukan\n' "$CONF" >&2
    exit 3
fi
# shellcheck source=/dev/null
. "$CONF"

gagal_konfig() {
    printf 'monitor.sh: %s (periksa %s)\n' "$1" "$CONF" >&2
    exit 3
}

angka_desimal "${LOAD_WARN:-}"     || gagal_konfig 'LOAD_WARN harus angka desimal, mis. 1.50'
angka_desimal "${LOAD_KRITIS:-}"   || gagal_konfig 'LOAD_KRITIS harus angka desimal, mis. 3.00'
angka_persen  "${MEM_WARN_PCT:-}"    || gagal_konfig 'MEM_WARN_PCT harus bilangan bulat 1-99'
angka_persen  "${MEM_KRITIS_PCT:-}"  || gagal_konfig 'MEM_KRITIS_PCT harus bilangan bulat 1-99'
angka_persen  "${DISK_WARN_PCT:-}"   || gagal_konfig 'DISK_WARN_PCT harus bilangan bulat 1-99'
angka_persen  "${DISK_KRITIS_PCT:-}" || gagal_konfig 'DISK_KRITIS_PCT harus bilangan bulat 1-99'
angka_bulat   "${ALERT_REPEAT:-}"    || gagal_konfig 'ALERT_REPEAT harus bilangan bulat 1 atau lebih'
angka_bulat   "${LOG_KEEP:-}"        || gagal_konfig 'LOG_KEEP harus bilangan bulat 1 atau lebih'
angka_bulat   "${LOG_MAX_BYTES:-}"   || gagal_konfig 'LOG_MAX_BYTES harus bilangan bulat 1 atau lebih'
[ -n "${DISK_MOUNTS:-}" ] || gagal_konfig 'DISK_MOUNTS belum diisi'
[ -n "${LOG_FILE:-}" ]   || gagal_konfig 'LOG_FILE belum diisi'

if ! awk -v a="$LOAD_WARN" -v b="$LOAD_KRITIS" 'BEGIN { exit (a < b) ? 0 : 1 }'; then
    gagal_konfig 'LOAD_WARN harus lebih kecil dari LOAD_KRITIS'
fi
[ "${MEM_WARN_PCT}" -lt "${MEM_KRITIS_PCT}" ] ||
    gagal_konfig 'MEM_WARN_PCT harus lebih kecil dari MEM_KRITIS_PCT'
[ "${DISK_WARN_PCT}" -lt "${DISK_KRITIS_PCT}" ] ||
    gagal_konfig 'DISK_WARN_PCT harus lebih kecil dari DISK_KRITIS_PCT'

# --- sumber data: /proc sungguhan atau fixture pengujian ------------------
if [ -n "$data_dir" ]; then
    loadavg_file="$data_dir/loadavg"
    meminfo_file="$data_dir/meminfo"
    df_file="$data_dir/df.txt"
    for f in "$loadavg_file" "$meminfo_file" "$df_file"; do
        if [ ! -r "$f" ]; then
            printf 'monitor.sh: sumber data %s tidak terbaca\n' "$f" >&2
            exit 3
        fi
    done
    df_output=$(cat "$df_file")
else
    loadavg_file=/proc/loadavg
    meminfo_file=/proc/meminfo
    df_output=$(df -P)
fi

# --- siapkan berkas log, state, dan rotasi --------------------------------
LOG_DIR=$(dirname "$LOG_FILE")
mkdir -p "$LOG_DIR" || exit 3
STATE_FILE="$LOG_DIR/monitor.state"
STATE_TMP="$STATE_FILE.tmp"

if [ -f "$LOG_FILE" ]; then
    ukuran=$(stat -c %s "$LOG_FILE")
    if [ "$ukuran" -gt "$LOG_MAX_BYTES" ]; then
        i=$LOG_KEEP
        while [ "$i" -gt 1 ]; do
            [ -f "$LOG_FILE.$((i - 1))" ] &&
                mv "$LOG_FILE.$((i - 1))" "$LOG_FILE.$i"
            i=$((i - 1))
        done
        mv "$LOG_FILE" "$LOG_FILE.1"
    fi
fi

# --- fungsi pengumpul data ------------------------------------------------

# ambil_load100: load average 1 menit dikali 100 (2.50 menjadi 250).
ambil_load100() {
    awk '{ printf "%.0f", $1 * 100 }' "$loadavg_file"
}

# ke_ratus ANGKA: kalikan angka desimal dengan 100 menjadi bilangan bulat.
ke_ratus() {
    awk -v v="$1" 'BEGIN { printf "%.0f", v * 100 }'
}

# ambil_mem100: persen memori terpakai. MemAvailable dipakai karena sudah
# memperhitungkan cache yang bisa direklamasi — bukan sekadar MemFree.
ambil_mem100() {
    awk '
        $1 == "MemTotal:"     { total = $2 }
        $1 == "MemAvailable:" { avail = $2; ada = 1 }
        END { if (total > 0 && ada) printf "%.0f", (total - avail) * 100 / total }
    ' "$meminfo_file"
}

# ambil_disk100 MOUNT: persen terpakai untuk MOUNT dari output df.
ambil_disk100() {
    printf '%s\n' "$df_output" |
        awk -v mnt="$1" '
            $6 == mnt { gsub(/%/, "", $5); print $5 + 0; exit }
        '
}

# --- fungsi status ---------------------------------------------------------

# kode_status NILAI WASPADA KRITIS: 0=OK, 1=WASPADA, 2=KRITIS.
# Nilai dan kedua ambang harus bilangan bulat.
kode_status() {
    if [ "$1" -ge "$3" ]; then
        printf '2'
    elif [ "$1" -ge "$2" ]; then
        printf '1'
    else
        printf '0'
    fi
}

# nama_status KODE: bentuk teks dari kode status.
nama_status() {
    case "$1" in
        1) printf 'WASPADA' ;;
        2) printf 'KRITIS' ;;
        *) printf 'OK' ;;
    esac
}

# --- fungsi log, state, dan alert ------------------------------------------

# catat STATUS METRIK NILAI: tambah satu baris log terstruktur.
catat() {
    printf '%s %-7s %-11s %s\n' "$STAMP" "$1" "$2" "$3" >> "$LOG_FILE"
}

# baca_hitung KUNCI: berapa kali berturut-turut KUNCI melewati ambang.
baca_hitung() {
    [ -f "$STATE_FILE" ] || return 0
    awk -v k="$1" '$1 == k { print $2; exit }' "$STATE_FILE"
}

# warna_status STATUS: teks status, diberi warna ANSI hanya bila stdout
# adalah terminal — output yang dialihkan ke berkas tetap polos.
warna_status() {
    if [ -t 1 ]; then
        case "$1" in
            WASPADA) printf '\033[33m%s\033[0m' "$1" ;;
            KRITIS)  printf '\033[31m%s\033[0m' "$1" ;;
            *)       printf '%s' "$1" ;;
        esac
    else
        printf '%s' "$1"
    fi
}

# --- kumpulkan data --------------------------------------------------------

load_tampil=$(awk '{ print $1 }' "$loadavg_file")
load100=$(ambil_load100)
if [ -z "$load100" ]; then
    printf 'monitor.sh: %s tidak berisi load average\n' "$loadavg_file" >&2
    exit 3
fi

mem100=$(ambil_mem100)
if [ -z "$mem100" ]; then
    printf 'monitor.sh: %s tidak berisi MemTotal/MemAvailable\n' "$meminfo_file" >&2
    exit 3
fi

load_warn100=$(ke_ratus "$LOAD_WARN")
load_kritis100=$(ke_ratus "$LOAD_KRITIS")

# Mount yang tidak ada di data df ditolak di sini, sebelum laporan
# dicetak, supaya tabel tidak tercetak setengah jalan.
# shellcheck disable=SC2086  # daftar mount memang dipisah spasi
for mnt in $DISK_MOUNTS; do
    if [ -z "$(ambil_disk100 "$mnt")" ]; then
        printf 'monitor.sh: mount %s tidak ditemukan di data df (periksa DISK_MOUNTS)\n' "$mnt" >&2
        exit 3
    fi
done

# --- periksa tiap metrik: laporan + log + alert ----------------------------

terburuk=0
: > "$STATE_TMP"

# periksa METRIK TAMPIL NILAI WASPADA KRITIS LABEL_AMBANG
periksa() {
    local metrik=$1 tampil=$2 nilai=$3 warn=$4 kritis=$5 label=$6
    local kode status n

    kode=$(kode_status "$nilai" "$warn" "$kritis")
    status=$(nama_status "$kode")
    if (( kode > terburuk )); then
        terburuk=$kode
    fi

    printf '%-10s %7s %-12s %s\n' "$metrik" "$tampil" "$label" \
        "$(warna_status "$status")"
    catat "$status" "$metrik" "$tampil"

    n=$(baca_hitung "$metrik")
    n=${n:-0}
    if [ "$status" != "OK" ]; then
        n=$((n + 1))
        printf '%s %s\n' "$metrik" "$n" >> "$STATE_TMP"
        if [ "$n" -eq "$ALERT_REPEAT" ]; then
            printf 'monitor.sh: ALERT %s = %s melewati ambang %sx berturut-turut\n' \
                "$metrik" "$tampil" "$ALERT_REPEAT" >&2
            catat ALERT "$metrik" "$tampil"
        fi
    fi
}

printf '%-10s %7s %-12s %s\n' 'Metrik' 'Nilai' 'Ambang W/K' 'Status'
printf '%.0s-' {1..39}; printf '\n'

periksa cpu-load "$load_tampil" "$load100" "$load_warn100" "$load_kritis100" \
    "$LOAD_WARN/$LOAD_KRITIS"
periksa memori "${mem100}%" "$mem100" "$MEM_WARN_PCT" "$MEM_KRITIS_PCT" \
    "${MEM_WARN_PCT}%/${MEM_KRITIS_PCT}%"

# shellcheck disable=SC2086  # daftar mount memang dipisah spasi
for mnt in $DISK_MOUNTS; do
    disk100=$(ambil_disk100 "$mnt")
    periksa "disk:$mnt" "${disk100}%" "$disk100" \
        "$DISK_WARN_PCT" "$DISK_KRITIS_PCT" \
        "${DISK_WARN_PCT}%/${DISK_KRITIS_PCT}%"
done

printf 'kesimpulan: %s\n' "$(nama_status "$terburuk")"

mv "$STATE_TMP" "$STATE_FILE"
exit "$terburuk"

tests/run-tests.sh

Unduh berkas
bash
#!/usr/bin/env bash
# run-tests.sh — test suite untuk monitor.sh.
# Suite menyiapkan sendiri folder kerja terisolasi (mktemp) berisi
# fixture /proc dan df yang dibekukan, menyalin src/monitor.sh dan
# config/monitor.conf, lalu menjalankan rangkaian pengujian dengan
# assertion sederhana. Semua eksekusi memakai MONITOR_STAMP tetap
# supaya isi log dan pesan bisa diramalkan.
# Jalankan dari mana saja: bash tests/run-tests.sh

set -u

SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
PROJ_DIR=$(dirname "$SCRIPT_DIR")

PASS=0
FAIL=0

# assert_eq <harapan> <nyata> <pesan>: bandingkan dua nilai.
assert_eq() {
    if [ "$1" = "$2" ]; then
        PASS=$((PASS + 1))
        printf 'OK    %s\n' "$3"
    else
        FAIL=$((FAIL + 1))
        printf 'GAGAL %s: harapan %s, nyata %s\n' "$3" "$1" "$2"
    fi
}

# --- siapkan folder kerja terisolasi ------------------------------------
WORK=$(mktemp -d)
cp "$PROJ_DIR/src/monitor.sh" "$PROJ_DIR/config/monitor.conf" "$WORK/"
cd "$WORK" || exit 1

# --- fixture: tiga keadaan sistem yang dibekukan ------------------------
# waspada: load 2.50, memori 84%, / 48%, /home 89%
mkdir -p waspada
printf '2.50 2.10 1.60 4/812 34567\n' > waspada/loadavg
cat > waspada/meminfo <<'EOF'
MemTotal:        8000000 kB
MemFree:          950000 kB
MemAvailable:    1280000 kB
Buffers:          210000 kB
Cached:           700000 kB
SwapCached:            0 kB
SwapTotal:       2000000 kB
SwapFree:        2000000 kB
EOF
cat > waspada/df.txt <<'EOF'
Filesystem     1024-blocks      Used Available Capacity Mounted on
/dev/sda1         82043440  36919548  40942056      48% /
tmpfs              4042268         0   4042268       0% /dev/shm
/dev/sda2        412849860 348092000  43741860      89% /home
EOF

# sehat: load 0.40, memori 42%, / 48%, /home 51%
mkdir -p sehat
printf '0.40 0.35 0.30 1/512 21001\n' > sehat/loadavg
sed 's/1280000/4640000/' waspada/meminfo > sehat/meminfo
sed 's| 89% /home| 51% /home|' waspada/df.txt > sehat/df.txt

# kritis: load 4.10, memori 95%, / 48%, /home 91%
mkdir -p kritis
printf '4.10 3.20 2.10 9/640 55002\n' > kritis/loadavg
sed 's/1280000/400000/' waspada/meminfo > kritis/meminfo
sed 's| 89% /home| 91% /home|' waspada/df.txt > kritis/df.txt

# --- uji 1: parsing nilai dari fixture ----------------------------------
MONITOR_STAMP=2026-01-15T09:00:00+07:00 bash monitor.sh --data-dir waspada \
    > laporan.txt
assert_eq 1 "$?" "eksekusi waspada keluar 1"
assert_eq 2.50 "$(awk '$1 == "cpu-load" { print $2 }' laporan.txt)" "load 1 menit terbaca 2.50"
assert_eq 84% "$(awk '$1 == "memori" { print $2 }' laporan.txt)" "memori terpakai 84 persen"
assert_eq 48% "$(awk '$1 == "disk:/" { print $2 }' laporan.txt)" "disk / terpakai 48 persen"
assert_eq 89% "$(awk '$1 == "disk:/home" { print $2 }' laporan.txt)" "disk /home terpakai 89 persen"

# --- uji 2: status per metrik pada tiga keadaan --------------------------
MONITOR_STAMP=2026-01-15T09:05:00+07:00 bash monitor.sh --data-dir sehat \
    > sehat.txt
assert_eq 0 "$?" "eksekusi sehat keluar 0"
for m in cpu-load memori disk:/ disk:/home; do
    assert_eq OK "$(awk -v m="$m" '$1 == m { print $NF }' sehat.txt)" "status $m OK saat sehat"
done

MONITOR_STAMP=2026-01-15T09:10:00+07:00 bash monitor.sh --data-dir kritis \
    > kritis.txt
assert_eq 2 "$?" "eksekusi kritis keluar 2"
assert_eq KRITIS "$(awk '$1 == "cpu-load" { print $NF }' kritis.txt)" "status cpu-load KRITIS"
assert_eq KRITIS "$(awk '$1 == "memori" { print $NF }' kritis.txt)" "status memori KRITIS"
assert_eq OK "$(awk '$1 == "disk:/" { print $NF }' kritis.txt)" "status disk / tetap OK"
assert_eq KRITIS "$(awk '$1 == "disk:/home" { print $NF }' kritis.txt)" "status disk /home KRITIS"

# --- uji 3: laporan waspada memuat campuran status -----------------------
assert_eq WASPADA "$(awk '$1 == "cpu-load" { print $NF }' laporan.txt)" "status cpu-load WASPADA"
assert_eq OK "$(awk '$1 == "disk:/" { print $NF }' laporan.txt)" "status disk / OK saat waspada"

# --- uji 4: log terstruktur ditulis --------------------------------------
jumlah=$(wc -l < monitor.log)
assert_eq 12 "$jumlah" "tiga eksekusi menulis 12 baris log"
jumlah=$(grep -c '^2026-01-15T09:00:00+07:00' monitor.log)
assert_eq 4 "$jumlah" "satu eksekusi menulis 4 baris berstempel sama"
jumlah=$(grep -c ' ALERT ' monitor.log)
assert_eq 0 "$jumlah" "belum ada baris ALERT sebelum hitungan cukup"

# --- uji 5: alert terkirim setelah N kali berturut-turut ------------------
# Hitungan dimulai dari nol; tiga bentuk pemanggilan sumber data
# diuji bergantian pada empat eksekusi berikut.
rm -f monitor.state
: > err.txt
MONITOR_STAMP=2026-01-15T10:00:00+07:00 bash monitor.sh --data-dir waspada \
    > /dev/null 2>> err.txt
assert_eq 1 "$?" "run 1 (--data-dir) keluar 1"
MONITOR_STAMP=2026-01-15T10:01:00+07:00 bash monitor.sh --data-dir=waspada \
    > /dev/null 2>> err.txt
assert_eq 1 "$?" "run 2 (--data-dir=DIR) keluar 1"
MONITOR_SOURCE=waspada MONITOR_STAMP=2026-01-15T10:02:00+07:00 bash monitor.sh \
    > /dev/null 2>> err.txt
assert_eq 1 "$?" "run 3 (MONITOR_SOURCE) keluar 1"
MONITOR_STAMP=2026-01-15T10:03:00+07:00 bash monitor.sh --data-dir waspada \
    > /dev/null 2>> err.txt
assert_eq 1 "$?" "run 4 keluar 1 tanpa alert baru"
assert_eq 4 "$(awk '$1 == "cpu-load" { print $2 }' monitor.state)" "hitungan cpu-load naik sampai 4"
jumlah=$(grep -c ALERT err.txt)
assert_eq 3 "$jumlah" "alert terkirim tepat sekali per metrik"
grep -q '^monitor.sh: ALERT cpu-load = 2.50 melewati ambang 3x' err.txt
assert_eq 0 "$?" "isi alert menyebut metrik dan nilainya"
MONITOR_STAMP=2026-01-15T10:04:00+07:00 bash monitor.sh --data-dir sehat \
    > /dev/null
assert_eq 0 "$?" "eksekusi sehat setelah deret waspada keluar 0"
[ ! -s monitor.state ]
assert_eq 0 "$?" "state dikosongkan saat semua metrik kembali OK"

# --- uji 6: rotasi log ----------------------------------------------------
cat > rotasi.conf <<'CONF'
LOAD_WARN="1.50"
LOAD_KRITIS="3.00"
MEM_WARN_PCT=80
MEM_KRITIS_PCT=90
DISK_WARN_PCT=80
DISK_KRITIS_PCT=90
DISK_MOUNTS="/ /home"
LOG_FILE="log/monitor.log"
LOG_MAX_BYTES=1
LOG_KEEP=3
ALERT_REPEAT=3
CONF
for i in 1 2 3; do
    MONITOR_CONF=rotasi.conf MONITOR_STAMP=2026-01-15T11:00:00+07:00 \
        bash monitor.sh --data-dir sehat > /dev/null 2>&1
done
[ -f log/monitor.log.1 ]
assert_eq 0 "$?" "rotasi menghasilkan monitor.log.1"
[ -f log/monitor.log.2 ]
assert_eq 0 "$?" "rotasi kedua menghasilkan monitor.log.2"
[ ! -f log/monitor.log.3 ]
assert_eq 0 "$?" "LOG_KEEP=3 menahan log.3"
jumlah=$(wc -l < log/monitor.log)
assert_eq 4 "$jumlah" "log aktif berisi 4 baris setelah rotasi"

# --- uji 7: konfigurasi dan sumber tidak valid keluar 3 -------------------
MONITOR_CONF=halang.conf bash monitor.sh --data-dir waspada > /dev/null 2>&1
assert_eq 3 "$?" "berkas konfigurasi hilang keluar 3"
bash monitor.sh --data-dir waspada --bogus > /dev/null 2>&1
assert_eq 3 "$?" "argumen tidak dikenal keluar 3"
cat > terbalik.conf <<'CONF'
LOAD_WARN="3.00"
LOAD_KRITIS="1.50"
MEM_WARN_PCT=80
MEM_KRITIS_PCT=90
DISK_WARN_PCT=80
DISK_KRITIS_PCT=90
DISK_MOUNTS="/ /home"
LOG_FILE="monitor.log"
LOG_MAX_BYTES=1048576
LOG_KEEP=3
ALERT_REPEAT=3
CONF
MONITOR_CONF=terbalik.conf bash monitor.sh --data-dir waspada > /dev/null 2>&1
assert_eq 3 "$?" "ambang waspada >= kritis ditolak"
sed 's/MEM_WARN_PCT=80/MEM_WARN_PCT=delapan/' monitor.conf > rusak.conf
MONITOR_CONF=rusak.conf bash monitor.sh --data-dir waspada > /dev/null 2>&1
assert_eq 3 "$?" "ambang bukan angka ditolak"
cat > salah-mount.conf <<'CONF'
LOAD_WARN="1.50"
LOAD_KRITIS="3.00"
MEM_WARN_PCT=80
MEM_KRITIS_PCT=90
DISK_WARN_PCT=80
DISK_KRITIS_PCT=90
DISK_MOUNTS="/ /data"
LOG_FILE="log2/monitor.log"
LOG_MAX_BYTES=1048576
LOG_KEEP=3
ALERT_REPEAT=3
CONF
MONITOR_CONF=salah-mount.conf bash monitor.sh --data-dir waspada > /dev/null 2>&1
assert_eq 3 "$?" "mount yang tidak ada di df ditolak"
mkdir -p rusak
printf '2.50 2.10 1.60 4/812 34567\n' > rusak/loadavg
printf 'MemTotal:        8000000 kB\n' > rusak/meminfo
cp waspada/df.txt rusak/df.txt
bash monitor.sh --data-dir rusak > /dev/null 2>&1
assert_eq 3 "$?" "meminfo tanpa MemAvailable ditolak"

# --- ringkasan --------------------------------------------------------------
if [ "$FAIL" -eq 0 ]; then
    printf 'PASSED: %d/%d test lulus\n' "$PASS" "$PASS"
else
    printf 'FAILED: %d/%d test lulus\n' "$PASS" "$((PASS + FAIL))"
fi
cd / && rm -rf "$WORK"
[ "$FAIL" -eq 0 ]