Tugas terjadwal punya sifat yang membuatnya berbeda dari tugas manual: ia berjalan saat kita tidak melihat. Pukul 08:00 kita mungkin sedang menyalakan mesin, menyeduh kopi, atau masih di jalan — dan tidak ada yang berdiri di depan layar menonton rsync bekerja. Satu-satunya saksi adalah log. Pada bagian ini kita memberi tugas kita saksi yang berkualitas: log terstruktur satu baris per eksekusi, lalu skrip cek-jadwal.sh yang membacanya dan menjawab pertanyaan admin yang sesungguhnya — jadwal saya sehat, macet, atau sekarat?

Log Terstruktur per Eksekusi #

Setiap eksekusi tugas.sh menambahkan tepat satu baris ke log/tugas.log. Formatnya sengaja dipipahkan (|) agar setiap field bisa diambil tanpa ambigu — awk -F'|', cut -d'|', maupun grep semuanya nyaman dengannya:

FieldContohArti
120260116T080000waktu eksekusi (STAMP, format YYYYMMDDTHHMMSS)
2OK / GAGALhasil eksekusi
3keluar=0kode keluar tugas
4durasi=0slama eksekusi
5–7total=3 pindah=1 hapus=0ringkasan rsync (hanya pada baris OK)

Baris GAGAL memuat pesan penyebab menggantikan ringkasan — misalnya sumber tidak ditemukan: /home/budi/otomasi/sumber. Blok berikut menjalankan sesi tiga hari yang sengaja berisi kegagalan: hari pertama sukses, hari kedua folder sumber sengaja dipindahkan sejenak sehingga tugas gagal, hari ketiga sumber kembali dan tugas pulih:

bash
mkdir -p otomasi/sumber/laporan
printf 'daftar tugas minggu ini\n' > otomasi/sumber/tugas.txt
printf 'laporan penjualan kuartal pertama\n' > otomasi/sumber/laporan/penjualan.txt
printf 'catatan rapat tim mingguan\n' > otomasi/sumber/laporan/rapat.txt

cat > otomasi/tugas.sh <<'BASH'
#!/usr/bin/env bash
# tugas.sh — tugas terjadwal: sinkronisasi folder sumber ke mirror.
#
# Tugas ini dirancang untuk dipanggil scheduler (cron / systemd timer):
#   - idempoten: berapa kali pun dijalankan, keadaan akhir mirror sama,
#   - lokasi kerja dihitung dari letak skrip, bukan cwd pemanggil,
#     sehingga aman dipanggil dari environment minimal milik cron,
#   - setiap eksekusi menuliskan SATU baris log terstruktur yang bisa
#     dibaca cek-jadwal.sh.
#
# Pemakaian:
#   tugas.sh [--dry-run] [--verbose]
#
# Opsi:
#   --dry-run   tampilkan perubahan yang akan dilakukan tanpa menjalankannya
#   --verbose   tampilkan daftar perubahan per berkas
#
# Variabel lingkungan (berguna untuk pengujian deterministik):
#   TUGAS_SRC   folder sumber  (default: <letak skrip>/sumber)
#   TUGAS_DEST  folder mirror  (default: <letak skrip>/mirror)
#   TUGAS_LOG   berkas log    (default: <letak skrip>/log/tugas.log)
#   STAMP       waktu eksekusi, format YYYYMMDDTHHMMSS (default: sekarang)
#
# Kode keluar:
#   0 sukses                       1 opsi tidak dikenal
#   2 folder sumber tidak ada      3 rsync gagal
#
# Catatan: kunci anti-dobel (flock) sengaja TIDAK ada di skrip ini;
# kunci dipasang di lapisan scheduler (baris crontab / ExecStart unit)
# supaya satu berkas kunci melindungi kedua cara penjadwalan.

set -u

# Lokasi kerja dihitung dari letak skrip, bukan cwd pemanggil.
BASE=$(cd "$(dirname "$0")" && pwd)
TUGAS_SRC="${TUGAS_SRC:-$BASE/sumber}"
TUGAS_DEST="${TUGAS_DEST:-$BASE/mirror}"
TUGAS_LOG="${TUGAS_LOG:-$BASE/log/tugas.log}"
STAMP="${STAMP:-$(date +%Y%m%dT%H%M%S)}"

DRY_RUN=0
VERBOSE=0
for arg in "$@"; do
    case $arg in
        --dry-run) DRY_RUN=1 ;;
        --verbose) VERBOSE=1 ;;
        *)
            printf 'tugas.sh: opsi tidak dikenal: %s\n' "$arg" >&2
            printf 'pemakaian: tugas.sh [--dry-run] [--verbose]\n' >&2
            exit 1
            ;;
    esac
done

# waktu() : bentuk terbaca dari STAMP untuk baris-ringkasan di stdout.
waktu() {
    printf '%s-%s-%s %s:%s:%s' \
        "${STAMP:0:4}" "${STAMP:4:2}" "${STAMP:6:2}" \
        "${STAMP:9:2}" "${STAMP:11:2}" "${STAMP:13:2}"
}

# Siapkan folder log sebelum bisa mencatat apa pun.
mkdir -p "$(dirname "$TUGAS_LOG")"
SECONDS=0

# --- 1. Validasi sumber --------------------------------------------------
if [ ! -d "$TUGAS_SRC" ]; then
    printf '%s|GAGAL|keluar=2|durasi=%ds|sumber tidak ditemukan: %s\n' \
        "$STAMP" "${TUGAS_DURASI:-$SECONDS}" "$TUGAS_SRC" >> "$TUGAS_LOG"
    printf 'tugas.sh: folder sumber tidak ditemukan: %s\n' "$TUGAS_SRC" >&2
    exit 2
fi

# --- 2. Sinkronisasi rsync ------------------------------------------------
# --itemize-changes membuat rsync mencetak satu baris per perubahan
# (">f..." berkas dipindah, "*deleting" berkas dihapus); baris itulah
# yang dihitung menjadi ringkasan.
Opsi=(-a --delete --itemize-changes)
[ "$DRY_RUN" -eq 1 ] && Opsi+=(--dry-run)

if ! ubah=$(rsync "${Opsi[@]}" "$TUGAS_SRC/" "$TUGAS_DEST/"); then
    printf '%s|GAGAL|keluar=3|durasi=%ds|rsync gagal\n' \
        "$STAMP" "${TUGAS_DURASI:-$SECONDS}" >> "$TUGAS_LOG"
    printf 'tugas.sh: rsync gagal\n' >&2
    exit 3
fi

total=$(find "$TUGAS_SRC" -type f | wc -l)
pindah=$(printf '%s\n' "$ubah" | grep -c '^[<>]f' || true)
hapus=$(printf '%s\n' "$ubah" | grep -c '^\*deleting' || true)

# --- 3. Dry-run: pratinjau tanpa mengubah apa pun, termasuk log -----------
if [ "$DRY_RUN" -eq 1 ]; then
    printf '[%s] dry-run: %s -> %s\n' "$(waktu)" "$TUGAS_SRC" "$TUGAS_DEST"
    [ -n "$ubah" ] && printf '%s\n' "$ubah"
    printf '[%s] tidak ada yang diubah (dry-run)\n' "$(waktu)"
    exit 0
fi

# --- 4. Catat log terstruktur + ringkasan ---------------------------------
printf '%s|OK|keluar=0|durasi=%ds|total=%d|pindah=%d|hapus=%d\n' \
    "$STAMP" "${TUGAS_DURASI:-$SECONDS}" "$total" "$pindah" "$hapus" >> "$TUGAS_LOG"

printf '[%s] mulai sinkronisasi: %s -> %s\n' "$(waktu)" "$TUGAS_SRC" "$TUGAS_DEST"
[ "$VERBOSE" -eq 1 ] && [ -n "$ubah" ] && printf '%s\n' "$ubah"
printf '[%s] %d berkas diperiksa, %d dipindah, %d dihapus\n' \
    "$(waktu)" "$total" "$pindah" "$hapus"
printf '[%s] selesai: OK (%d detik)\n' "$(waktu)" "${TUGAS_DURASI:-$SECONDS}"
exit 0
BASH

echo "== hari 1: sukses =="
STAMP=20260114T080000 TUGAS_DURASI=0 bash otomasi/tugas.sh
echo "== hari 2: folder sumber lenyap sejenak =="
mv otomasi/sumber otomasi/sumber-pindah
STAMP=20260115T080000 TUGAS_DURASI=1 bash otomasi/tugas.sh 2>&1
echo "keluar=$?"
mv otomasi/sumber-pindah otomasi/sumber
echo "== hari 3: sumber kembali, tugas pulih =="
STAMP=20260116T080000 TUGAS_DURASI=0 bash otomasi/tugas.sh
echo "== log terstruktur hasil tiga hari =="
cat otomasi/log/tugas.log

Output yang Dihasilkan:

text
== hari 1: sukses ==
[2026-01-14 08:00:00] mulai sinkronisasi: /home/budi/otomasi/sumber -> /home/budi/otomasi/mirror
[2026-01-14 08:00:00] 3 berkas diperiksa, 3 dipindah, 0 dihapus
[2026-01-14 08:00:00] selesai: OK (0 detik)
== hari 2: folder sumber lenyap sejenak ==
tugas.sh: folder sumber tidak ditemukan: /home/budi/otomasi/sumber
keluar=2
== hari 3: sumber kembali, tugas pulih ==
[2026-01-16 08:00:00] mulai sinkronisasi: /home/budi/otomasi/sumber -> /home/budi/otomasi/mirror
[2026-01-16 08:00:00] 3 berkas diperiksa, 0 dipindah, 0 dihapus
[2026-01-16 08:00:00] selesai: OK (0 detik)
== log terstruktur hasil tiga hari ==
20260114T080000|OK|keluar=0|durasi=0s|total=3|pindah=3|hapus=0
20260115T080000|GAGAL|keluar=2|durasi=1s|sumber tidak ditemukan: /home/budi/otomasi/sumber
20260116T080000|OK|keluar=0|durasi=0s|total=3|pindah=0|hapus=0

Tiga baris terakhir blok itu adalah cerita lengkap tiga hari: sukses dengan pindah=3, kegagalan dengan penyebabnya tercatat lengkap beserta kode keluar, lalu pemulihan tanpa sisa. Perhatikan bahwa kegagalan hari kedua tetap meninggalkan mirror kemarin utuh — inilah gunanya rsync yang tidak pernah menghapus mirror saat sumber tidak bisa dibaca, karena ia gagal sebelum menyentuh apa pun.

cek-jadwal.sh: Membaca Log Menjadi Status #

Log yang baik masih berupa data; admin butuh kesimpulan. Skrip cek-jadwal.sh membaca seluruh baris log, melacak empat hal — eksekusi terakhir, sukses terakhir, umurnya, dan jumlah gagal berturut-turut — lalu menyimpulkan status dengan kode keluar yang bisa dibaca scheduler lain. Variabel NOW berperan sebagai “waktu sekarang” sehingga pengujian umur log deterministik. Blok berikut menulis skrip lengkapnya (identik dengan src/cek-jadwal.sh) dan mencobanya pada tiga berkas log fixture: sehat, terlambat, dan gagal berturut:

bash
cat > cek-jadwal.sh <<'BASH'
#!/usr/bin/env bash
# cek-jadwal.sh — periksa kesehatan tugas terjadwal dari berkas lognya.
#
# Membaca log terstruktur tugas.sh dan menjawab tiga pertanyaan admin:
# kapan eksekusi terakhir, berapa jam sejak sukses terakhir, dan sudah
# berapa kali gagal berturut-turut. Cocok dijalankan manual saat curiga
# jadwal macet, atau dijadwalkan sendiri sebagai tugas kedua.
#
# Pemakaian:
#   cek-jadwal.sh <berkas-log> [batas-jam]
#
#   batas-jam : berapa jam tanpa sukses sebelum dianggap TERLAMPAT
#               (default 25: jadwal harian + toleransi 1 jam)
#   NOW       : variabel lingkungan "waktu sekarang", format
#               YYYYMMDDTHHMMSS (default: waktu nyata). Dipakai agar
#               pengujian umur log deterministik.
#
# Kode keluar:
#   0 SEHAT                        1 TERLAMPAT / belum ada eksekusi
#   2 GAGAL berturut-turut         3 berkas log tidak ditemukan

set -u

LOG="${1:-}"
BATAS="${2:-25}"
NOW="${NOW:-$(date +%Y%m%dT%H%M%S)}"

if [ -z "$LOG" ]; then
    printf 'pemakaian: cek-jadwal.sh <berkas-log> [batas-jam]\n' >&2
    exit 3
fi
if [ ! -f "$LOG" ]; then
    printf 'cek-jadwal.sh: berkas log tidak ditemukan: %s\n' "$LOG" >&2
    exit 3
fi

# ke_epoch() : ubah STAMP (YYYYMMDDTHHMMSS) menjadi detik epoch (UTC).
ke_epoch() {
    date -u -d "${1:0:4}-${1:4:2}-${1:6:2} ${1:9:2}:${1:11:2}:${1:13:2}" +%s
}

# tampil() : bentuk terbaca dari STAMP untuk laporan.
tampil() {
    printf '%s-%s-%s %s:%s:%s' \
        "${1:0:4}" "${1:4:2}" "${1:6:2}" "${1:9:2}" "${1:11:2}" "${1:13:2}"
}

# --- 1. Baca seluruh baris log -------------------------------------------
n=0
terakhir_status=""
terakhir_stamp=""
terakhir_keluar=""
sukses_stamp=""
berturut=0

while IFS='|' read -r stamp status sisa; do
    # Hanya baris berformat STAMP|STATUS|... yang diperhitungkan.
    case $stamp in
        [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T*) ;;
        *) continue ;;
    esac
    n=$((n + 1))
    terakhir_stamp=$stamp
    terakhir_status=$status
    keluar=${sisa%%|*}
    terakhir_keluar=${keluar#keluar=}
    if [ "$status" = "OK" ]; then
        sukses_stamp=$stamp
        berturut=0
    elif [ "$status" = "GAGAL" ]; then
        berturut=$((berturut + 1))
    fi
done < "$LOG"

# --- 2. Laporan -----------------------------------------------------------
printf 'log      : %s\n' "$LOG"
printf 'eksekusi : %d\n' "$n"

if [ "$n" -eq 0 ]; then
    printf 'status   : BELUM — belum ada eksekusi tercatat\n'
    exit 1
fi

if [ "$terakhir_status" = "GAGAL" ]; then
    printf 'terakhir : GAGAL pada %s (keluar=%s)\n' \
        "$(tampil "$terakhir_stamp")" "$terakhir_keluar"
else
    printf 'terakhir : OK pada %s\n' "$(tampil "$terakhir_stamp")"
fi

if [ -n "$sukses_stamp" ]; then
    sukses_epoch=$(ke_epoch "$sukses_stamp")
    umur=$(( ($(ke_epoch "$NOW") - sukses_epoch) / 3600 ))
    printf 'sukses   : %s (%d jam lalu)\n' "$(tampil "$sukses_stamp")" "$umur"
else
    umur=999999
    printf 'sukses   : belum pernah\n'
fi
printf 'berturut : %d gagal beruntun\n' "$berturut"

# --- 3. Klasifikasi -------------------------------------------------------
# Gagal berturut-turut paling parah: tugas jalan tapi selalu gagal.
# Umur melebihi batas: tugas tidak jalan (atau jalan tanpa hasil).
if [ "$berturut" -ge 2 ]; then
    printf 'status   : GAGAL — %d kali gagal berturut-turut\n' "$berturut"
    exit 2
fi
if [ -z "$sukses_stamp" ]; then
    printf 'status   : TERLAMPAT — belum pernah sukses (batas %d jam)\n' "$BATAS"
    exit 1
fi
if [ "$umur" -gt "$BATAS" ]; then
    printf 'status   : TERLAMBAT — %d jam tanpa sukses (batas %d jam)\n' \
        "$umur" "$BATAS"
    exit 1
fi
printf 'status   : SEHAT — sukses terakhir %d jam lalu\n' "$umur"
exit 0
BASH

cat > log-sehat.log <<'FIX'
20260113T080000|OK|keluar=0|durasi=0s|total=3|pindah=0|hapus=0
20260114T080000|OK|keluar=0|durasi=0s|total=3|pindah=1|hapus=0
20260115T080000|OK|keluar=0|durasi=0s|total=3|pindah=0|hapus=0
20260116T080000|OK|keluar=0|durasi=0s|total=3|pindah=2|hapus=0
FIX
cat > log-terlambat.log <<'FIX'
20260112T080000|OK|keluar=0|durasi=0s|total=3|pindah=0|hapus=0
20260113T080000|OK|keluar=0|durasi=0s|total=3|pindah=2|hapus=1
FIX
cat > log-gagal.log <<'FIX'
20260114T080000|OK|keluar=0|durasi=0s|total=3|pindah=0|hapus=0
20260115T080000|GAGAL|keluar=3|durasi=0s|rsync gagal
20260116T080000|GAGAL|keluar=3|durasi=0s|rsync gagal
FIX

echo "== kondisi 1: sehat =="
NOW=20260116T120000 bash cek-jadwal.sh log-sehat.log; echo "keluar=$?"
echo "== kondisi 2: terlambat (log berhenti) =="
NOW=20260116T120000 bash cek-jadwal.sh log-terlambat.log; echo "keluar=$?"
echo "== kondisi 3: gagal berturut-turut =="
NOW=20260116T120000 bash cek-jadwal.sh log-gagal.log; echo "keluar=$?"

Output yang Dihasilkan:

text
== kondisi 1: sehat ==
log      : log-sehat.log
eksekusi : 4
terakhir : OK pada 2026-01-16 08:00:00
sukses   : 2026-01-16 08:00:00 (4 jam lalu)
berturut : 0 gagal beruntun
status   : SEHAT — sukses terakhir 4 jam lalu
keluar=0
== kondisi 2: terlambat (log berhenti) ==
log      : log-terlambat.log
eksekusi : 2
terakhir : OK pada 2026-01-13 08:00:00
sukses   : 2026-01-13 08:00:00 (76 jam lalu)
berturut : 0 gagal beruntun
status   : TERLAMBAT — 76 jam tanpa sukses (batas 25 jam)
keluar=1
== kondisi 3: gagal berturut-turut ==
log      : log-gagal.log
eksekusi : 3
terakhir : GAGAL pada 2026-01-16 08:00:00 (keluar=3)
sukses   : 2026-01-14 08:00:00 (52 jam lalu)
berturut : 2 gagal beruntun
status   : GAGAL — 2 kali gagal berturut-turut
keluar=2

Bedah skripnya:

  1. while IFS='|' read -r stamp status sisa: setiap baris dipecah sekali jalan; pola case $stamp in [0-9]...T*) menyaring baris yang tidak berformat sehingga log yang disisipi catatan manual tetap aman dibaca.
  2. Empat pelacak: terakhir_* diisi setiap baris, sukses_stamp hanya saat OK, dan berturut bertambah saat GAGAL lalu nol lagi saat OK — karena itu satu kegagalan yang dipulihkan tidak memicu status GAGAL.
  3. ke_epoch() lewat date -u -d: waktu diubah ke detik epoch secara UTC. Zona waktu tidak jadi soal karena selisih dua waktu dikonversi dengan cara yang sama — yang penting konsisten.
  4. umur=$(( (... ) / 3600 )): selisih detik dibagi 3600 dengan pembagian bulat menjadi jam; batas bawaan 25 jam memberi toleransi satu jam untuk jadwal harian.
  5. Urutan klasifikasi: gagal berturut-turut dinilai paling parah (tugas jalan tapi rusak — keluar 2), disusul terlambat (tugas tidak jalan — keluar 1), selebihnya sehat (keluar 0). Kode keluar ini menjadikan skrip layak dijadwalkan sendiri sebagai penjaga tugas pertama.

Diagnosa Empat Gejala Klasik #

Empat keluhan paling umum tentang tugas terjadwal dan jalur diagnosisnya:

Tidak jalan sama sekali #

Gejalanya: mirror tidak pernah terbentuk, log/tugas.log kosong, dan cek-jadwal.sh menjawab BELUM atau TERLAMPAT. Periksa berjenjang dari luar ke dalam: apakah jadwalnya masih terpasang (crontab -l, systemctl list-timers — kolom LAST yang lama adalah petunjuk kuat), apakah daemon-nya hidup (systemctl status cron), lalu apakah environment cocok — skrip yang jalan mulus saat manual tetapi diam di scheduler hampir selalu kalah oleh PATH atau cwd milik cron. Di situlah letak kegunaan dua keputusan kita sejak Bagian 1: jalur absolut di baris jadwal dan skrip yang menghitung sendiri lokasinya.

Jalan, tapi gagal #

Gejalanya: baris GAGAL bermunculan di log terstruktur. keluar=N sudah menyebut jenisnya (2 sumber hilang, 3 rsync gagal); untuk pesan lengkapnya, baca output mentah yang ditangkap redirect — tail log/tugas-output.log untuk cron, atau journalctl -u tugas-sinkron.service -n 30 untuk timer. Berikut contoh tampilan eksekusi sukses yang sama di journald:

text
Jan 16 08:00:01 server-kantor systemd[1]: Starting tugas-sinkron.service - Tugas sinkronisasi folder sumber ke mirror...
Jan 16 08:00:01 server-kantor bash[812]: [2026-01-16 08:00:01] mulai sinkronisasi: /home/budi/otomasi/sumber -> /home/budi/otomasi/mirror
Jan 16 08:00:01 server-kantor bash[812]: [2026-01-16 08:00:01] 3 berkas diperiksa, 1 dipindah, 0 dihapus
Jan 16 08:00:01 server-kantor systemd[1]: Finished tugas-sinkron.service - Tugas sinkronisasi folder sumber ke mirror.

Jalan dobel #

Gejalanya: log berisi dua eksekusi pada jam yang sama atau proses tugas bertumpuk. Penyebabnya dua: tugas yang makin lambat bertemu jadwal yang rapat, atau dua scheduler terpasang untuk tugas yang sama. Kedua jadwal kita sudah kebal berkat flock -n pada satu berkas kunci — eksekusi kedua ditolak sebelum menyentuh apa pun, dan buktinya khas: pemanggilan yang ditolak tidak meninggalkan baris log sama sekali, persis seperti demo penutup Bagian 2. Kalau Anda mendapati jadwal tanpa flock, cukup tambahkan pembungkus itu sebelum tugasnya.

Terlewat saat mesin mati #

Gejalanya: laptop dimatikan semalam, jadwal 08:00 terlewat, dan log tidak punya baris untuk pagi itu. Cron tidak mengejar jadwal yang lewat — laptop yang jarang menyala pada jam jadwal lebih cocok memakai anacron atau pindah ke systemd timer. Timer kita memasang Persistent=true: begitu mesin menyala, jadwal yang terlewat langsung dijalankan sekali. Perlu diketahui batasnya: pengejaran hanya sekali, bukan sekali per hari yang terlewat — tiga hari mati tetap menghasilkan satu eksekusi pemulihan.

Kerangka otomasi kita kini lengkap dari ujung ke ujung: tugas yang idempoten, dua cara penjadwalan yang tervalidasi, kunci yang menolak tabrakan, log yang bisa dibaca mesin, dan alat diagnosis yang menyimpulkannya. Keterampilan inti yang dibawa proyek ini — menjaga sebuah layanan tetap hidup, terjadwal, dan terpantau — adalah bekal langsung untuk proyek penutup section ini, Firewall dan Hardening Server , tempat kita mengamankan server dengan disiplin yang sama: aturan default-deny yang teruji, bukan yang sekadar terpasang.

Berkas Sumber

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

src/cek-jadwal.sh

Unduh berkas
bash
#!/usr/bin/env bash
# cek-jadwal.sh — periksa kesehatan tugas terjadwal dari berkas lognya.
#
# Membaca log terstruktur tugas.sh dan menjawab tiga pertanyaan admin:
# kapan eksekusi terakhir, berapa jam sejak sukses terakhir, dan sudah
# berapa kali gagal berturut-turut. Cocok dijalankan manual saat curiga
# jadwal macet, atau dijadwalkan sendiri sebagai tugas kedua.
#
# Pemakaian:
#   cek-jadwal.sh <berkas-log> [batas-jam]
#
#   batas-jam : berapa jam tanpa sukses sebelum dianggap TERLAMPAT
#               (default 25: jadwal harian + toleransi 1 jam)
#   NOW       : variabel lingkungan "waktu sekarang", format
#               YYYYMMDDTHHMMSS (default: waktu nyata). Dipakai agar
#               pengujian umur log deterministik.
#
# Kode keluar:
#   0 SEHAT                        1 TERLAMPAT / belum ada eksekusi
#   2 GAGAL berturut-turut         3 berkas log tidak ditemukan

set -u

LOG="${1:-}"
BATAS="${2:-25}"
NOW="${NOW:-$(date +%Y%m%dT%H%M%S)}"

if [ -z "$LOG" ]; then
    printf 'pemakaian: cek-jadwal.sh <berkas-log> [batas-jam]\n' >&2
    exit 3
fi
if [ ! -f "$LOG" ]; then
    printf 'cek-jadwal.sh: berkas log tidak ditemukan: %s\n' "$LOG" >&2
    exit 3
fi

# ke_epoch() : ubah STAMP (YYYYMMDDTHHMMSS) menjadi detik epoch (UTC).
ke_epoch() {
    date -u -d "${1:0:4}-${1:4:2}-${1:6:2} ${1:9:2}:${1:11:2}:${1:13:2}" +%s
}

# tampil() : bentuk terbaca dari STAMP untuk laporan.
tampil() {
    printf '%s-%s-%s %s:%s:%s' \
        "${1:0:4}" "${1:4:2}" "${1:6:2}" "${1:9:2}" "${1:11:2}" "${1:13:2}"
}

# --- 1. Baca seluruh baris log -------------------------------------------
n=0
terakhir_status=""
terakhir_stamp=""
terakhir_keluar=""
sukses_stamp=""
berturut=0

while IFS='|' read -r stamp status sisa; do
    # Hanya baris berformat STAMP|STATUS|... yang diperhitungkan.
    case $stamp in
        [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T*) ;;
        *) continue ;;
    esac
    n=$((n + 1))
    terakhir_stamp=$stamp
    terakhir_status=$status
    keluar=${sisa%%|*}
    terakhir_keluar=${keluar#keluar=}
    if [ "$status" = "OK" ]; then
        sukses_stamp=$stamp
        berturut=0
    elif [ "$status" = "GAGAL" ]; then
        berturut=$((berturut + 1))
    fi
done < "$LOG"

# --- 2. Laporan -----------------------------------------------------------
printf 'log      : %s\n' "$LOG"
printf 'eksekusi : %d\n' "$n"

if [ "$n" -eq 0 ]; then
    printf 'status   : BELUM — belum ada eksekusi tercatat\n'
    exit 1
fi

if [ "$terakhir_status" = "GAGAL" ]; then
    printf 'terakhir : GAGAL pada %s (keluar=%s)\n' \
        "$(tampil "$terakhir_stamp")" "$terakhir_keluar"
else
    printf 'terakhir : OK pada %s\n' "$(tampil "$terakhir_stamp")"
fi

if [ -n "$sukses_stamp" ]; then
    sukses_epoch=$(ke_epoch "$sukses_stamp")
    umur=$(( ($(ke_epoch "$NOW") - sukses_epoch) / 3600 ))
    printf 'sukses   : %s (%d jam lalu)\n' "$(tampil "$sukses_stamp")" "$umur"
else
    umur=999999
    printf 'sukses   : belum pernah\n'
fi
printf 'berturut : %d gagal beruntun\n' "$berturut"

# --- 3. Klasifikasi -------------------------------------------------------
# Gagal berturut-turut paling parah: tugas jalan tapi selalu gagal.
# Umur melebihi batas: tugas tidak jalan (atau jalan tanpa hasil).
if [ "$berturut" -ge 2 ]; then
    printf 'status   : GAGAL — %d kali gagal berturut-turut\n' "$berturut"
    exit 2
fi
if [ -z "$sukses_stamp" ]; then
    printf 'status   : TERLAMPAT — belum pernah sukses (batas %d jam)\n' "$BATAS"
    exit 1
fi
if [ "$umur" -gt "$BATAS" ]; then
    printf 'status   : TERLAMBAT — %d jam tanpa sukses (batas %d jam)\n' \
        "$umur" "$BATAS"
    exit 1
fi
printf 'status   : SEHAT — sukses terakhir %d jam lalu\n' "$umur"
exit 0

src/runner.sh

Unduh berkas
bash
#!/usr/bin/env bash
# runner.sh — tiruan scheduler (cron / systemd timer) untuk tugas.sh.
#
# Lingkungan latihan tidak menjalankan crond maupun systemd timer, jadi
# runner.sh memainkan peran scheduler: memanggil tugas persis seperti
# baris crontab / ExecStart unit melakukannya — environment minimal,
# dilindungi flock -n, stdout+stderr ditangkap redirect — lalu
# melaporkan kode keluarnya. STAMP menjadi "waktu eksekusi" sehingga
# jadwal bisa direproduksi kapan pun tanpa menunggu jam 08:00.
#
# Pemakaian:
#   STAMP=20260115T080000 bash runner.sh [folder-tugas]
#
#   folder-tugas : folder berisi tugas.sh (default: folder saat ini)
#
# Kode keluar:
#   0 tugas sukses                  1 kunci flock sibuk (tugas ditolak)
#   2/3 kode keluar tugas.sh lainnya

set -u

DIR="${1:-.}"
STAMP="${STAMP:-$(date +%Y%m%dT%H%M%S)}"

mkdir -p "$DIR/log"

# env -i membersihkan lingkungan seperti milik cron: hanya PATH minimal
# dan HOME yang diteruskan, ditambah STAMP untuk determinisme.
env -i PATH=/usr/local/bin:/usr/bin:/bin HOME="$DIR" STAMP="$STAMP" \
    flock -n "$DIR/.tugas.lock" /bin/bash "$DIR/tugas.sh" \
    >> "$DIR/log/tugas-output.log" 2>&1
rc=$?

printf 'scheduler: tugas keluar=%d\n' "$rc"
exit "$rc"

src/tugas.sh

Unduh berkas
bash
#!/usr/bin/env bash
# tugas.sh — tugas terjadwal: sinkronisasi folder sumber ke mirror.
#
# Tugas ini dirancang untuk dipanggil scheduler (cron / systemd timer):
#   - idempoten: berapa kali pun dijalankan, keadaan akhir mirror sama,
#   - lokasi kerja dihitung dari letak skrip, bukan cwd pemanggil,
#     sehingga aman dipanggil dari environment minimal milik cron,
#   - setiap eksekusi menuliskan SATU baris log terstruktur yang bisa
#     dibaca cek-jadwal.sh.
#
# Pemakaian:
#   tugas.sh [--dry-run] [--verbose]
#
# Opsi:
#   --dry-run   tampilkan perubahan yang akan dilakukan tanpa menjalankannya
#   --verbose   tampilkan daftar perubahan per berkas
#
# Variabel lingkungan (berguna untuk pengujian deterministik):
#   TUGAS_SRC   folder sumber  (default: <letak skrip>/sumber)
#   TUGAS_DEST  folder mirror  (default: <letak skrip>/mirror)
#   TUGAS_LOG   berkas log    (default: <letak skrip>/log/tugas.log)
#   STAMP       waktu eksekusi, format YYYYMMDDTHHMMSS (default: sekarang)
#
# Kode keluar:
#   0 sukses                       1 opsi tidak dikenal
#   2 folder sumber tidak ada      3 rsync gagal
#
# Catatan: kunci anti-dobel (flock) sengaja TIDAK ada di skrip ini;
# kunci dipasang di lapisan scheduler (baris crontab / ExecStart unit)
# supaya satu berkas kunci melindungi kedua cara penjadwalan.

set -u

# Lokasi kerja dihitung dari letak skrip, bukan cwd pemanggil.
BASE=$(cd "$(dirname "$0")" && pwd)
TUGAS_SRC="${TUGAS_SRC:-$BASE/sumber}"
TUGAS_DEST="${TUGAS_DEST:-$BASE/mirror}"
TUGAS_LOG="${TUGAS_LOG:-$BASE/log/tugas.log}"
STAMP="${STAMP:-$(date +%Y%m%dT%H%M%S)}"

DRY_RUN=0
VERBOSE=0
for arg in "$@"; do
    case $arg in
        --dry-run) DRY_RUN=1 ;;
        --verbose) VERBOSE=1 ;;
        *)
            printf 'tugas.sh: opsi tidak dikenal: %s\n' "$arg" >&2
            printf 'pemakaian: tugas.sh [--dry-run] [--verbose]\n' >&2
            exit 1
            ;;
    esac
done

# waktu() : bentuk terbaca dari STAMP untuk baris-ringkasan di stdout.
waktu() {
    printf '%s-%s-%s %s:%s:%s' \
        "${STAMP:0:4}" "${STAMP:4:2}" "${STAMP:6:2}" \
        "${STAMP:9:2}" "${STAMP:11:2}" "${STAMP:13:2}"
}

# Siapkan folder log sebelum bisa mencatat apa pun.
mkdir -p "$(dirname "$TUGAS_LOG")"
SECONDS=0

# --- 1. Validasi sumber --------------------------------------------------
if [ ! -d "$TUGAS_SRC" ]; then
    printf '%s|GAGAL|keluar=2|durasi=%ds|sumber tidak ditemukan: %s\n' \
        "$STAMP" "${TUGAS_DURASI:-$SECONDS}" "$TUGAS_SRC" >> "$TUGAS_LOG"
    printf 'tugas.sh: folder sumber tidak ditemukan: %s\n' "$TUGAS_SRC" >&2
    exit 2
fi

# --- 2. Sinkronisasi rsync ------------------------------------------------
# --itemize-changes membuat rsync mencetak satu baris per perubahan
# (">f..." berkas dipindah, "*deleting" berkas dihapus); baris itulah
# yang dihitung menjadi ringkasan.
Opsi=(-a --delete --itemize-changes)
[ "$DRY_RUN" -eq 1 ] && Opsi+=(--dry-run)

if ! ubah=$(rsync "${Opsi[@]}" "$TUGAS_SRC/" "$TUGAS_DEST/"); then
    printf '%s|GAGAL|keluar=3|durasi=%ds|rsync gagal\n' \
        "$STAMP" "${TUGAS_DURASI:-$SECONDS}" >> "$TUGAS_LOG"
    printf 'tugas.sh: rsync gagal\n' >&2
    exit 3
fi

total=$(find "$TUGAS_SRC" -type f | wc -l)
pindah=$(printf '%s\n' "$ubah" | grep -c '^[<>]f' || true)
hapus=$(printf '%s\n' "$ubah" | grep -c '^\*deleting' || true)

# --- 3. Dry-run: pratinjau tanpa mengubah apa pun, termasuk log -----------
if [ "$DRY_RUN" -eq 1 ]; then
    printf '[%s] dry-run: %s -> %s\n' "$(waktu)" "$TUGAS_SRC" "$TUGAS_DEST"
    [ -n "$ubah" ] && printf '%s\n' "$ubah"
    printf '[%s] tidak ada yang diubah (dry-run)\n' "$(waktu)"
    exit 0
fi

# --- 4. Catat log terstruktur + ringkasan ---------------------------------
printf '%s|OK|keluar=0|durasi=%ds|total=%d|pindah=%d|hapus=%d\n' \
    "$STAMP" "${TUGAS_DURASI:-$SECONDS}" "$total" "$pindah" "$hapus" >> "$TUGAS_LOG"

printf '[%s] mulai sinkronisasi: %s -> %s\n' "$(waktu)" "$TUGAS_SRC" "$TUGAS_DEST"
[ "$VERBOSE" -eq 1 ] && [ -n "$ubah" ] && printf '%s\n' "$ubah"
printf '[%s] %d berkas diperiksa, %d dipindah, %d dihapus\n' \
    "$(waktu)" "$total" "$pindah" "$hapus"
printf '[%s] selesai: OK (%d detik)\n' "$(waktu)" "${TUGAS_DURASI:-$SECONDS}"
exit 0

tests/run-tests.sh

Unduh berkas
bash
#!/usr/bin/env bash
# run-tests.sh — test suite untuk paket otomasi-dengan-cron-dan-systemd.
#
# Suite menyiapkan folder kerja terisolasi (mktemp) beserta data
# contohnya, menyalin skrip dari src/ dan berkas dari config/, lalu
# menjalankan rangkaian pengujian dengan assertion sederhana:
#   - idempotensi tugas.sh (dua eksekusi, hasil akhir identik),
#   - --dry-run tidak mengubah mirror maupun log,
#   - baris crontab lolos validator sintaks 5-field dan benar-benar
#     bisa dijalankan (jalur disesuaikan ke folder kerja),
#   - unit systemd lolos systemd-analyze verify,
#   - flock menolak eksekusi ganda lewat baris crontab dan runner.sh,
#   - cek-jadwal.sh mengklasifikasi tiga kondisi log dengan benar.
#
# 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/tugas.sh" "$PROJ_DIR/src/cek-jadwal.sh" \
    "$PROJ_DIR/src/runner.sh" "$WORK/"
cd "$WORK" || exit 1

# Data contoh (fixture) untuk tugas sinkronisasi.
mkdir -p sumber/laporan
printf 'daftar tugas minggu ini\n' > sumber/tugas.txt
printf 'laporan penjualan kuartal pertama\n' > sumber/laporan/penjualan.txt

# --- uji 1: eksekusi pertama sukses ---------------------------------------
STAMP=20260115T080000 bash tugas.sh > /dev/null 2>&1
assert_eq 0 "$?" "eksekusi pertama keluar 0"
diff -r sumber mirror > /dev/null 2>&1
assert_eq 0 "$?" "mirror identik dengan sumber"

# --- uji 2: idempotensi — eksekusi ulang tidak mengubah hasil -------------
cp -a mirror potret-1
STAMP=20260115T080500 bash tugas.sh > /dev/null 2>&1
assert_eq 0 "$?" "eksekusi kedua keluar 0"
diff -r potret-1 mirror > /dev/null 2>&1
assert_eq 0 "$?" "eksekusi ulang tidak mengubah mirror"
pindah2=$(sed -n '2p' log/tugas.log | grep -c 'pindah=0')
assert_eq 1 "$pindah2" "eksekusi kedua tidak memindah berkas (idempoten)"

# --- uji 3: dry-run tidak mengubah mirror maupun log -----------------------
printf 'catatan baru\n' > sumber/catatan-baru.txt
n_log=$(wc -l < log/tugas.log)
STAMP=20260115T081000 bash tugas.sh --dry-run > /dev/null 2>&1
assert_eq 0 "$?" "dry-run keluar 0"
[ ! -f mirror/catatan-baru.txt ]
assert_eq 0 "$?" "dry-run tidak menyalin berkas baru"
n_log2=$(wc -l < log/tugas.log)
assert_eq "$n_log" "$n_log2" "dry-run tidak menulis log"

# --- uji 4: eksekusi berikutnya membawa berkas baru ------------------------
STAMP=20260115T081500 bash tugas.sh > /dev/null 2>&1
assert_eq 0 "$?" "eksekusi ketiga keluar 0"
diff -r sumber mirror > /dev/null 2>&1
assert_eq 0 "$?" "mirror mengikuti sumber terbaru"

# --- uji 5: opsi salah dan sumber hilang ----------------------------------
bash tugas.sh --salah-opsi > /dev/null 2>&1
assert_eq 1 "$?" "opsi tidak dikenal keluar 1"
TUGAS_SRC="$WORK/tidak-ada" STAMP=20260116T080000 bash tugas.sh > /dev/null 2>&1
assert_eq 2 "$?" "sumber hilang keluar 2"
gagal=$(tail -n 1 log/tugas.log | grep -c 'GAGAL|keluar=2')
assert_eq 1 "$gagal" "kegagalan sumber tercatat di log"

# --- uji 6: crontab lolos validator sintaks 5-field ------------------------
cat > validasi-crontab.awk <<'AWK'
# validasi-crontab.awk — validator sintaks 5-field untuk berkas crontab.

function cek_field(v, lo, hi,    daftar, n, i, bagian, pos, langkah, rentang) {
    n = split(v, daftar, ",")
    for (i = 1; i <= n; i++) {
        bagian = daftar[i]
        pos = index(bagian, "/")
        if (pos > 0) {
            langkah = substr(bagian, pos + 1)
            if (langkah !~ /^[0-9]+$/ || langkah + 0 < 1)
                return 0
            bagian = substr(bagian, 1, pos - 1)
        }
        if (bagian == "*")
            continue
        if (bagian ~ /^[0-9]+$/) {
            if (bagian + 0 < lo || bagian + 0 > hi)
                return 0
        } else if (bagian ~ /^[0-9]+-[0-9]+$/) {
            split(bagian, rentang, "-")
            if (rentang[1] + 0 < lo || rentang[2] + 0 > hi)
                return 0
            if (rentang[1] + 0 > rentang[2] + 0)
                return 0
        } else {
            return 0
        }
    }
    return 1
}

/^[ \t]*#/ || /^[ \t]*$/ { next }
/^[A-Za-z_][A-Za-z0-9_]*=/ { printf "BARIS %d: pengaturan %s\n", NR, $1; next }
{
    jumlah++
    if (NF < 6) {
        printf "BARIS %d SALAH: perlu 5 field jadwal + perintah\n", NR
        salah++
        next
    }
    if (!cek_field($1, 0, 59)) { printf "BARIS %d SALAH: menit   %s\n", NR, $1; salah++; next }
    if (!cek_field($2, 0, 23)) { printf "BARIS %d SALAH: jam     %s\n", NR, $2; salah++; next }
    if (!cek_field($3, 1, 31)) { printf "BARIS %d SALAH: tanggal %s\n", NR, $3; salah++; next }
    if (!cek_field($4, 1, 12)) { printf "BARIS %d SALAH: bulan   %s\n", NR, $4; salah++; next }
    if (!cek_field($5, 0, 7))  { printf "BARIS %d SALAH: hari    %s\n", NR, $5; salah++; next }
    printf "BARIS %d OK: %s %s %s %s %s\n", NR, $1, $2, $3, $4, $5
}
END {
    printf "%d baris jadwal diperiksa, %d SALAH\n", jumlah + 0, salah + 0
    if (salah > 0)
        exit 1
}
AWK
awk -f validasi-crontab.awk "$PROJ_DIR/config/tugas.crontab" > hasil-vaildasi.txt
assert_eq 0 "$?" "config/tugas.crontab lolos validator 5-field"
ringkasan=$(tail -n 1 hasil-vaildasi.txt)
assert_eq "1 baris jadwal diperiksa, 0 SALAH" "$ringkasan" "validator menemukan 1 jadwal valid"

cat > salah-jadwal.crontab <<'CRON'
# salah-jadwal.crontab — fixture berisi jadwal yang salah.
SHELL=/bin/bash

0 8 * * *
0 25 * * * /home/budi/otomasi/tugas.sh
61 * * * * /home/budi/otomasi/tugas.sh
CRON
awk -f validasi-crontab.awk salah-jadwal.crontab > hasil-salah.txt
assert_eq 1 "$?" "validator menolak jadwal yang salah (keluar 1)"
n_salah=$(grep -c 'SALAH:' hasil-salah.txt)
assert_eq 3 "$n_salah" "tiga baris salah terdeteksi"

# --- uji 7: baris crontab benar-benar bisa dijalankan ----------------------
# Ambil baris jadwal pertama, ganti jalur instalasi ke folder kerja,
# lalu jalankan persis seperti yang akan dilakukan cron.
baris=$(awk '!/^[ \t]*#/ && !/^[ \t]*$/ && !/^[A-Za-z_][A-Za-z0-9_]*=/ { print; exit }' \
    "$PROJ_DIR/config/tugas.crontab")
read -r _m _h _dm _mo _dw cmd <<< "$baris"
cmd=${cmd//\/home\/budi\/otomasi/$WORK}
mkdir -p log
STAMP=20260117T080000 bash -c "$cmd"
assert_eq 0 "$?" "baris crontab (jalur disesuaikan) sukses dijalankan"
n_ok=$(grep -c '^20260117T080000|OK' log/tugas.log)
assert_eq 1 "$n_ok" "eksekusi via baris crontab tercatat di log"
[ -s log/tugas-output.log ]
assert_eq 0 "$?" "output tugas tertangkap redirect crontab"

# --- uji 8: flock menolak eksekusi ganda ------------------------------------
flock "$WORK/.tugas.lock" sleep 1 &
sleep 0.3
n_sebelum=$(wc -l < log/tugas.log)
STAMP=20260117T080500 bash -c "$cmd" > /dev/null 2>&1
assert_eq 1 "$?" "baris crontab ditolak flock saat kunci sibuk"
n_sesudah=$(wc -l < log/tugas.log)
assert_eq "$n_sebelum" "$n_sesudah" "eksekusi yang ditolak tidak menulis log"
wait

# --- uji 9: unit systemd lolos verify ----------------------------------------
systemd-analyze verify "$PROJ_DIR/config/tugas-sinkron.service" \
    "$PROJ_DIR/config/tugas-sinkron.timer" > /dev/null 2>&1
assert_eq 0 "$?" "unit service+timer lolos systemd-analyze verify"

# --- uji 10: runner menjalankan tugas seperti scheduler ----------------------
STAMP=20260118T080000 bash runner.sh "$WORK" > /dev/null 2>&1
assert_eq 0 "$?" "runner menjalankan tugas seperti scheduler"
n_ok=$(grep -c '^20260118T080000|OK' log/tugas.log)
assert_eq 1 "$n_ok" "eksekusi via runner tercatat di log"

# --- uji 11: runner ditolak saat kunci sibuk (dobel) ------------------------
flock "$WORK/.tugas.lock" sleep 1 &
sleep 0.3
n_sebelum=$(wc -l < log/tugas.log)
STAMP=20260118T080500 bash runner.sh "$WORK" > /dev/null 2>&1
assert_eq 1 "$?" "runner ditolak flock saat tugas lain berjalan"
n_sesudah=$(wc -l < log/tugas.log)
assert_eq "$n_sebelum" "$n_sesudah" "runner yang ditolak tidak menulis log"
wait

# --- uji 12: cek-jadwal mengklasifikasi tiga kondisi log --------------------
cat > log-sehat.log <<'FIX'
20260113T080000|OK|keluar=0|durasi=0s|total=3|pindah=0|hapus=0
20260114T080000|OK|keluar=0|durasi=0s|total=3|pindah=1|hapus=0
20260115T080000|OK|keluar=0|durasi=0s|total=3|pindah=0|hapus=0
20260116T080000|OK|keluar=0|durasi=0s|total=3|pindah=2|hapus=0
FIX
cat > log-terlambat.log <<'FIX'
20260112T080000|OK|keluar=0|durasi=0s|total=3|pindah=0|hapus=0
20260113T080000|OK|keluar=0|durasi=0s|total=3|pindah=2|hapus=1
FIX
cat > log-gagal.log <<'FIX'
20260114T080000|OK|keluar=0|durasi=0s|total=3|pindah=0|hapus=0
20260115T080000|GAGAL|keluar=3|durasi=0s|rsync gagal
20260116T080000|GAGAL|keluar=3|durasi=0s|rsync gagal
FIX

NOW=20260116T120000 bash cek-jadwal.sh log-sehat.log > laporan-sehat.txt
assert_eq 0 "$?" "log sehat diklasifikasi SEHAT (keluar 0)"
n=$(grep -c 'sukses terakhir 4 jam lalu' laporan-sehat.txt)
assert_eq 1 "$n" "umur sukses dihitung: 4 jam"

NOW=20260116T120000 bash cek-jadwal.sh log-terlambat.log > laporan-terlambat.txt
assert_eq 1 "$?" "log berhenti diklasifikasi TERLAMBAT (keluar 1)"
n=$(grep -c '76 jam tanpa sukses' laporan-terlambat.txt)
assert_eq 1 "$n" "umur tanpa sukses dihitung: 76 jam"

NOW=20260116T120000 bash cek-jadwal.sh log-gagal.log > laporan-gagal.txt
assert_eq 2 "$?" "log gagal berturut diklasifikasi GAGAL (keluar 2)"
n=$(grep -c '2 kali gagal berturut-turut' laporan-gagal.txt)
assert_eq 1 "$n" "gagal berturut terdeteksi: 2 kali"

bash cek-jadwal.sh log-tidak-ada.log > /dev/null 2>&1
assert_eq 3 "$?" "berkas log yang hilang dilaporkan (keluar 3)"

# --- 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 ]